Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,38 @@ curl -X POST -d '{"slug": "foo"}' http://api.local.ethui.dev:4000/stacks
- **<http://graph-rpc-foo.local.ethui.dev>** (subgraph RPC client)
- **<http://ipfs-foo.local.ethui.dev>** (IPFS)
- **<http://foo.local.ethui.dev>** (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 <jwt>" }
}
}
}
```

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.
2 changes: 2 additions & 0 deletions server/config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
2 changes: 2 additions & 0 deletions server/lib/ethui/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion server/lib/ethui/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions server/lib/ethui/chain.ex
Original file line number Diff line number Diff line change
@@ -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,
<<reason::binary-size(len), _padding::binary>> <- rest do
{:ok, reason}
else
_ -> :error
end
end

def revert_reason(_), do: :error
end
58 changes: 58 additions & 0 deletions server/lib/ethui/mcp/auth.ex
Original file line number Diff line number Diff line change
@@ -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 <token>` header"}
end
end

defp bearer_token(_frame), do: {:error, "missing `Authorization: Bearer <token>` 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
29 changes: 29 additions & 0 deletions server/lib/ethui/mcp/explorer.ex
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions server/lib/ethui/mcp/server.ex
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions server/lib/ethui/mcp/stack_info.ex
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions server/lib/ethui/mcp/tool.ex
Original file line number Diff line number Diff line change
@@ -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
Loading