diff --git a/README.md b/README.md index ffa53eb..23a2bbb 100644 --- a/README.md +++ b/README.md @@ -80,3 +80,38 @@ curl -X POST -d '{"slug": "foo"}' http://api.local.ethui.dev:4000/stacks - **** (subgraph RPC client) - **** (IPFS) - **** (explorer) + +## MCP + +The server speaks [MCP](https://modelcontextprotocol.io) over streamable HTTP at +`/mcp` on the api host, so an agent can provision sandboxes, drive them and hand +back explorer links a human can open. + +Authentication is the same 7-day JWT as the REST api: + +```bash +curl -X POST https://api.stacks.ethui.dev/auth/send-code -d '{"email":"you@example.com"}' +curl -X POST https://api.stacks.ethui.dev/auth/verify-code -d '{"email":"you@example.com","code":"123456"}' +``` + +```json +{ + "mcpServers": { + "ethui-stacks": { + "type": "http", + "url": "https://api.stacks.ethui.dev/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Tools: + +- lifecycle: `create_stack` `list_stacks` `delete_stack` +- reads: `get_block` `get_transaction` `get_address` `get_logs` +- writes: `simulate_call` `execute` — raw calldata, `from` is impersonated so no key is needed +- cheatcodes: `impersonate` `set_balance` `mine` `set_block_timestamp` `snapshot` `revert` + +ABI encoding and decoding are deliberately out of scope: calldata goes in raw, so +the caller stays free to use `cast`, viem, or whatever it already has. diff --git a/server/config/runtime.exs b/server/config/runtime.exs index 5fe7a77..b3a63c2 100644 --- a/server/config/runtime.exs +++ b/server/config/runtime.exs @@ -25,6 +25,8 @@ if jwt_secret = System.get_env("JWT_SECRET") do config :ethui, :jwt_secret, jwt_secret end +config :ethui, :explorer_base, System.get_env("EXPLORER_BASE", "https://explorer.ethui.dev") + is_saas? = !!System.get_env("ETHUI_STACKS_SAAS") config :ethui, EthuiWeb.Plugs.Authenticate, enabled: is_saas? diff --git a/server/lib/ethui/accounts/user.ex b/server/lib/ethui/accounts/user.ex index beeaa01..fcd5d2d 100644 --- a/server/lib/ethui/accounts/user.ex +++ b/server/lib/ethui/accounts/user.ex @@ -7,6 +7,8 @@ defmodule Ethui.Accounts.User do import Ecto.Changeset alias Ethui.Stacks.Stack + @type t :: %__MODULE__{} + schema "users" do field(:email, :string) field(:verification_code, :string) diff --git a/server/lib/ethui/application.ex b/server/lib/ethui/application.ex index 8a172eb..ef71edb 100644 --- a/server/lib/ethui/application.ex +++ b/server/lib/ethui/application.ex @@ -18,7 +18,8 @@ defmodule Ethui.Application do # {Ethui.Worker, arg}, # Start to serve requests, typically the last entry EthuiWeb.Endpoint, - Ethui.Stacks.Supervisor + Ethui.Stacks.Supervisor, + {Ethui.MCP.Server, transport: :streamable_http} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/server/lib/ethui/chain.ex b/server/lib/ethui/chain.ex new file mode 100644 index 0000000..f47605d --- /dev/null +++ b/server/lib/ethui/chain.ex @@ -0,0 +1,86 @@ +defmodule Ethui.Chain do + @moduledoc """ + JSON-RPC client for a stack's anvil instance. + + Talks to the process-local anvil port instead of the public proxy url, so no + api key or round trip through the reverse proxy is involved. + """ + + alias Ethui.Stacks.Server + + @receive_timeout :timer.seconds(30) + @error_string_selector "08c379a0" + + @spec call(String.t(), String.t(), list) :: {:ok, term} | {:error, String.t()} + def call(slug, method, params \\ []) do + with {:ok, url} <- anvil_url(slug) do + request(url, method, params) + end + end + + @doc "Converts an integer to the 0x-prefixed hex quantity the JSON-RPC api expects" + @spec hex(integer) :: String.t() + def hex(n) when is_integer(n), do: "0x" <> (n |> Integer.to_string(16) |> String.downcase()) + + @doc "Normalizes a user-supplied block reference into a JSON-RPC block parameter" + @spec block_param(String.t()) :: String.t() + def block_param(block) when block in ~w(latest earliest pending safe finalized), do: block + def block_param("0x" <> _ = block), do: block + + def block_param(block) do + case Integer.parse(block) do + {n, ""} -> hex(n) + _ -> block + end + end + + defp anvil_url(slug) do + case Server.anvil_url(slug) do + {:ok, url} -> {:ok, url} + {:error, reason} -> {:error, "stack #{slug} is not reachable: #{reason}"} + end + end + + defp request(url, method, params) do + body = Jason.encode!(%{jsonrpc: "2.0", id: 1, method: method, params: params}) + + :post + |> Finch.build(url, [{"content-type", "application/json"}], body) + |> Finch.request(Ethui.Finch, receive_timeout: @receive_timeout) + |> case do + {:ok, %Finch.Response{body: body}} -> decode(body) + {:error, error} -> {:error, "rpc request failed: #{Exception.message(error)}"} + end + end + + defp decode(body) do + case Jason.decode(body) do + {:ok, %{"result" => result}} -> {:ok, result} + {:ok, %{"error" => error}} -> {:error, rpc_error(error)} + _ -> {:error, "unexpected rpc response: #{body}"} + end + end + + defp rpc_error(%{"message" => message} = error) do + case revert_reason(error) do + {:ok, reason} -> "#{message}: #{reason}" + :error -> message + end + end + + defp rpc_error(error), do: inspect(error) + + @doc "Decodes a standard `Error(string)` revert payload, which needs no contract ABI" + @spec revert_reason(map) :: {:ok, String.t()} | :error + def revert_reason(%{"data" => "0x" <> @error_string_selector <> encoded}) do + with {:ok, bin} <- Base.decode16(encoded, case: :mixed), + <<_offset::binary-size(32), len::unsigned-big-integer-size(256), rest::binary>> <- bin, + <> <- rest do + {:ok, reason} + else + _ -> :error + end + end + + def revert_reason(_), do: :error +end diff --git a/server/lib/ethui/mcp/auth.ex b/server/lib/ethui/mcp/auth.ex new file mode 100644 index 0000000..38b81cb --- /dev/null +++ b/server/lib/ethui/mcp/auth.ex @@ -0,0 +1,58 @@ +defmodule Ethui.MCP.Auth do + @moduledoc """ + Resolves the caller of an MCP tool from the `Authorization` header carried on + the frame, reusing the same JWT as the REST api. + """ + + alias Anubis.Server.Frame + alias Ethui.Accounts + alias Ethui.Accounts.User + alias Ethui.Stacks + alias Ethui.Stacks.Stack + alias EthuiWeb.Plugs.Authenticate + + @spec current_user(Frame.t()) :: {:ok, User.t() | nil} | {:error, String.t()} + def current_user(frame) do + if Authenticate.enabled?() do + with {:ok, token} <- bearer_token(frame), do: verify(token) + else + {:ok, nil} + end + end + + @doc "Fetches a stack the caller owns. Unowned stacks read as missing, to avoid leaking slugs" + @spec fetch_stack(Frame.t(), String.t()) :: {:ok, Stack.t()} | {:error, String.t()} + def fetch_stack(frame, slug) do + with {:ok, user} <- current_user(frame) do + case Stacks.get_stack_by_slug(slug) do + %Stack{} = stack -> authorize(user, stack, slug) + nil -> {:error, not_found(slug)} + end + end + end + + defp authorize(nil, stack, _slug), do: {:ok, stack} + defp authorize(_user, %Stack{user_id: nil} = stack, _slug), do: {:ok, stack} + defp authorize(%User{id: id}, %Stack{user_id: id} = stack, _slug), do: {:ok, stack} + defp authorize(_user, _stack, slug), do: {:error, not_found(slug)} + + defp not_found(slug), do: "stack not found: #{slug}" + + defp bearer_token(%Frame{context: %{headers: headers}}) do + case headers["authorization"] do + "Bearer " <> token -> {:ok, token} + _ -> {:error, "missing `Authorization: Bearer ` header"} + end + end + + defp bearer_token(_frame), do: {:error, "missing `Authorization: Bearer ` header"} + + defp verify(token) do + case Accounts.verify_token(token) do + {:ok, %User{} = user} -> {:ok, user} + _ -> {:error, "invalid or expired token"} + end + rescue + Ecto.NoResultsError -> {:error, "invalid or expired token"} + end +end diff --git a/server/lib/ethui/mcp/explorer.ex b/server/lib/ethui/mcp/explorer.ex new file mode 100644 index 0000000..7c3f413 --- /dev/null +++ b/server/lib/ethui/mcp/explorer.ex @@ -0,0 +1,29 @@ +defmodule Ethui.MCP.Explorer do + @moduledoc """ + Deep links into the ethui explorer, which takes the target rpc url base64 + encoded in its path. The stack api key is already inside that url, so a human + opening the link is authenticated. + """ + + alias Ethui.Stacks + alias Ethui.Stacks.Stack + + @default_base "https://explorer.ethui.dev" + + @spec root(Stack.t()) :: String.t() + def root(stack), do: "#{base()}/rpc/#{Base.encode64(Stacks.ws_rpc_url(stack))}" + + @spec tx(Stack.t(), String.t()) :: String.t() + def tx(stack, hash), do: "#{root(stack)}/tx/#{hash}" + + @spec address(Stack.t(), String.t()) :: String.t() + def address(stack, address), do: "#{root(stack)}/address/#{address}" + + @doc "Block links take a decimal number, the way the frontend builds them" + @spec block(Stack.t(), String.t() | integer | nil) :: String.t() + def block(stack, nil), do: root(stack) + def block(stack, "0x" <> hex), do: block(stack, String.to_integer(hex, 16)) + def block(stack, number), do: "#{root(stack)}/block/#{number}" + + defp base, do: Application.get_env(:ethui, :explorer_base, @default_base) +end diff --git a/server/lib/ethui/mcp/server.ex b/server/lib/ethui/mcp/server.ex new file mode 100644 index 0000000..2aef21a --- /dev/null +++ b/server/lib/ethui/mcp/server.ex @@ -0,0 +1,34 @@ +defmodule Ethui.MCP.Server do + @moduledoc """ + MCP server exposing stack lifecycle, chain reads and anvil cheatcodes. + + Served over streamable HTTP at `/mcp`, authenticated with the same bearer JWT + as the REST api. + """ + + use Anubis.Server, + name: "ethui-stacks", + version: "0.1.0", + capabilities: [:tools] + + alias Ethui.MCP.Tools + + component(Tools.CreateStack) + component(Tools.ListStacks) + component(Tools.DeleteStack) + + component(Tools.GetBlock) + component(Tools.GetTransaction) + component(Tools.GetAddress) + component(Tools.GetLogs) + + component(Tools.SimulateCall) + component(Tools.Execute) + + component(Tools.Impersonate) + component(Tools.SetBalance) + component(Tools.Mine) + component(Tools.SetBlockTimestamp) + component(Tools.Snapshot) + component(Tools.Revert) +end diff --git a/server/lib/ethui/mcp/stack_info.ex b/server/lib/ethui/mcp/stack_info.ex new file mode 100644 index 0000000..bd30d5e --- /dev/null +++ b/server/lib/ethui/mcp/stack_info.ex @@ -0,0 +1,34 @@ +defmodule Ethui.MCP.StackInfo do + @moduledoc "Stack payload returned by the lifecycle tools" + + alias Ethui.MCP.Explorer + alias Ethui.Stacks + alias Ethui.Stacks.Server + alias Ethui.Stacks.Stack + + @type t :: %{ + slug: String.t(), + status: String.t(), + chain_id: non_neg_integer, + http_rpc: String.t(), + ws_rpc: String.t(), + explorer: String.t(), + anvil_opts: map + } + + @spec describe(Stack.t()) :: t + def describe(stack), do: describe(stack, Server.list()) + + @spec describe(Stack.t(), [String.t()]) :: t + def describe(%Stack{} = stack, running_slugs) do + %{ + slug: stack.slug, + status: if(stack.slug in running_slugs, do: "running", else: "stopped"), + chain_id: Stacks.chain_id(stack.id), + http_rpc: Stacks.http_rpc_url(stack), + ws_rpc: Stacks.ws_rpc_url(stack), + explorer: Explorer.root(stack), + anvil_opts: stack.anvil_opts + } + end +end diff --git a/server/lib/ethui/mcp/tool.ex b/server/lib/ethui/mcp/tool.ex new file mode 100644 index 0000000..a2b5c75 --- /dev/null +++ b/server/lib/ethui/mcp/tool.ex @@ -0,0 +1,57 @@ +defmodule Ethui.MCP.Tool do + @moduledoc """ + Shared plumbing for MCP tools: stack lookup with ownership check, rpc calls + and the `{:ok, data} | {:error, message}` to MCP response mapping. + """ + + alias Anubis.Server.Frame + alias Anubis.Server.Response + alias Ethui.Chain + alias Ethui.MCP.Auth + alias Ethui.Stacks.Stack + + defmacro __using__(_opts) do + quote do + use Anubis.Server.Component, type: :tool + + import Ethui.MCP.Tool + + alias Ethui.Chain + alias Ethui.MCP.Explorer + end + end + + @doc """ + Resolves `slug` to a stack the caller owns and runs `fun` on it, mapping its + `{:ok, data} | {:error, message}` result into an MCP response. + """ + @spec with_stack(Frame.t(), String.t(), (Stack.t() -> {:ok, term} | {:error, String.t()})) :: + {:reply, Response.t(), Frame.t()} + def with_stack(frame, slug, fun) do + with {:ok, stack} <- Auth.fetch_stack(frame, slug) do + fun.(stack) + end + |> reply(frame) + end + + @doc "Runs a JSON-RPC call against the stack's anvil" + @spec rpc(Stack.t(), String.t(), list) :: {:ok, term} | {:error, String.t()} + def rpc(%Stack{slug: slug}, method, params \\ []), do: Chain.call(slug, method, params) + + @doc "Casts a decimal (or already hex) amount into a JSON-RPC hex quantity" + @spec quantity(String.t()) :: {:ok, String.t()} | {:error, String.t()} + def quantity("0x" <> _ = value), do: {:ok, value} + + def quantity(value) do + case Integer.parse(value) do + {n, ""} when n >= 0 -> {:ok, Chain.hex(n)} + _ -> {:error, "expected a non-negative decimal or 0x-prefixed amount, got: #{value}"} + end + end + + @spec reply({:ok, term} | {:error, String.t()}, Frame.t()) :: {:reply, Response.t(), Frame.t()} + def reply({:ok, data}, frame), do: {:reply, Response.json(Response.tool(), data), frame} + + def reply({:error, message}, frame), + do: {:reply, Response.error(Response.tool(), message), frame} +end diff --git a/server/lib/ethui/mcp/tools/create_stack.ex b/server/lib/ethui/mcp/tools/create_stack.ex new file mode 100644 index 0000000..4cb8d97 --- /dev/null +++ b/server/lib/ethui/mcp/tools/create_stack.ex @@ -0,0 +1,69 @@ +defmodule Ethui.MCP.Tools.CreateStack do + @moduledoc """ + Creates a disposable anvil sandbox and returns its rpc urls plus an explorer + link. Pass fork_url (and optionally fork_block_number) to fork a live chain. + """ + + use Ethui.MCP.Tool + + alias Ethui.MCP.Auth + alias Ethui.MCP.StackInfo + alias Ethui.Stacks + alias Ethui.Stacks.Server + + schema do + field(:slug, :string, + description: "Stack name, lowercase alphanumeric + dashes. Generated if omitted" + ) + + field(:fork_url, :string, description: "RPC url to fork from, e.g. a mainnet endpoint") + field(:fork_block_number, :integer, description: "Block to fork at. Defaults to chain head") + end + + @impl true + def execute(params, frame) do + with {:ok, user} <- Auth.current_user(frame), + {:ok, stack} <- Stacks.create_stack(user, attrs(params)), + {:ok, _pid} <- start(stack) do + {:ok, stack.slug |> Stacks.get_stack_by_slug() |> StackInfo.describe()} + else + {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset_error(changeset)} + {:error, {:user_limit_exceeded, max}} -> {:error, "stack limit reached (#{max} per user)"} + {:error, {:global_limit_exceeded, max}} -> {:error, "global stack limit reached (#{max})"} + {:error, reason} when is_binary(reason) -> {:error, reason} + {:error, reason} -> {:error, "could not create stack: #{inspect(reason)}"} + end + |> reply(frame) + end + + # The row is already committed, so a failed boot has to give the slug and quota slot back + defp start(stack) do + case Server.create(stack) do + {:ok, pid} -> + {:ok, pid} + + error -> + Stacks.delete_stack(stack) + {:error, "could not start stack: #{inspect(error)}"} + end + end + + defp attrs(params) do + %{"slug" => params[:slug] || generate_slug(), "anvil_opts" => anvil_opts(params)} + end + + defp anvil_opts(params) do + %{"fork_url" => params[:fork_url], "fork_block_number" => params[:fork_block_number]} + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end + + defp generate_slug, + do: "mcp-" <> (4 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)) + + defp changeset_error(changeset) do + changeset + |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end) + |> Enum.map_join("; ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end) + end +end diff --git a/server/lib/ethui/mcp/tools/delete_stack.ex b/server/lib/ethui/mcp/tools/delete_stack.ex new file mode 100644 index 0000000..eb8a59f --- /dev/null +++ b/server/lib/ethui/mcp/tools/delete_stack.ex @@ -0,0 +1,24 @@ +defmodule Ethui.MCP.Tools.DeleteStack do + @moduledoc "Destroys a stack and everything on it. Irreversible" + + use Ethui.MCP.Tool + + alias Ethui.Stacks + alias Ethui.Stacks.Server + + schema do + field(:slug, :string, required: true, description: "Stack to delete") + end + + @impl true + def execute(%{slug: slug}, frame) do + with_stack(frame, slug, fn stack -> + Server.destroy(stack) + + case Stacks.delete_stack(stack) do + {:ok, _stack} -> {:ok, %{slug: slug, deleted: true}} + {:error, _changeset} -> {:error, "could not delete stack: #{slug}"} + end + end) + end +end diff --git a/server/lib/ethui/mcp/tools/execute.ex b/server/lib/ethui/mcp/tools/execute.ex new file mode 100644 index 0000000..11e926a --- /dev/null +++ b/server/lib/ethui/mcp/tools/execute.ex @@ -0,0 +1,89 @@ +defmodule Ethui.MCP.Tools.Execute do + @moduledoc """ + Sends a transaction to a stack and waits for its receipt. Takes raw calldata — + encode it with the contract ABI first. `from` can be any address: the sandbox + impersonates it, no private key needed. + """ + + use Ethui.MCP.Tool + + @receipt_attempts 100 + @receipt_interval 200 + + schema do + field(:slug, :string, required: true) + field(:to, :string, description: "Target address. Omit to deploy the bytecode in `data`") + field(:data, :string, description: "0x-prefixed calldata or deploy bytecode") + + field(:from, :string, + description: "Sender, impersonated automatically. Defaults to the first anvil account" + ) + + field(:value, :string, + default: "0", + description: "Wei to send, decimal or hex. Defaults to 0" + ) + + field(:gas, :integer, description: "Gas limit. Estimated when omitted") + end + + @impl true + def execute(%{slug: slug} = params, frame) do + with_stack(frame, slug, fn stack -> + with {:ok, value} <- quantity(params.value), + {:ok, from} <- sender(stack, params[:from]), + {:ok, hash} <- rpc(stack, "eth_sendTransaction", [tx(params, from, value)]), + {:ok, receipt} <- await_receipt(stack, hash) do + {:ok, + %{ + hash: hash, + status: if(receipt["status"] == "0x1", do: "success", else: "reverted"), + gas_used: receipt["gasUsed"], + contract_address: receipt["contractAddress"], + logs: receipt["logs"], + explorer: Explorer.tx(stack, hash) + }} + end + end) + end + + defp sender(stack, nil) do + case rpc(stack, "eth_accounts") do + {:ok, [account | _]} -> {:ok, account} + {:ok, []} -> {:error, "stack has no unlocked accounts, pass `from`"} + error -> error + end + end + + defp sender(stack, from) do + with {:ok, _} <- rpc(stack, "anvil_impersonateAccount", [from]), do: {:ok, from} + end + + defp tx(params, from, value) do + %{ + "from" => from, + "to" => params[:to], + "data" => params[:data], + "value" => value, + "gas" => params[:gas] && Chain.hex(params[:gas]) + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end + + defp await_receipt(stack, hash, attempts \\ @receipt_attempts) + + defp await_receipt(_stack, hash, 0), + do: {:error, "transaction #{hash} was sent but no receipt appeared — is the stack mining?"} + + defp await_receipt(stack, hash, attempts) do + case rpc(stack, "eth_getTransactionReceipt", [hash]) do + {:ok, nil} -> + Process.sleep(@receipt_interval) + await_receipt(stack, hash, attempts - 1) + + other -> + other + end + end +end diff --git a/server/lib/ethui/mcp/tools/get_address.ex b/server/lib/ethui/mcp/tools/get_address.ex new file mode 100644 index 0000000..72100d1 --- /dev/null +++ b/server/lib/ethui/mcp/tools/get_address.ex @@ -0,0 +1,40 @@ +defmodule Ethui.MCP.Tools.GetAddress do + @moduledoc "Reads balance, nonce and contract status of an address on a stack" + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + field(:address, :string, required: true) + + field(:block, :string, + default: "latest", + description: "Block number or tag to read at. Defaults to latest" + ) + end + + @impl true + def execute(%{slug: slug, address: address, block: block}, frame) do + with_stack(frame, slug, fn stack -> + at = Chain.block_param(block) + + with {:ok, balance} <- rpc(stack, "eth_getBalance", [address, at]), + {:ok, nonce} <- rpc(stack, "eth_getTransactionCount", [address, at]), + {:ok, code} <- rpc(stack, "eth_getCode", [address, at]) do + {:ok, + %{ + address: address, + balance_wei: to_decimal(balance), + balance_hex: balance, + nonce: to_decimal(nonce), + is_contract: code not in ["0x", "0x0"], + code_size: byte_size(code) |> div(2) |> Kernel.-(1), + explorer: Explorer.address(stack, address) + }} + end + end) + end + + defp to_decimal("0x" <> hex), do: hex |> String.to_integer(16) |> to_string() + defp to_decimal(other), do: other +end diff --git a/server/lib/ethui/mcp/tools/get_block.ex b/server/lib/ethui/mcp/tools/get_block.ex new file mode 100644 index 0000000..c524aa0 --- /dev/null +++ b/server/lib/ethui/mcp/tools/get_block.ex @@ -0,0 +1,36 @@ +defmodule Ethui.MCP.Tools.GetBlock do + @moduledoc "Reads a block from a stack, with an explorer link to it" + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + + field(:block, :string, + default: "latest", + description: + "Block number (decimal or hex) or latest/earliest/pending/safe/finalized. Defaults to latest" + ) + + field(:full_transactions, :boolean, + default: false, + description: "Include full transaction objects instead of hashes. Defaults to false" + ) + end + + @impl true + def execute(%{slug: slug, block: block, full_transactions: full}, frame) do + with_stack(frame, slug, fn stack -> + case rpc(stack, "eth_getBlockByNumber", [Chain.block_param(block), full]) do + {:ok, nil} -> + {:error, "block not found: #{block}"} + + {:ok, result} -> + {:ok, Map.put(result, "explorer", Explorer.block(stack, result["number"]))} + + error -> + error + end + end) + end +end diff --git a/server/lib/ethui/mcp/tools/get_logs.ex b/server/lib/ethui/mcp/tools/get_logs.ex new file mode 100644 index 0000000..c7609d3 --- /dev/null +++ b/server/lib/ethui/mcp/tools/get_logs.ex @@ -0,0 +1,47 @@ +defmodule Ethui.MCP.Tools.GetLogs do + @moduledoc """ + Queries event logs on a stack. Topics are raw 32-byte hex values — hash the + event signature yourself (topic0) to filter by event. + """ + + use Ethui.MCP.Tool + + alias Ethui.Stacks.Stack + + schema do + field(:slug, :string, required: true) + field(:address, :string, description: "Contract address to filter by") + + field(:from_block, :string, + description: + "Defaults to earliest, or to the fork block on a forked stack, where scanning further back queries the upstream chain" + ) + + field(:to_block, :string, default: "latest", description: "Defaults to latest") + field(:topics, {:list, :string}, description: "Topic filters, topic0 first") + end + + @impl true + def execute(%{slug: slug} = params, frame) do + with_stack(frame, slug, fn stack -> + with {:ok, logs} <- rpc(stack, "eth_getLogs", [filter(params, stack)]) do + {:ok, %{count: length(logs), logs: logs, explorer: Explorer.root(stack)}} + end + end) + end + + defp filter(params, stack) do + %{ + "fromBlock" => Chain.block_param(params[:from_block] || from_block(stack)), + "toBlock" => Chain.block_param(params.to_block), + "address" => params[:address], + "topics" => params[:topics] + } + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end + + defp from_block(%Stack{anvil_opts: %{"fork_block_number" => block}}), do: to_string(block) + defp from_block(%Stack{anvil_opts: %{"fork_url" => _}}), do: "latest" + defp from_block(_stack), do: "earliest" +end diff --git a/server/lib/ethui/mcp/tools/get_transaction.ex b/server/lib/ethui/mcp/tools/get_transaction.ex new file mode 100644 index 0000000..60c08b2 --- /dev/null +++ b/server/lib/ethui/mcp/tools/get_transaction.ex @@ -0,0 +1,26 @@ +defmodule Ethui.MCP.Tools.GetTransaction do + @moduledoc """ + Reads a transaction and its receipt (status, gas, logs) from a stack, with an + explorer link. Calldata and logs are returned raw — decode them with the ABI. + """ + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + field(:hash, :string, required: true, description: "Transaction hash") + end + + @impl true + def execute(%{slug: slug, hash: hash}, frame) do + with_stack(frame, slug, fn stack -> + with {:ok, tx} <- rpc(stack, "eth_getTransactionByHash", [hash]), + {:ok, receipt} <- receipt(stack, tx, hash) do + {:ok, %{transaction: tx, receipt: receipt, explorer: Explorer.tx(stack, hash)}} + end + end) + end + + defp receipt(_stack, nil, hash), do: {:error, "transaction not found: #{hash}"} + defp receipt(stack, _tx, hash), do: rpc(stack, "eth_getTransactionReceipt", [hash]) +end diff --git a/server/lib/ethui/mcp/tools/impersonate.ex b/server/lib/ethui/mcp/tools/impersonate.ex new file mode 100644 index 0000000..99ec7b0 --- /dev/null +++ b/server/lib/ethui/mcp/tools/impersonate.ex @@ -0,0 +1,29 @@ +defmodule Ethui.MCP.Tools.Impersonate do + @moduledoc """ + Unlocks an address on the sandbox so transactions can be sent from it without + its private key. Set stop: true to release it again. + """ + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + field(:address, :string, required: true) + + field(:stop, :boolean, + default: false, + description: "Stop impersonating instead of starting. Defaults to false" + ) + end + + @impl true + def execute(%{slug: slug, address: address, stop: stop}, frame) do + with_stack(frame, slug, fn stack -> + method = if stop, do: "anvil_stopImpersonatingAccount", else: "anvil_impersonateAccount" + + with {:ok, _} <- rpc(stack, method, [address]) do + {:ok, %{address: address, impersonating: not stop}} + end + end) + end +end diff --git a/server/lib/ethui/mcp/tools/list_stacks.ex b/server/lib/ethui/mcp/tools/list_stacks.ex new file mode 100644 index 0000000..5b78fe4 --- /dev/null +++ b/server/lib/ethui/mcp/tools/list_stacks.ex @@ -0,0 +1,22 @@ +defmodule Ethui.MCP.Tools.ListStacks do + @moduledoc "Lists the caller's stacks with their status, rpc urls and explorer links" + + use Ethui.MCP.Tool + + alias Ethui.MCP.Auth + alias Ethui.MCP.StackInfo + alias Ethui.Stacks + alias Ethui.Stacks.Server + + schema do + end + + @impl true + def execute(_params, frame) do + with {:ok, user} <- Auth.current_user(frame) do + running = Server.list() + {:ok, Enum.map(Stacks.list_stacks(user), &StackInfo.describe(&1, running))} + end + |> reply(frame) + end +end diff --git a/server/lib/ethui/mcp/tools/mine.ex b/server/lib/ethui/mcp/tools/mine.ex new file mode 100644 index 0000000..808603e --- /dev/null +++ b/server/lib/ethui/mcp/tools/mine.ex @@ -0,0 +1,29 @@ +defmodule Ethui.MCP.Tools.Mine do + @moduledoc "Mines blocks on the sandbox, optionally spacing them in time" + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + + field(:blocks, :integer, + default: 1, + min: 1, + description: "How many blocks to mine. Defaults to 1" + ) + + field(:interval, :integer, min: 0, description: "Seconds between mined blocks") + end + + @impl true + def execute(%{slug: slug, blocks: blocks} = params, frame) do + with_stack(frame, slug, fn stack -> + args = [Chain.hex(blocks) | List.wrap(params[:interval] && Chain.hex(params[:interval]))] + + with {:ok, _} <- rpc(stack, "anvil_mine", args), + {:ok, number} <- rpc(stack, "eth_blockNumber") do + {:ok, %{mined: blocks, block_number: number, explorer: Explorer.block(stack, number)}} + end + end) + end +end diff --git a/server/lib/ethui/mcp/tools/revert.ex b/server/lib/ethui/mcp/tools/revert.ex new file mode 100644 index 0000000..3a69ff9 --- /dev/null +++ b/server/lib/ethui/mcp/tools/revert.ex @@ -0,0 +1,24 @@ +defmodule Ethui.MCP.Tools.Revert do + @moduledoc """ + Rolls the sandbox back to a snapshot. Snapshots are consumed on revert and + anything taken after them is discarded. + """ + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + field(:snapshot_id, :string, required: true, description: "Id returned by `snapshot`") + end + + @impl true + def execute(%{slug: slug, snapshot_id: id}, frame) do + with_stack(frame, slug, fn stack -> + case rpc(stack, "evm_revert", [id]) do + {:ok, true} -> {:ok, %{reverted: true, snapshot_id: id}} + {:ok, false} -> {:error, "unknown or already consumed snapshot: #{id}"} + error -> error + end + end) + end +end diff --git a/server/lib/ethui/mcp/tools/set_balance.ex b/server/lib/ethui/mcp/tools/set_balance.ex new file mode 100644 index 0000000..4f61d74 --- /dev/null +++ b/server/lib/ethui/mcp/tools/set_balance.ex @@ -0,0 +1,22 @@ +defmodule Ethui.MCP.Tools.SetBalance do + @moduledoc "Sets the native balance of an address on the sandbox" + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + field(:address, :string, required: true) + field(:balance, :string, required: true, description: "Wei, decimal or 0x-prefixed hex") + end + + @impl true + def execute(%{slug: slug, address: address, balance: balance}, frame) do + with_stack(frame, slug, fn stack -> + with {:ok, amount} <- quantity(balance), + {:ok, _} <- rpc(stack, "anvil_setBalance", [address, amount]) do + {:ok, + %{address: address, balance_wei: balance, explorer: Explorer.address(stack, address)}} + end + end) + end +end diff --git a/server/lib/ethui/mcp/tools/set_block_timestamp.ex b/server/lib/ethui/mcp/tools/set_block_timestamp.ex new file mode 100644 index 0000000..64c8057 --- /dev/null +++ b/server/lib/ethui/mcp/tools/set_block_timestamp.ex @@ -0,0 +1,31 @@ +defmodule Ethui.MCP.Tools.SetBlockTimestamp do + @moduledoc """ + Sets the timestamp of the next block, and mines it by default so the new time + takes effect immediately. + """ + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + field(:timestamp, :integer, required: true, description: "Unix timestamp in seconds") + + field(:mine, :boolean, + default: true, + description: "Mine a block right after setting it. Defaults to true" + ) + end + + @impl true + def execute(%{slug: slug, timestamp: timestamp, mine: mine}, frame) do + with_stack(frame, slug, fn stack -> + with {:ok, _} <- rpc(stack, "evm_setNextBlockTimestamp", [timestamp]), + {:ok, _} <- maybe_mine(stack, mine) do + {:ok, %{timestamp: timestamp, mined: mine}} + end + end) + end + + defp maybe_mine(_stack, false), do: {:ok, nil} + defp maybe_mine(stack, true), do: rpc(stack, "anvil_mine", [Chain.hex(1)]) +end diff --git a/server/lib/ethui/mcp/tools/simulate_call.ex b/server/lib/ethui/mcp/tools/simulate_call.ex new file mode 100644 index 0000000..1c3e194 --- /dev/null +++ b/server/lib/ethui/mcp/tools/simulate_call.ex @@ -0,0 +1,42 @@ +defmodule Ethui.MCP.Tools.SimulateCall do + @moduledoc """ + Runs an eth_call against a stack without committing state. Takes raw + calldata — encode it with the contract ABI before calling. + """ + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + field(:to, :string, required: true, description: "Target contract address") + field(:data, :string, required: true, description: "0x-prefixed calldata") + field(:from, :string, description: "Sender address. Any address, no key needed") + + field(:value, :string, + default: "0", + description: "Wei to send, decimal or hex. Defaults to 0" + ) + + field(:block, :string, + default: "latest", + description: "Block number or tag to call at. Defaults to latest" + ) + end + + @impl true + def execute(%{slug: slug} = params, frame) do + with_stack(frame, slug, fn stack -> + with {:ok, value} <- quantity(params.value), + {:ok, result} <- + rpc(stack, "eth_call", [tx(params, value), Chain.block_param(params.block)]) do + {:ok, %{result: result}} + end + end) + end + + defp tx(params, value) do + %{"to" => params.to, "data" => params.data, "from" => params[:from], "value" => value} + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Map.new() + end +end diff --git a/server/lib/ethui/mcp/tools/snapshot.ex b/server/lib/ethui/mcp/tools/snapshot.ex new file mode 100644 index 0000000..f534a0f --- /dev/null +++ b/server/lib/ethui/mcp/tools/snapshot.ex @@ -0,0 +1,18 @@ +defmodule Ethui.MCP.Tools.Snapshot do + @moduledoc "Snapshots the sandbox state. Pass the returned id to `revert` to roll back" + + use Ethui.MCP.Tool + + schema do + field(:slug, :string, required: true) + end + + @impl true + def execute(%{slug: slug}, frame) do + with_stack(frame, slug, fn stack -> + with {:ok, id} <- rpc(stack, "evm_snapshot") do + {:ok, %{snapshot_id: id}} + end + end) + end +end diff --git a/server/lib/ethui/services/anvil.ex b/server/lib/ethui/services/anvil.ex index 33d0817..f32bdee 100644 --- a/server/lib/ethui/services/anvil.ex +++ b/server/lib/ethui/services/anvil.ex @@ -60,9 +60,10 @@ defmodule Ethui.Services.Anvil do GenServer.call(id, :url) end - @spec ensure_running(id) :: :ok - def ensure_running(id) do - GenServer.call(id, :ensure_running) + # Booting a forked instance waits on the upstream chain, well past a default call timeout + @spec ensure_running(id, timeout) :: :ok + def ensure_running(id, timeout \\ :timer.seconds(30)) do + GenServer.call(id, :ensure_running, timeout) end @doc """ diff --git a/server/lib/ethui/stacks.ex b/server/lib/ethui/stacks.ex index 270718f..17d502a 100644 --- a/server/lib/ethui/stacks.ex +++ b/server/lib/ethui/stacks.ex @@ -122,7 +122,7 @@ defmodule Ethui.Stacks do Repo.all(from(s in Stack, where: s.user_id == ^user.id)) |> Repo.preload(:api_key) else - Repo.all(Stack) + Repo.all(Stack) |> Repo.preload(:api_key) end end diff --git a/server/lib/ethui/stacks/server.ex b/server/lib/ethui/stacks/server.ex index 9c58e16..881cb3a 100644 --- a/server/lib/ethui/stacks/server.ex +++ b/server/lib/ethui/stacks/server.ex @@ -70,6 +70,10 @@ defmodule Ethui.Stacks.Server do _ -> {:error, "Stack not found"} end + catch + # anvil dies mid-call when it cannot boot at all, e.g. bad fork options + :exit, {:timeout, _} -> {:error, "Stack is taking too long to start"} + :exit, _ -> {:error, "Stack failed to start, check its fork options"} end def graph_ip_from_slug(proxied_path, slug, target_port) do diff --git a/server/lib/ethui/stacks/stack.ex b/server/lib/ethui/stacks/stack.ex index 7f483bb..09c66b5 100644 --- a/server/lib/ethui/stacks/stack.ex +++ b/server/lib/ethui/stacks/stack.ex @@ -16,6 +16,8 @@ defmodule Ethui.Stacks.Stack do "enabled" => :boolean } + @type t :: %__MODULE__{} + schema "stacks" do field(:slug, :string) field(:anvil_opts, :map, default: %{}) diff --git a/server/lib/ethui_web/router.ex b/server/lib/ethui_web/router.ex index 248b90b..d77a8ca 100644 --- a/server/lib/ethui_web/router.ex +++ b/server/lib/ethui_web/router.ex @@ -33,6 +33,11 @@ defmodule EthuiWeb.Router do plug EthuiWeb.Plugs.Authenticate end + # No `accepts`: MCP clients negotiate json and text/event-stream on the same path + pipeline :mcp do + plug EthuiWeb.Plugs.Authenticate + end + pipeline :proxy do plug EthuiWeb.Plugs.StackSubdomain plug EthuiWeb.Plugs.ApiKeyAuth @@ -47,6 +52,12 @@ defmodule EthuiWeb.Router do get "/healthz", Api.HealthzController, :index end + scope "/", host: "api." do + pipe_through [:base, :mcp] + + forward "/mcp", Anubis.Server.Transport.StreamableHTTP.Plug, server: Ethui.MCP.Server + end + scope "/", EthuiWeb, host: "api." do pipe_through [:base, :authenticated_api] diff --git a/server/mix.exs b/server/mix.exs index 05e2a19..2856bdd 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -36,6 +36,7 @@ defmodule Ethui.MixProject do # application {:muontrap, "~> 1.6"}, {:mint, "~> 1.7"}, + {:anubis_mcp, "~> 1.10"}, # development {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, diff --git a/server/mix.lock b/server/mix.lock index 6646dc1..ae36eaf 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -1,4 +1,5 @@ %{ + "anubis_mcp": {:hex, :anubis_mcp, "1.10.0", "5b5cd3102b4ef3f34c9507ace2bc5092d126dd1f6daf6aa9edb02dd19fcd61e7", [:mix], [{:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:jose, "~> 1.11.7", [hex: :jose, repo: "hexpm", optional: true]}, {:peri, "0.9.0", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:redix, "~> 1.5", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4868f73183cf2e1722aae049b0631ef2bc6c07052c61fc163ec5c60e747c4fd6"}, "b58": {:hex, :b58, "1.0.3", "d300d6ae5a3de956a54b9e8220e924e4fee1a349de983df2340fe61e0e464202", [:mix], [], "hexpm", "af62a98a8661fd89978cf3a3a4b5b2ebe82209de6ac6164f0b112e36af72fc59"}, "backpex": {:hex, :backpex, "0.12.0", "cdf05d581da648ec8f7fd2efdf3adad5fe74acb515139d264b1043fd947c19a5", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: true]}, {:ash_postgres, "~> 2.0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:money, "~> 1.13", [hex: :money, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:number, "~> 1.0", [hex: :number, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "8b8c034d7e47ddc91631fa691c69dfdabf6d3faba03082b3a5883f840d69f9de"}, "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, @@ -47,7 +48,7 @@ "logger_json": {:hex, :logger_json, "6.2.1", "a1db30e1164e6057f2328a1e4d6b632b9583c015574fdf6c38cf73721128edcb", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "34acd0bfd419d5fcf08c4108a8a4b59b695fcc60409dc1dd1a868b70c42e1d1f"}, "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, @@ -59,6 +60,7 @@ "number": {:hex, :number, "1.0.5", "d92136f9b9382aeb50145782f116112078b3465b7be58df1f85952b8bb399b0f", [:mix], [{:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "c0733a0a90773a66582b9e92a3f01290987f395c972cb7d685f51dd927cd5169"}, "paged_file": {:hex, :paged_file, "1.1.3", "b898d3ba11122c46ddcf17935c20baecc58e9197bc5b0058f98f4452034479ba", [:mix], [], "hexpm", "1cf29e99afa2a8057d4299ccf919af6967288db49ce5c7500008c1eebd255467"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "peri": {:hex, :peri, "0.9.0", "ff3867597af6e45dfa2a081ab403096b1e7e0824ae571bc203ec6900c0a9269f", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "53d773928e3105565cbfffe36bf642d85be1ec00130a176b2090dc3f80d2c273"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, @@ -68,9 +70,9 @@ "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.9", "4dc5e535832733df68df22f9de168b11c0c74bca65b27b088a10ac36dfb75d04", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1dccb04ec8544340e01608e108f32724458d0ac4b07e551406b3b920c40ba2e5"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "poison": {:hex, :poison, "6.0.0", "9bbe86722355e36ffb62c51a552719534257ba53f3271dacd20fbbd6621a583a", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "bb9064632b94775a3964642d6a78281c07b7be1319e0016e1643790704e739a2"}, "porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm", "dc996ab8fadbc09912c787c7ab8673065e50ea1a6245177b0c24569013d23620"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, @@ -80,7 +82,7 @@ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "swoosh": {:hex, :swoosh, "1.18.4", "5f5f325cfbc68d454f1606421f2dd02d1b20fd03e10905e9728b26662ae01f1d", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8b45e6f9109bdf89f3d83f810e0cc97c1c971925e72fc4f47da42959d8487ee"}, "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, diff --git a/server/test/ethui/chain_test.exs b/server/test/ethui/chain_test.exs new file mode 100644 index 0000000..3adf40e --- /dev/null +++ b/server/test/ethui/chain_test.exs @@ -0,0 +1,44 @@ +defmodule Ethui.ChainTest do + use ExUnit.Case, async: true + + alias Ethui.Chain + + describe "hex/1" do + test "encodes integers as hex quantities" do + assert Chain.hex(0) == "0x0" + assert Chain.hex(255) == "0xff" + end + end + + describe "block_param/1" do + test "passes through tags and hex" do + assert Chain.block_param("latest") == "latest" + assert Chain.block_param("0x10") == "0x10" + end + + test "converts decimal block numbers" do + assert Chain.block_param("16") == "0x10" + end + end + + describe "revert_reason/1" do + test "decodes an Error(string) payload" do + assert {:ok, "insufficient funds"} = + Chain.revert_reason(%{"data" => error_payload("insufficient funds")}) + end + + test "ignores payloads it cannot decode" do + assert :error == Chain.revert_reason(%{"data" => "0xdeadbeef"}) + assert :error == Chain.revert_reason(%{"message" => "execution reverted"}) + end + end + + defp error_payload(reason) do + len = byte_size(reason) + padding = :binary.copy(<<0>>, 32 - rem(len, 32)) + + "0x08c379a0" <> word(32) <> word(len) <> Base.encode16(reason <> padding, case: :lower) + end + + defp word(n), do: n |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(64, "0") +end diff --git a/server/test/ethui/mcp/explorer_test.exs b/server/test/ethui/mcp/explorer_test.exs new file mode 100644 index 0000000..a1a2bbf --- /dev/null +++ b/server/test/ethui/mcp/explorer_test.exs @@ -0,0 +1,21 @@ +defmodule Ethui.MCP.ExplorerTest do + use ExUnit.Case, async: true + + alias Ethui.MCP.Explorer + alias Ethui.Stacks.Stack + + @stack %Stack{slug: "demo"} + + test "encodes the rpc url into the path" do + assert Explorer.root(@stack) =~ ~r"/rpc/[A-Za-z0-9+/=]+$" + end + + test "builds block links with decimal numbers" do + assert Explorer.block(@stack, "0x10") =~ "/block/16" + assert Explorer.block(@stack, 16) =~ "/block/16" + end + + test "falls back to the stack root when a block has no number" do + assert Explorer.block(@stack, nil) == Explorer.root(@stack) + end +end diff --git a/server/test/ethui/mcp/tools_test.exs b/server/test/ethui/mcp/tools_test.exs new file mode 100644 index 0000000..742bce4 --- /dev/null +++ b/server/test/ethui/mcp/tools_test.exs @@ -0,0 +1,217 @@ +defmodule Ethui.MCP.ToolsTest do + use Ethui.DataCase, async: false + + alias Anubis.Server.Context + alias Anubis.Server.Frame + alias Ethui.Accounts + alias Ethui.MCP.Tools + alias Ethui.Stacks + alias Ethui.Stacks.Server + alias Ethui.Stacks.Stack + + @whale "0x1111111111111111111111111111111111111111" + @recipient "0x2222222222222222222222222222222222222222" + + setup do + original = Application.get_env(:ethui, EthuiWeb.Plugs.Authenticate, []) + Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, enabled: true) + + on_exit(fn -> + Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, original) + Enum.each(Server.list(), &Server.destroy(%Stack{slug: &1})) + end) + + {:ok, frame: authenticated_frame()} + end + + describe "auth" do + test "rejects calls without a bearer token" do + assert {:error, message} = call(Tools.ListStacks, %{}, %Frame{}) + assert message =~ "Authorization" + end + + test "hides stacks owned by another user", %{frame: frame} do + {:ok, other} = Stacks.create_stack(user("other"), %{"slug" => "someone-elses"}) + on_exit(fn -> Stacks.delete_stack(other) end) + + assert {:error, "stack not found: someone-elses"} = + call( + Tools.GetBlock, + %{slug: "someone-elses", block: "latest", full_transactions: false}, + frame + ) + end + + test "reports unknown slugs as not found", %{frame: frame} do + assert {:error, "stack not found: nope"} = call(Tools.Snapshot, %{slug: "nope"}, frame) + end + end + + describe "lifecycle" do + test "creates, lists and deletes a stack", %{frame: frame} do + assert {:ok, %{slug: slug, status: "running", http_rpc: rpc, explorer: explorer}} = + call(Tools.CreateStack, %{}, frame) + + assert rpc =~ slug + assert explorer =~ "/rpc/" + + assert {:ok, stacks} = call(Tools.ListStacks, %{}, frame) + assert Enum.any?(stacks, &(&1.slug == slug)) + + assert {:ok, %{deleted: true}} = call(Tools.DeleteStack, %{slug: slug}, frame) + assert {:ok, []} = call(Tools.ListStacks, %{}, frame) + end + + test "reports a stack whose anvil cannot boot", %{frame: frame} do + assert {:ok, %{slug: slug}} = + call( + Tools.CreateStack, + %{slug: "badfork", fork_url: "http://127.0.0.1:1", fork_block_number: 1}, + frame + ) + + assert {:error, message} = + call( + Tools.GetBlock, + %{slug: slug, block: "latest", full_transactions: false}, + frame + ) + + assert message =~ "failed to start" + end + + test "rejects an invalid slug", %{frame: frame} do + assert {:error, message} = call(Tools.CreateStack, %{slug: "Not Valid"}, frame) + assert message =~ "slug" + end + end + + describe "chain tools" do + setup %{frame: frame} do + {:ok, %{slug: slug}} = call(Tools.CreateStack, %{}, frame) + {:ok, slug: slug} + end + + test "reads blocks and addresses", %{frame: frame, slug: slug} do + assert {:ok, block} = + call( + Tools.GetBlock, + %{slug: slug, block: "latest", full_transactions: false}, + frame + ) + + assert block.number == "0x0" + assert String.ends_with?(block.explorer, "/block/0") + + assert {:ok, %{is_contract: false, nonce: "0"}} = + call(Tools.GetAddress, %{slug: slug, address: @whale, block: "latest"}, frame) + end + + test "mines blocks", %{frame: frame, slug: slug} do + assert {:ok, %{mined: 3, block_number: "0x3"}} = + call(Tools.Mine, %{slug: slug, blocks: 3}, frame) + end + + test "funds an address and moves value from it", %{frame: frame, slug: slug} do + one_eth = "1000000000000000000" + + assert {:ok, _} = + call( + Tools.SetBalance, + %{slug: slug, address: @whale, balance: "2#{one_eth}"}, + frame + ) + + assert {:ok, %{status: "success", hash: hash}} = + call( + Tools.Execute, + %{slug: slug, to: @recipient, from: @whale, value: one_eth}, + frame + ) + + assert {:ok, %{transaction: tx, explorer: explorer}} = + call(Tools.GetTransaction, %{slug: slug, hash: hash}, frame) + + assert String.downcase(tx.from) == @whale + assert explorer =~ "/tx/#{hash}" + + assert {:ok, %{balance_wei: ^one_eth}} = + call(Tools.GetAddress, %{slug: slug, address: @recipient, block: "latest"}, frame) + end + + test "simulates a call without committing state", %{frame: frame, slug: slug} do + assert {:ok, %{result: "0x"}} = + call( + Tools.SimulateCall, + %{slug: slug, to: @recipient, data: "0x", value: "0", block: "latest"}, + frame + ) + end + + test "snapshots and reverts", %{frame: frame, slug: slug} do + assert {:ok, %{snapshot_id: id}} = call(Tools.Snapshot, %{slug: slug}, frame) + + assert {:ok, _} = + call(Tools.SetBalance, %{slug: slug, address: @whale, balance: "1"}, frame) + + assert {:ok, %{reverted: true}} = call(Tools.Revert, %{slug: slug, snapshot_id: id}, frame) + + assert {:ok, %{balance_wei: "0"}} = + call(Tools.GetAddress, %{slug: slug, address: @whale, block: "latest"}, frame) + + assert {:error, message} = call(Tools.Revert, %{slug: slug, snapshot_id: id}, frame) + assert message =~ "unknown or already consumed snapshot" + end + + test "moves block time forward", %{frame: frame, slug: slug} do + future = System.os_time(:second) + 3600 + + assert {:ok, %{mined: true}} = + call(Tools.SetBlockTimestamp, %{slug: slug, timestamp: future, mine: true}, frame) + + assert {:ok, block} = + call( + Tools.GetBlock, + %{slug: slug, block: "latest", full_transactions: false}, + frame + ) + + assert String.to_integer(String.replace(block.timestamp, "0x", ""), 16) == future + end + + test "impersonates and releases an address", %{frame: frame, slug: slug} do + assert {:ok, %{impersonating: true}} = + call(Tools.Impersonate, %{slug: slug, address: @whale, stop: false}, frame) + + assert {:ok, %{impersonating: false}} = + call(Tools.Impersonate, %{slug: slug, address: @whale, stop: true}, frame) + end + + test "returns no logs on a fresh chain, defaulting the block range", %{ + frame: frame, + slug: slug + } do + assert {:ok, %{count: 0}} = call(Tools.GetLogs, %{slug: slug, to_block: "latest"}, frame) + end + end + + # Params arrive already validated at runtime, so defaults are passed explicitly here + defp call(tool, params, frame) do + assert {:reply, response, %Frame{}} = tool.execute(params, frame) + + [%{"type" => "text", "text" => text}] = response.content + + if response.isError, do: {:error, text}, else: {:ok, Jason.decode!(text, keys: :atoms)} + end + + defp authenticated_frame do + {:ok, token} = Accounts.generate_token(user("mcp")) + %Frame{context: %Context{headers: %{"authorization" => "Bearer #{token}"}}} + end + + defp user(prefix) do + email = "#{prefix}-#{System.unique_integer([:positive])}@example.com" + {:ok, user} = Accounts.send_verification_code(email) + user + end +end diff --git a/server/test/ethui_web/mcp_test.exs b/server/test/ethui_web/mcp_test.exs new file mode 100644 index 0000000..3319726 --- /dev/null +++ b/server/test/ethui_web/mcp_test.exs @@ -0,0 +1,124 @@ +defmodule EthuiWeb.MCPTest do + use EthuiWeb.ConnCase, async: false + + alias Ethui.Accounts + + @protocol_version "2025-06-18" + + setup do + # The app-level server stays idle in tests, since no endpoint is serving + start_supervised!({Ethui.MCP.Server, transport: {:streamable_http, start: true}}) + + original = Application.get_env(:ethui, EthuiWeb.Plugs.Authenticate, []) + Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, enabled: true) + on_exit(fn -> Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, original) end) + + {:ok, user} = + Accounts.send_verification_code( + "mcp-http-#{System.unique_integer([:positive])}@example.com" + ) + + {:ok, token} = Accounts.generate_token(user) + auth = [authorization: "Bearer #{token}"] + + {:ok, auth: auth, session: initialize(auth)} + end + + test "lists every tool", %{session: session, auth: auth} do + assert %{"result" => %{"tools" => tools}} = request(session, "tools/list", %{}, auth) + + names = Enum.map(tools, & &1["name"]) + + assert "create_stack" in names + assert "execute" in names + assert "set_block_timestamp" in names + assert length(names) == 15 + end + + test "refuses to open a session without a token" do + assert post_mcp(initialize_body(), []).status == 401 + end + + test "runs a tool for an authenticated caller", %{session: session, auth: auth} do + assert %{"result" => result} = + request(session, "tools/call", %{"name" => "list_stacks", "arguments" => %{}}, auth) + + refute result["isError"] + assert [%{"text" => "[]"}] = result["content"] + end + + test "reports unknown tools as protocol errors", %{session: session, auth: auth} do + assert %{"error" => error} = + request(session, "tools/call", %{"name" => "nope", "arguments" => %{}}, auth) + + assert error["message"] =~ "not found" or error["code"] + end + + defp initialize(headers) do + conn = post_mcp(initialize_body(), headers) + + assert conn.status == 200 + [session_id] = Plug.Conn.get_resp_header(conn, "mcp-session-id") + + post_mcp( + %{"jsonrpc" => "2.0", "method" => "notifications/initialized"}, + Keyword.put(headers, :"mcp-session-id", session_id) + ) + + session_id + end + + defp initialize_body do + %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "initialize", + "params" => %{ + "protocolVersion" => @protocol_version, + "clientInfo" => %{"name" => "test", "version" => "1.0.0"}, + "capabilities" => %{} + } + } + end + + defp request(session, method, params, headers) do + conn = + post_mcp( + %{ + "jsonrpc" => "2.0", + "id" => System.unique_integer([:positive]), + "method" => method, + "params" => params + }, + Keyword.put(headers, :"mcp-session-id", session) + ) + + assert conn.status == 200 + decode(conn.resp_body) + end + + # Responses come back as a single SSE event when the client accepts a stream + defp decode("event:" <> _ = body) do + body + |> String.split("\n", trim: true) + |> Enum.find_value(fn + "data:" <> payload -> Jason.decode!(String.trim(payload)) + _ -> nil + end) + end + + defp decode(body), do: Jason.decode!(body) + + defp post_mcp(body, headers) do + :post + |> Phoenix.ConnTest.build_conn("http://api.lvh.me/mcp", nil) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_req_header("accept", "application/json, text/event-stream") + |> then(fn conn -> + Enum.reduce(headers, conn, fn {key, value}, acc -> + Plug.Conn.put_req_header(acc, to_string(key), value) + end) + end) + |> Phoenix.ConnTest.dispatch(@endpoint, :post, "http://api.lvh.me/mcp", Jason.encode!(body)) + end +end