From 3f7b7a87b0d18c24f65bd44f7a93399a02068e46 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:17:06 +0100 Subject: [PATCH 01/13] feat(mcp): scaffold stacks MCP server with stack lifecycle tools Add a TS MCP package to the workspace. Exposes create_stack/list_stacks/ delete_stack over stdio against the Stacks REST API, with base64 explorer deep links built from each stack's key-in-url rpc. Co-Authored-By: Claude Opus 4.8 --- mcp/.gitignore | 3 ++ mcp/README.md | 62 ++++++++++++++++++++++++++++++++++++++++ mcp/package.json | 24 ++++++++++++++++ mcp/src/config.ts | 15 ++++++++++ mcp/src/explorer.ts | 17 +++++++++++ mcp/src/index.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++ mcp/src/stacks.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++ mcp/tsconfig.json | 15 ++++++++++ pnpm-workspace.yaml | 1 + 9 files changed, 274 insertions(+) create mode 100644 mcp/.gitignore create mode 100644 mcp/README.md create mode 100644 mcp/package.json create mode 100644 mcp/src/config.ts create mode 100644 mcp/src/explorer.ts create mode 100644 mcp/src/index.ts create mode 100644 mcp/src/stacks.ts create mode 100644 mcp/tsconfig.json diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..06e6038 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.tsbuildinfo diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..ec8ce70 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,62 @@ +# @ethui/stacks-mcp + +MCP server for [ethui Stacks](../). Lets an agent provision disposable forked +anvil sandboxes, inspect the chain, simulate & execute calls, and drive anvil +cheatcodes — with every result deep-linked into the ethui explorer for a human +to verify. + +## Why + +Stacks spins up forked anvil environments on demand. This MCP gives an agent the +full loop: **provision → experiment → verify → tear down**. The human watches in +the explorer; the agent does the work. + +## Config + +Runs over stdio. Point it at hosted Stacks (default) or a local instance. + +| Env | Default | Notes | +| --- | --- | --- | +| `STACKS_API` | `https://api.stacks.ethui.dev` | Stacks REST base | +| `STACKS_TOKEN` | — | JWT for `/stacks` CRUD (7-day). Omit against a no-auth local instance | +| `EXPLORER_BASE` | `https://explorer.ethui.dev` | Explorer for deep links | +| `FOUNDRY_OUT` | — | Path to a foundry `out/` dir for ABI decoding | + +### Getting a token (hosted) + +``` +curl -X POST https://api.stacks.ethui.dev/auth/send-code -d '{"email":"you@x.com"}' +curl -X POST https://api.stacks.ethui.dev/auth/verify-code -d '{"email":"you@x.com","code":"123456"}' +# -> { "token": "" } (valid 7 days) +``` + +## Claude Desktop / Claude Code + +```json +{ + "mcpServers": { + "ethui-stacks": { + "command": "node", + "args": ["/absolute/path/to/stacks/mcp/dist/index.js"], + "env": { + "STACKS_TOKEN": "", + "FOUNDRY_OUT": "/path/to/your/foundry/out" + } + } + } +} +``` + +## Build + +``` +pnpm --filter @ethui/stacks-mcp build +``` + +## Tools + +- `create_stack` / `list_stacks` / `delete_stack` — sandbox lifecycle +- _(next)_ `get_block` / `get_transaction` / `get_address` / `get_logs` — read + explorer links +- _(next)_ `simulate_call` / `execute` — run the experiment +- _(next)_ cheatcodes: `impersonate` / `set_balance` / `mine` / `set_block_timestamp` / `snapshot` / `revert` +- _(next)_ `decode` — foundry ABI decoding diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..902a9d7 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,24 @@ +{ + "name": "@ethui/stacks-mcp", + "version": "0.0.0", + "description": "MCP server for ethui Stacks — provision forked anvil sandboxes, inspect chain, simulate & execute, drive anvil cheatcodes", + "type": "module", + "private": true, + "bin": { + "ethui-stacks-mcp": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "viem": "^2.21.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/mcp/src/config.ts b/mcp/src/config.ts new file mode 100644 index 0000000..3476640 --- /dev/null +++ b/mcp/src/config.ts @@ -0,0 +1,15 @@ +export interface Config { + stacksApi: string; + stacksToken: string | undefined; + explorerBase: string; + foundryOut: string | undefined; +} + +export function loadConfig(): Config { + return { + stacksApi: process.env.STACKS_API ?? "https://api.stacks.ethui.dev", + stacksToken: process.env.STACKS_TOKEN, + explorerBase: process.env.EXPLORER_BASE ?? "https://explorer.ethui.dev", + foundryOut: process.env.FOUNDRY_OUT, + }; +} diff --git a/mcp/src/explorer.ts b/mcp/src/explorer.ts new file mode 100644 index 0000000..376e818 --- /dev/null +++ b/mcp/src/explorer.ts @@ -0,0 +1,17 @@ +import type { Config } from "./config.js"; + +// Explorer encodes the RPC url as base64 in the path: atob(params.rpc). +// The stack's api key is already inside http_rpc, so the browser self-auths. +function encodeRpc(rpc: string): string { + return Buffer.from(rpc, "utf8").toString("base64"); +} + +export function explorerLinks(cfg: Config, rpc: string) { + const base = `${cfg.explorerBase}/rpc/${encodeRpc(rpc)}`; + return { + root: base, + tx: (hash: string) => `${base}/tx/${hash}`, + address: (addr: string) => `${base}/address/${addr}`, + block: (n: number | string) => `${base}/block/${n}`, + }; +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 0000000..d009411 --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { loadConfig } from "./config.js"; +import { StacksClient } from "./stacks.js"; +import { explorerLinks } from "./explorer.js"; + +const cfg = loadConfig(); +const stacks = new StacksClient(cfg); + +const server = new McpServer({ name: "ethui-stacks-mcp", version: "0.0.0" }); + +server.tool( + "create_stack", + "Fork any chain into a fresh disposable anvil sandbox. Returns rpc url + explorer link. Use fork_url + fork_block_number to fork mainnet at a block.", + { + slug: z.string().optional().describe("Optional stack name; auto-generated if omitted"), + fork_url: z.string().optional().describe("RPC url to fork from (e.g. mainnet)"), + fork_block_number: z.number().int().optional().describe("Block to fork at"), + }, + async (args) => { + const stack = await stacks.createStack(args); + const links = explorerLinks(cfg, stack.urls.http_rpc); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + slug: stack.slug, + status: stack.status, + rpc: stack.urls.http_rpc, + ws: stack.urls.ws_rpc, + explorer: links.root, + }, + null, + 2, + ), + }, + ], + }; + }, +); + +server.tool("list_stacks", "List running stacks with their rpc + explorer urls.", {}, async () => { + const list = await stacks.listStacks(); + const rows = list.map((s) => ({ + slug: s.slug, + status: s.status, + rpc: s.urls?.http_rpc, + explorer: s.urls?.http_rpc ? explorerLinks(cfg, s.urls.http_rpc).root : undefined, + })); + return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] }; +}); + +server.tool( + "delete_stack", + "Tear down a stack by slug.", + { slug: z.string().describe("Stack slug to destroy") }, + async ({ slug }) => { + await stacks.deleteStack(slug); + return { content: [{ type: "text", text: `Deleted stack ${slug}` }] }; + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/mcp/src/stacks.ts b/mcp/src/stacks.ts new file mode 100644 index 0000000..8bba021 --- /dev/null +++ b/mcp/src/stacks.ts @@ -0,0 +1,69 @@ +import type { Config } from "./config.js"; + +export interface StackUrls { + http_rpc: string; + ws_rpc: string; + explorer: string; + [key: string]: string; +} + +export interface Stack { + slug: string; + status: string; + urls: StackUrls; + chain_id?: string; + anvil_opts?: Record; +} + +export interface CreateStackParams { + slug?: string; + fork_url?: string; + fork_block_number?: number; +} + +export class StacksClient { + constructor(private cfg: Config) {} + + private async req(path: string, init?: RequestInit): Promise { + const headers: Record = { + "content-type": "application/json", + ...(init?.headers as Record), + }; + if (this.cfg.stacksToken) { + headers.authorization = `Bearer ${this.cfg.stacksToken}`; + } + + const res = await fetch(`${this.cfg.stacksApi}${path}`, { ...init, headers }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Stacks ${init?.method ?? "GET"} ${path} -> ${res.status}: ${body}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + async createStack(params: CreateStackParams): Promise { + const anvil_opts: Record = {}; + if (params.fork_url) anvil_opts.fork_url = params.fork_url; + if (params.fork_block_number != null) + anvil_opts.fork_block_number = params.fork_block_number; + + const body: Record = { anvil_opts }; + if (params.slug) body.slug = params.slug; + + const { data } = await this.req<{ data: Stack }>("/stacks", { + method: "POST", + body: JSON.stringify(body), + }); + return data; + } + + async listStacks(): Promise { + const { data } = await this.req<{ data: Stack[] }>("/stacks"); + return data; + } + + async deleteStack(slug: string): Promise { + await this.req(`/stacks/${slug}`, { method: "DELETE" }); + } +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000..172662e --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 315de4c..c0654cf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - 'frontend' + - 'mcp' From 5fb4c8e1d0889f7a931776fb8fda388fd0651906 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:19:24 +0100 Subject: [PATCH 02/13] feat(mcp): add chain read tools with explorer links get_block/get_transaction/get_address/get_logs resolve a stack's rpc via GET /stacks/:slug and return bigint-safe data plus an explorer deep link. Co-Authored-By: Claude Opus 4.8 --- mcp/src/chain.ts | 50 ++++++++++++++++++++++++++++++++++ mcp/src/index.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++++ mcp/src/stacks.ts | 11 ++++++++ 3 files changed, 129 insertions(+) create mode 100644 mcp/src/chain.ts diff --git a/mcp/src/chain.ts b/mcp/src/chain.ts new file mode 100644 index 0000000..dd40451 --- /dev/null +++ b/mcp/src/chain.ts @@ -0,0 +1,50 @@ +import { createPublicClient, http, type Address, type Hash } from "viem"; + +export function clientFor(rpc: string) { + return createPublicClient({ transport: http(rpc) }); +} + +// viem returns bigints; JSON.stringify can't serialize them. Bigints become strings. +export function jsonSafe(value: T): T { + return JSON.parse( + JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)), + ); +} + +export async function getBlock(rpc: string, block: bigint | "latest") { + const client = clientFor(rpc); + return block === "latest" + ? client.getBlock() + : client.getBlock({ blockNumber: block }); +} + +export async function getTransaction(rpc: string, hash: Hash) { + const client = clientFor(rpc); + const [tx, receipt] = await Promise.all([ + client.getTransaction({ hash }), + client.getTransactionReceipt({ hash }).catch(() => null), + ]); + return { tx, receipt }; +} + +export async function getAddress(rpc: string, address: Address) { + const client = clientFor(rpc); + const [balance, nonce, code] = await Promise.all([ + client.getBalance({ address }), + client.getTransactionCount({ address }), + client.getCode({ address }), + ]); + return { address, balance, nonce, isContract: !!code && code !== "0x", code }; +} + +export async function getLogs( + rpc: string, + params: { address?: Address; fromBlock?: bigint; toBlock?: bigint }, +) { + const client = clientFor(rpc); + return client.getLogs({ + address: params.address, + fromBlock: params.fromBlock, + toBlock: params.toBlock, + }); +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index d009411..515c6c5 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -2,9 +2,15 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; +import type { Address, Hash } from "viem"; import { loadConfig } from "./config.js"; import { StacksClient } from "./stacks.js"; import { explorerLinks } from "./explorer.js"; +import { getAddress, getBlock, getLogs, getTransaction, jsonSafe } from "./chain.js"; + +function text(value: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; +} const cfg = loadConfig(); const stacks = new StacksClient(cfg); @@ -64,5 +70,67 @@ server.tool( }, ); +server.tool( + "get_block", + "Get a block on a stack. Returns block data + explorer link.", + { + slug: z.string().describe("Stack slug"), + block: z + .union([z.number().int(), z.literal("latest")]) + .default("latest") + .describe("Block number, or 'latest'"), + }, + async ({ slug, block }) => { + const rpc = await stacks.rpcFor(slug); + const b = await getBlock(rpc, block === "latest" ? "latest" : BigInt(block)); + const links = explorerLinks(cfg, rpc); + return text({ block: jsonSafe(b), explorer: links.block(b.number?.toString() ?? "latest") }); + }, +); + +server.tool( + "get_transaction", + "Get a transaction and its receipt on a stack. Returns data + explorer link.", + { slug: z.string().describe("Stack slug"), hash: z.string().describe("Tx hash") }, + async ({ slug, hash }) => { + const rpc = await stacks.rpcFor(slug); + const result = await getTransaction(rpc, hash as Hash); + const links = explorerLinks(cfg, rpc); + return text({ ...jsonSafe(result), explorer: links.tx(hash) }); + }, +); + +server.tool( + "get_address", + "Get balance, nonce, and code for an address on a stack. Returns data + explorer link.", + { slug: z.string().describe("Stack slug"), address: z.string().describe("Address") }, + async ({ slug, address }) => { + const rpc = await stacks.rpcFor(slug); + const info = await getAddress(rpc, address as Address); + const links = explorerLinks(cfg, rpc); + return text({ ...jsonSafe(info), explorer: links.address(address) }); + }, +); + +server.tool( + "get_logs", + "Fetch event logs on a stack, optionally filtered by address and block range.", + { + slug: z.string().describe("Stack slug"), + address: z.string().optional().describe("Filter by contract address"), + fromBlock: z.number().int().optional().describe("Start block"), + toBlock: z.number().int().optional().describe("End block"), + }, + async ({ slug, address, fromBlock, toBlock }) => { + const rpc = await stacks.rpcFor(slug); + const logs = await getLogs(rpc, { + address: address as Address | undefined, + fromBlock: fromBlock != null ? BigInt(fromBlock) : undefined, + toBlock: toBlock != null ? BigInt(toBlock) : undefined, + }); + return text({ count: logs.length, logs: jsonSafe(logs) }); + }, +); + const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/mcp/src/stacks.ts b/mcp/src/stacks.ts index 8bba021..9f0065c 100644 --- a/mcp/src/stacks.ts +++ b/mcp/src/stacks.ts @@ -63,6 +63,17 @@ export class StacksClient { return data; } + async getStack(slug: string): Promise { + const { data } = await this.req<{ data: Stack }>(`/stacks/${slug}`); + return data; + } + + async rpcFor(slug: string): Promise { + const stack = await this.getStack(slug); + if (!stack.urls?.http_rpc) throw new Error(`Stack ${slug} has no http_rpc url`); + return stack.urls.http_rpc; + } + async deleteStack(slug: string): Promise { await this.req(`/stacks/${slug}`, { method: "DELETE" }); } From 0ff44fee547fffe411dca022cbeeb29271684108 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:22:53 +0100 Subject: [PATCH 03/13] feat(mcp): add simulate_call and execute tools simulate_call dry-runs via eth_call. execute signs with PRIVATE_KEY or the default anvil account 0 (no config needed), waits for the receipt, and returns an explorer link. Co-Authored-By: Claude Opus 4.8 --- mcp/src/chain.ts | 54 ++++++++++++++++++++++++++++++++++++++++++- mcp/src/config.ts | 4 ++++ mcp/src/index.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/mcp/src/chain.ts b/mcp/src/chain.ts index dd40451..9cef24b 100644 --- a/mcp/src/chain.ts +++ b/mcp/src/chain.ts @@ -1,9 +1,26 @@ -import { createPublicClient, http, type Address, type Hash } from "viem"; +import { + createPublicClient, + createWalletClient, + http, + type Address, + type Hash, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +// anvil's default funded account 0 — public, deterministic dev key. +export const ANVIL_ACCOUNT_0 = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; export function clientFor(rpc: string) { return createPublicClient({ transport: http(rpc) }); } +export function walletFor(rpc: string, privateKey?: Hex) { + const account = privateKeyToAccount(privateKey ?? ANVIL_ACCOUNT_0); + return createWalletClient({ account, transport: http(rpc) }); +} + // viem returns bigints; JSON.stringify can't serialize them. Bigints become strings. export function jsonSafe(value: T): T { return JSON.parse( @@ -48,3 +65,38 @@ export async function getLogs( toBlock: params.toBlock, }); } + +export interface CallParams { + to: Address; + data?: Hex; + value?: bigint; + from?: Address; +} + +export async function simulateCall(rpc: string, params: CallParams) { + const client = clientFor(rpc); + const result = await client.call({ + to: params.to, + data: params.data, + value: params.value, + account: params.from, + }); + return { data: result.data ?? "0x" }; +} + +export async function execute( + rpc: string, + params: CallParams, + privateKey?: Hex, +) { + const wallet = walletFor(rpc, privateKey); + const public_ = clientFor(rpc); + const hash = await wallet.sendTransaction({ + chain: null, + to: params.to, + data: params.data, + value: params.value, + }); + const receipt = await public_.waitForTransactionReceipt({ hash }); + return { hash, receipt }; +} diff --git a/mcp/src/config.ts b/mcp/src/config.ts index 3476640..a87e9fe 100644 --- a/mcp/src/config.ts +++ b/mcp/src/config.ts @@ -1,8 +1,11 @@ +import type { Hex } from "viem"; + export interface Config { stacksApi: string; stacksToken: string | undefined; explorerBase: string; foundryOut: string | undefined; + privateKey: Hex | undefined; } export function loadConfig(): Config { @@ -11,5 +14,6 @@ export function loadConfig(): Config { stacksToken: process.env.STACKS_TOKEN, explorerBase: process.env.EXPLORER_BASE ?? "https://explorer.ethui.dev", foundryOut: process.env.FOUNDRY_OUT, + privateKey: process.env.PRIVATE_KEY as Hex | undefined, }; } diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 515c6c5..0ee7c78 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -2,11 +2,19 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; -import type { Address, Hash } from "viem"; +import type { Address, Hash, Hex } from "viem"; import { loadConfig } from "./config.js"; import { StacksClient } from "./stacks.js"; import { explorerLinks } from "./explorer.js"; -import { getAddress, getBlock, getLogs, getTransaction, jsonSafe } from "./chain.js"; +import { + execute, + getAddress, + getBlock, + getLogs, + getTransaction, + jsonSafe, + simulateCall, +} from "./chain.js"; function text(value: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; @@ -132,5 +140,52 @@ server.tool( }, ); +server.tool( + "simulate_call", + "Dry-run a call on a stack (eth_call, no state change). Returns return data.", + { + slug: z.string().describe("Stack slug"), + to: z.string().describe("Target contract address"), + data: z.string().optional().describe("Calldata hex (0x...)"), + value: z.string().optional().describe("Wei value as decimal string"), + from: z.string().optional().describe("Caller address (defaults to node)"), + }, + async ({ slug, to, data, value, from }) => { + const rpc = await stacks.rpcFor(slug); + const result = await simulateCall(rpc, { + to: to as Address, + data: data as Hex | undefined, + value: value != null ? BigInt(value) : undefined, + from: from as Address | undefined, + }); + return text(result); + }, +); + +server.tool( + "execute", + "Send a transaction on a stack and wait for the receipt. Signs with PRIVATE_KEY or anvil account 0 by default. Returns hash, receipt + explorer link.", + { + slug: z.string().describe("Stack slug"), + to: z.string().describe("Target address"), + data: z.string().optional().describe("Calldata hex (0x...)"), + value: z.string().optional().describe("Wei value as decimal string"), + }, + async ({ slug, to, data, value }) => { + const rpc = await stacks.rpcFor(slug); + const result = await execute( + rpc, + { + to: to as Address, + data: data as Hex | undefined, + value: value != null ? BigInt(value) : undefined, + }, + cfg.privateKey, + ); + const links = explorerLinks(cfg, rpc); + return text({ ...jsonSafe(result), explorer: links.tx(result.hash) }); + }, +); + const transport = new StdioServerTransport(); await server.connect(transport); From f56d8fa7a3b99d511503145fe40717e64737e405 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:40:37 +0100 Subject: [PATCH 04/13] feat(mcp): add foundry ABI decode, deploy_contract, and anvil cheatcodes - abi.ts loads foundry out/*.json into a merged ABI for calldata/event decode - get_transaction now decodes call + events when FOUNDRY_OUT is set - deploy_contract deploys bytecode from artifacts, returns address + link - execute gains a 'from' param that impersonates any sender (no key) - cheatcodes: impersonate, set_balance, mine, set_block_timestamp, snapshot, revert via raw anvil_/evm_ rpc Co-Authored-By: Claude Opus 4.8 --- mcp/src/abi.ts | 75 +++++++++++++++++++++++++++++ mcp/src/chain.ts | 48 +++++++++++++++++++ mcp/src/index.ts | 120 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 mcp/src/abi.ts diff --git a/mcp/src/abi.ts b/mcp/src/abi.ts new file mode 100644 index 0000000..179a308 --- /dev/null +++ b/mcp/src/abi.ts @@ -0,0 +1,75 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { decodeFunctionData, parseEventLogs, type Abi, type Hex, type Log } from "viem"; + +export interface Artifact { + name: string; + abi: Abi; + bytecode: Hex; +} + +interface Loaded { + out: string; + byName: Map; + merged: Abi; +} + +let cache: Loaded | null = null; + +function walkJson(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) found.push(...walkJson(full)); + else if (entry.endsWith(".json")) found.push(full); + } + return found; +} + +function load(out: string): Loaded { + if (cache && cache.out === out) return cache; + + const byName = new Map(); + const merged: Abi[number][] = []; + + for (const file of walkJson(out)) { + let json: { abi?: Abi; bytecode?: { object?: string } }; + try { + json = JSON.parse(readFileSync(file, "utf8")); + } catch { + continue; + } + if (!json.abi) continue; + + const name = file.split("/").pop()!.replace(/\.json$/, ""); + const bytecode = (json.bytecode?.object ?? "0x") as Hex; + byName.set(name, { name, abi: json.abi, bytecode }); + merged.push(...json.abi); + } + + cache = { out, byName, merged }; + return cache; +} + +export function getArtifact(out: string, name: string): Artifact { + const artifact = load(out).byName.get(name); + if (!artifact) throw new Error(`Contract "${name}" not found in ${out}`); + return artifact; +} + +export function decodeCalldata(out: string, data: Hex) { + if (!data || data === "0x") return null; + try { + return decodeFunctionData({ abi: load(out).merged, data }); + } catch { + return null; + } +} + +export function decodeLogs(out: string, logs: Log[]) { + try { + return parseEventLogs({ abi: load(out).merged, logs }); + } catch { + return []; + } +} diff --git a/mcp/src/chain.ts b/mcp/src/chain.ts index 9cef24b..60a3ecc 100644 --- a/mcp/src/chain.ts +++ b/mcp/src/chain.ts @@ -100,3 +100,51 @@ export async function execute( const receipt = await public_.waitForTransactionReceipt({ hash }); return { hash, receipt }; } + +// anvil signs on behalf of an impersonated address — no private key needed. +export async function executeAs(rpc: string, from: Address, params: CallParams) { + const client = clientFor(rpc); + await anvil(rpc, "anvil_impersonateAccount", [from]); + try { + const hash = (await client.request({ + method: "eth_sendTransaction", + params: [ + { + from, + to: params.to, + data: params.data, + value: params.value != null ? `0x${params.value.toString(16)}` : undefined, + }, + ], + } as never)) as Hash; + const receipt = await client.waitForTransactionReceipt({ hash }); + return { hash, receipt }; + } finally { + await anvil(rpc, "anvil_stopImpersonatingAccount", [from]); + } +} + +export async function deployContract( + rpc: string, + abi: readonly unknown[], + bytecode: Hex, + args: unknown[], + privateKey?: Hex, +) { + const wallet = walletFor(rpc, privateKey); + const public_ = clientFor(rpc); + const hash = await wallet.deployContract({ + chain: null, + abi: abi as never, + bytecode, + args, + }); + const receipt = await public_.waitForTransactionReceipt({ hash }); + return { hash, address: receipt.contractAddress, receipt }; +} + +// Raw anvil_/evm_ cheatcode passthrough. +export async function anvil(rpc: string, method: string, params: unknown[]) { + const client = clientFor(rpc); + return client.request({ method, params } as never); +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 0ee7c78..4ccd474 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -7,7 +7,10 @@ import { loadConfig } from "./config.js"; import { StacksClient } from "./stacks.js"; import { explorerLinks } from "./explorer.js"; import { + anvil, + deployContract, execute, + executeAs, getAddress, getBlock, getLogs, @@ -15,6 +18,11 @@ import { jsonSafe, simulateCall, } from "./chain.js"; +import { decodeCalldata, decodeLogs, getArtifact } from "./abi.js"; + +function toWeiHex(decimal: string): Hex { + return `0x${BigInt(decimal).toString(16)}`; +} function text(value: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; @@ -104,7 +112,13 @@ server.tool( const rpc = await stacks.rpcFor(slug); const result = await getTransaction(rpc, hash as Hash); const links = explorerLinks(cfg, rpc); - return text({ ...jsonSafe(result), explorer: links.tx(hash) }); + const decoded = cfg.foundryOut + ? { + call: decodeCalldata(cfg.foundryOut, result.tx.input), + events: result.receipt ? decodeLogs(cfg.foundryOut, result.receipt.logs) : [], + } + : undefined; + return text({ ...jsonSafe(result), decoded: jsonSafe(decoded), explorer: links.tx(hash) }); }, ); @@ -164,28 +178,114 @@ server.tool( server.tool( "execute", - "Send a transaction on a stack and wait for the receipt. Signs with PRIVATE_KEY or anvil account 0 by default. Returns hash, receipt + explorer link.", + "Send a transaction on a stack and wait for the receipt. Signs with PRIVATE_KEY or anvil account 0 by default. Pass 'from' to send as any address via impersonation (no key needed). Returns hash, receipt + explorer link.", { slug: z.string().describe("Stack slug"), to: z.string().describe("Target address"), data: z.string().optional().describe("Calldata hex (0x...)"), value: z.string().optional().describe("Wei value as decimal string"), + from: z.string().optional().describe("Impersonate this sender (no key needed)"), + }, + async ({ slug, to, data, value, from }) => { + const rpc = await stacks.rpcFor(slug); + const params = { + to: to as Address, + data: data as Hex | undefined, + value: value != null ? BigInt(value) : undefined, + }; + const result = from + ? await executeAs(rpc, from as Address, params) + : await execute(rpc, params, cfg.privateKey); + const links = explorerLinks(cfg, rpc); + return text({ ...jsonSafe(result), explorer: links.tx(result.hash) }); + }, +); + +server.tool( + "deploy_contract", + "Deploy a compiled contract from the foundry out/ dir to a stack. Returns address + explorer link.", + { + slug: z.string().describe("Stack slug"), + contract: z.string().describe("Contract name, e.g. 'Counter'"), + args: z.array(z.any()).optional().describe("Constructor args"), }, - async ({ slug, to, data, value }) => { + async ({ slug, contract, args }) => { + if (!cfg.foundryOut) throw new Error("FOUNDRY_OUT not set"); + const artifact = getArtifact(cfg.foundryOut, contract); + if (!artifact.bytecode || artifact.bytecode === "0x") + throw new Error(`${contract} has no bytecode (interface or abstract?)`); const rpc = await stacks.rpcFor(slug); - const result = await execute( + const result = await deployContract( rpc, - { - to: to as Address, - data: data as Hex | undefined, - value: value != null ? BigInt(value) : undefined, - }, + artifact.abi, + artifact.bytecode, + args ?? [], cfg.privateKey, ); const links = explorerLinks(cfg, rpc); - return text({ ...jsonSafe(result), explorer: links.tx(result.hash) }); + return text({ + contract, + address: result.address, + txHash: result.hash, + explorer: result.address ? links.address(result.address) : links.tx(result.hash), + }); }, ); +const cheat = ( + name: string, + description: string, + shape: z.ZodRawShape, + build: (a: Record) => { method: string; params: unknown[] }, +) => + server.tool(name, description, { slug: z.string().describe("Stack slug"), ...shape }, async (a) => { + const rpc = await stacks.rpcFor(a.slug as string); + const { method, params } = build(a as Record); + const result = await anvil(rpc, method, params); + return text({ method, result: jsonSafe(result) ?? "ok" }); + }); + +cheat( + "impersonate", + "Impersonate an address so subsequent txs can be sent as it (whale testing).", + { address: z.string().describe("Address to impersonate") }, + (a) => ({ method: "anvil_impersonateAccount", params: [a.address] }), +); + +cheat( + "set_balance", + "Set an address's native balance.", + { address: z.string().describe("Address"), wei: z.string().describe("Balance in wei (decimal)") }, + (a) => ({ method: "anvil_setBalance", params: [a.address, toWeiHex(a.wei!)] }), +); + +cheat( + "mine", + "Mine blocks immediately.", + { blocks: z.string().optional().describe("How many (default 1)") }, + (a) => ({ method: "anvil_mine", params: [`0x${BigInt(a.blocks ?? "1").toString(16)}`] }), +); + +cheat( + "set_block_timestamp", + "Set the timestamp of the next block (unix seconds).", + { timestamp: z.string().describe("Unix seconds") }, + (a) => ({ method: "evm_setNextBlockTimestamp", params: [Number(a.timestamp)] }), +); + +cheat( + "snapshot", + "Snapshot current chain state; returns an id to revert to.", + {}, + () => ({ method: "evm_snapshot", params: [] }), +); + +cheat( + "revert", + "Revert chain state to a snapshot id.", + { id: z.string().describe("Snapshot id from snapshot") }, + (a) => ({ method: "evm_revert", params: [a.id] }), +); + const transport = new StdioServerTransport(); await server.connect(transport); From cc9fe81a78f436e807a42c1e6110c2c217c0944a Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:40:56 +0100 Subject: [PATCH 05/13] docs(mcp): update tool list and demo paths Co-Authored-By: Claude Opus 4.8 --- mcp/README.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index ec8ce70..71048a1 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -56,7 +56,19 @@ pnpm --filter @ethui/stacks-mcp build ## Tools - `create_stack` / `list_stacks` / `delete_stack` — sandbox lifecycle -- _(next)_ `get_block` / `get_transaction` / `get_address` / `get_logs` — read + explorer links -- _(next)_ `simulate_call` / `execute` — run the experiment -- _(next)_ cheatcodes: `impersonate` / `set_balance` / `mine` / `set_block_timestamp` / `snapshot` / `revert` -- _(next)_ `decode` — foundry ABI decoding +- `get_block` / `get_transaction` / `get_address` / `get_logs` — read + explorer links + (`get_transaction` decodes call + events when `FOUNDRY_OUT` is set) +- `deploy_contract` — deploy a compiled contract from `out/` to a stack +- `simulate_call` / `execute` — run the experiment (`execute` takes `from` to + impersonate any sender with no key) +- cheatcodes: `impersonate` / `set_balance` / `mine` / `set_block_timestamp` / + `snapshot` / `revert` + +## Demo paths + +**Deploy your own:** `create_stack` → `deploy_contract Counter` → `execute` / +`simulate_call` → `get_transaction` (decoded) → open explorer link → `delete_stack`. + +**Fork mainnet:** `create_stack {fork_url, fork_block_number}` → `impersonate` +a whale (or `execute` with `from`) → move funds → inspect in explorer → +`delete_stack`. From 5bc9e396570eace8d2e4910afe67ed9ae95aa6a4 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:45:27 +0100 Subject: [PATCH 06/13] feat(mcp): 4byte fallback for calldata not in foundry out/ When a selector isn't in local artifacts, resolve the signature via the openchain database and decode args from it. Lets get_transaction decode arbitrary mainnet contracts on a fork. Co-Authored-By: Claude Opus 4.8 --- mcp/src/abi.ts | 35 ++++++++++++++++++++++++++++++++++- mcp/src/index.ts | 18 ++++++++++-------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/mcp/src/abi.ts b/mcp/src/abi.ts index 179a308..c7635aa 100644 --- a/mcp/src/abi.ts +++ b/mcp/src/abi.ts @@ -1,6 +1,14 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; -import { decodeFunctionData, parseEventLogs, type Abi, type Hex, type Log } from "viem"; +import { + decodeFunctionData, + parseAbiItem, + parseEventLogs, + slice, + type Abi, + type Hex, + type Log, +} from "viem"; export interface Artifact { name: string; @@ -73,3 +81,28 @@ export function decodeLogs(out: string, logs: Log[]) { return []; } } + +// Fallback when the selector isn't in out/: resolve the signature via the +// openchain/4byte database, then decode args from the recovered signature. +export async function decodeVia4byte(data: Hex) { + if (!data || data.length < 10) return null; + const selector = slice(data, 0, 4); + try { + const res = await fetch( + `https://api.openchain.xyz/signature-database/v1/lookup?function=${selector}&filter=true`, + ); + if (!res.ok) return null; + const json = (await res.json()) as { + result?: { function?: Record }; + }; + const candidates = json.result?.function?.[selector] ?? []; + for (const { name } of candidates) { + try { + const item = parseAbiItem(`function ${name}`); + const decoded = decodeFunctionData({ abi: [item], data }); + return { signature: name, ...decoded, source: "4byte" as const }; + } catch {} + } + } catch {} + return null; +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 4ccd474..ab20618 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -18,7 +18,7 @@ import { jsonSafe, simulateCall, } from "./chain.js"; -import { decodeCalldata, decodeLogs, getArtifact } from "./abi.js"; +import { decodeCalldata, decodeLogs, decodeVia4byte, getArtifact } from "./abi.js"; function toWeiHex(decimal: string): Hex { return `0x${BigInt(decimal).toString(16)}`; @@ -112,13 +112,15 @@ server.tool( const rpc = await stacks.rpcFor(slug); const result = await getTransaction(rpc, hash as Hash); const links = explorerLinks(cfg, rpc); - const decoded = cfg.foundryOut - ? { - call: decodeCalldata(cfg.foundryOut, result.tx.input), - events: result.receipt ? decodeLogs(cfg.foundryOut, result.receipt.logs) : [], - } - : undefined; - return text({ ...jsonSafe(result), decoded: jsonSafe(decoded), explorer: links.tx(hash) }); + let call = cfg.foundryOut ? decodeCalldata(cfg.foundryOut, result.tx.input) : null; + if (!call) call = await decodeVia4byte(result.tx.input); + const events = + cfg.foundryOut && result.receipt ? decodeLogs(cfg.foundryOut, result.receipt.logs) : []; + return text({ + ...jsonSafe(result), + decoded: jsonSafe({ call, events }), + explorer: links.tx(hash), + }); }, ); From 9ce3110ec557327dd221ca02ef1739c558b05c7c Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:46:32 +0100 Subject: [PATCH 07/13] feat(mcp): add end-to-end fork-mainnet whale demo script pnpm demo forks mainnet into a stack, impersonates a USDC whale, transfers to anvil account 0, decodes the tx, prints explorer links, tears down. Doubles as an e2e test (needs STACKS_TOKEN + FORK_URL). Co-Authored-By: Claude Opus 4.8 --- mcp/README.md | 12 +++++++++ mcp/package.json | 3 ++- mcp/src/demo.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 mcp/src/demo.ts diff --git a/mcp/README.md b/mcp/README.md index 71048a1..14846a5 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -53,6 +53,18 @@ curl -X POST https://api.stacks.ethui.dev/auth/verify-code -d '{"email":"you@x.c pnpm --filter @ethui/stacks-mcp build ``` +## End-to-end demo + +Runs the fork-mainnet whale flow against live Stacks and prints explorer links: + +``` +export STACKS_TOKEN= +export FORK_URL= +pnpm --filter @ethui/stacks-mcp demo +``` + +Optional overrides: `USDC`, `WHALE`, `RECIPIENT`. + ## Tools - `create_stack` / `list_stacks` / `delete_stack` — sandbox lifecycle diff --git a/mcp/package.json b/mcp/package.json index 902a9d7..499f75f 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -10,7 +10,8 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "start": "node dist/index.js" + "start": "node dist/index.js", + "demo": "node dist/demo.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/mcp/src/demo.ts b/mcp/src/demo.ts new file mode 100644 index 0000000..c91a1f2 --- /dev/null +++ b/mcp/src/demo.ts @@ -0,0 +1,66 @@ +import { encodeFunctionData, formatUnits, parseAbiItem, type Address, type Hex } from "viem"; +import { loadConfig } from "./config.js"; +import { StacksClient } from "./stacks.js"; +import { explorerLinks } from "./explorer.js"; +import { clientFor, executeAs } from "./chain.js"; +import { decodeVia4byte } from "./abi.js"; + +// Fork-mainnet whale demo: fork -> impersonate a USDC whale -> transfer to +// anvil account 0 -> decode the tx -> print explorer links -> tear down. +const USDC = (process.env.USDC ?? + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") as Address; +const WHALE = (process.env.WHALE ?? + "0x28C6c06298d514Db089934071355E5743bf21d60") as Address; +const RECIPIENT = (process.env.RECIPIENT ?? + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") as Address; + +const balanceOf = parseAbiItem("function balanceOf(address) view returns (uint256)"); +const transfer = parseAbiItem("function transfer(address,uint256) returns (bool)"); + +async function main() { + const cfg = loadConfig(); + const forkUrl = process.env.FORK_URL; + if (!cfg.stacksToken) throw new Error("Set STACKS_TOKEN"); + if (!forkUrl) throw new Error("Set FORK_URL (a mainnet rpc to fork)"); + + const stacks = new StacksClient(cfg); + + console.log("→ creating forked stack…"); + const stack = await stacks.createStack({ fork_url: forkUrl }); + const rpc = stack.urls.http_rpc; + const links = explorerLinks(cfg, rpc); + console.log(` stack ${stack.slug} @ ${rpc}`); + + try { + const client = clientFor(rpc); + const read = (owner: Address) => + client.readContract({ address: USDC, abi: [balanceOf], functionName: "balanceOf", args: [owner] }) as Promise; + + const whaleBal = await read(WHALE); + console.log(` whale USDC: ${formatUnits(whaleBal, 6)}`); + const amount = whaleBal / 2n; + + const data = encodeFunctionData({ abi: [transfer], functionName: "transfer", args: [RECIPIENT, amount] }) as Hex; + + console.log("→ impersonating whale, transferring half…"); + const { hash } = await executeAs(rpc, WHALE, { to: USDC, data }); + console.log(` tx ${hash}`); + console.log(` decoded: ${JSON.stringify(await decodeVia4byte(data))}`); + + const recipientBal = await read(RECIPIENT); + console.log(` recipient USDC: ${formatUnits(recipientBal, 6)}`); + + console.log("\nExplorer:"); + console.log(` tx: ${links.tx(hash)}`); + console.log(` recipient: ${links.address(RECIPIENT)}`); + } finally { + console.log("\n→ tearing down…"); + await stacks.deleteStack(stack.slug); + console.log(" done"); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From 5f65691e4fa2d749ed870e35015021413402fd04 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 18:50:11 +0100 Subject: [PATCH 08/13] feat(mcp): add explorer_link tool for on-demand deep links Build a tx/address/block/root explorer link for a stack without a fetch. Co-Authored-By: Claude Opus 4.8 --- mcp/src/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/mcp/src/index.ts b/mcp/src/index.ts index ab20618..4bcc754 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -289,5 +289,28 @@ cheat( (a) => ({ method: "evm_revert", params: [a.id] }), ); +server.tool( + "explorer_link", + "Build an ethui explorer deep link for a stack — to a tx, address, block, or the stack root. Hand this to a human to inspect.", + { + slug: z.string().describe("Stack slug"), + tx: z.string().optional().describe("Tx hash"), + address: z.string().optional().describe("Address"), + block: z.string().optional().describe("Block number"), + }, + async ({ slug, tx, address, block }) => { + const rpc = await stacks.rpcFor(slug); + const links = explorerLinks(cfg, rpc); + const url = tx + ? links.tx(tx) + : address + ? links.address(address) + : block + ? links.block(block) + : links.root; + return text({ url }); + }, +); + const transport = new StdioServerTransport(); await server.connect(transport); From 77d0cb557798136fd8e4e27bcc97a3c55fb81db2 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Fri, 24 Jul 2026 20:00:49 +0100 Subject: [PATCH 09/13] fix(mcp): resolve keyed rpc url via api-key endpoint, wait for anvil The create/show urls omit the per-stack key until provisioning catches up (load-dependent, seconds to over a minute), and show returns no urls at all right after create. Build the keyed url from slug + baseHost + the api-key token (populated immediately), then poll eth_chainId until anvil answers. Verified end-to-end against hosted Stacks: create -> get_block -> execute (anvil account 0) -> get_transaction -> delete. Co-Authored-By: Claude Opus 4.8 --- mcp/src/stacks.ts | 58 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/mcp/src/stacks.ts b/mcp/src/stacks.ts index 9f0065c..1192ef9 100644 --- a/mcp/src/stacks.ts +++ b/mcp/src/stacks.ts @@ -55,7 +55,12 @@ export class StacksClient { method: "POST", body: JSON.stringify(body), }); - return data; + // The create/show `urls` omit the per-stack key until provisioning catches + // up; resolve it from the api-key endpoint, which is populated immediately, + // then wait until anvil actually answers. + const stack = await this.resolveStack(data.slug); + await this.waitForAnvil(stack.urls.http_rpc); + return stack; } async listStacks(): Promise { @@ -68,10 +73,55 @@ export class StacksClient { return data; } + async apiKeyToken(slug: string): Promise { + const { data } = await this.req<{ data: { token: string } }>( + `/stacks/${slug}/api-keys`, + ); + return data.token; + } + + // Stack services live at {scheme}//{slug}.{baseHost}; baseHost is the api + // host without its "api." prefix (api.stacks.ethui.dev -> stacks.ethui.dev). + private origins(slug: string) { + const api = new URL(this.cfg.stacksApi); + const baseHost = api.host.replace(/^api\./, ""); + const ws = api.protocol === "https:" ? "wss:" : "ws:"; + return { + http: `${api.protocol}//${slug}.${baseHost}`, + ws: `${ws}//${slug}.${baseHost}`, + }; + } + + // The show/list `urls` lag behind provisioning, but the api-key exists at + // once — so build the keyed urls ourselves and wait for anvil to answer. + async resolveStack(slug: string): Promise { + const token = await this.apiKeyToken(slug); + const o = this.origins(slug); + const urls: StackUrls = { + http_rpc: `${o.http}/${token}`, + ws_rpc: `${o.ws}/${token}`, + explorer: `${o.http}/${token}`, + }; + return { slug, status: "running", urls }; + } + + async waitForAnvil(rpc: string, tries = 45, delayMs = 2000): Promise { + for (let i = 0; i < tries; i++) { + try { + const r = await fetch(rpc, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), + }); + if (r.ok && (await r.json())?.result) return; + } catch {} + await new Promise((res) => setTimeout(res, delayMs)); + } + throw new Error(`anvil for ${rpc} did not respond in time`); + } + async rpcFor(slug: string): Promise { - const stack = await this.getStack(slug); - if (!stack.urls?.http_rpc) throw new Error(`Stack ${slug} has no http_rpc url`); - return stack.urls.http_rpc; + return (await this.resolveStack(slug)).urls.http_rpc; } async deleteStack(slug: string): Promise { From 73eb930a6aab4069b0c3f9c38b34812687776ed6 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Sat, 25 Jul 2026 10:07:42 +0100 Subject: [PATCH 10/13] fix(mcp): normalize flat url fields in list_stacks list/show return http_rpc/ws_rpc/explorer at top level while create nests them under `urls`. Normalize both so list_stacks shows rpc + explorer links. Co-Authored-By: Claude Opus 4.8 --- mcp/src/stacks.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/mcp/src/stacks.ts b/mcp/src/stacks.ts index 1192ef9..8b6e3f4 100644 --- a/mcp/src/stacks.ts +++ b/mcp/src/stacks.ts @@ -21,6 +21,23 @@ export interface CreateStackParams { fork_block_number?: number; } +// list/show return url fields flat; create nests them under `urls`. +export function normalizeStack(s: Record): Stack { + const nested = s.urls as StackUrls | undefined; + const urls: StackUrls = nested ?? { + http_rpc: s.http_rpc as string, + ws_rpc: s.ws_rpc as string, + explorer: (s.explorer as string) ?? (s.http_rpc as string), + }; + return { + slug: s.slug as string, + status: s.status as string, + urls, + chain_id: s.chain_id as string | undefined, + anvil_opts: s.anvil_opts as Record | undefined, + }; +} + export class StacksClient { constructor(private cfg: Config) {} @@ -64,8 +81,8 @@ export class StacksClient { } async listStacks(): Promise { - const { data } = await this.req<{ data: Stack[] }>("/stacks"); - return data; + const { data } = await this.req<{ data: Record[] }>("/stacks"); + return data.map((s) => normalizeStack(s)); } async getStack(slug: string): Promise { From e860dc0efab9f8bb2735fe7ec2c3b8bf2e17c6ca Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Sat, 25 Jul 2026 11:50:40 +0100 Subject: [PATCH 11/13] feat(mcp): replace typescript MCP with an in-app elixir server Serves MCP over streamable HTTP at /mcp on the api host, so tools run inside the app instead of calling its REST api from outside. Chain calls reach anvil on its process-local port, skipping the reverse proxy and the per-stack api key. Auth reuses the REST bearer JWT. Stacks owned by another user read as missing rather than forbidden, to avoid leaking slugs. ABI encoding and decoding stay out of scope: calldata goes in raw, so the caller keeps using cast, viem, or whatever it already has. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 35 ++ mcp/.gitignore | 3 - mcp/README.md | 86 ----- mcp/package.json | 25 -- mcp/src/abi.ts | 108 ------ mcp/src/chain.ts | 150 --------- mcp/src/config.ts | 19 -- mcp/src/demo.ts | 66 ---- mcp/src/explorer.ts | 17 - mcp/src/index.ts | 316 ------------------ mcp/src/stacks.ts | 147 -------- mcp/tsconfig.json | 15 - pnpm-workspace.yaml | 1 - server/config/config.exs | 2 + server/lib/ethui/accounts/user.ex | 2 + server/lib/ethui/application.ex | 3 +- server/lib/ethui/chain.ex | 86 +++++ server/lib/ethui/mcp/auth.ex | 58 ++++ server/lib/ethui/mcp/explorer.ex | 26 ++ server/lib/ethui/mcp/server.ex | 34 ++ server/lib/ethui/mcp/stack_info.ex | 34 ++ server/lib/ethui/mcp/tool.ex | 57 ++++ server/lib/ethui/mcp/tools/create_stack.ex | 57 ++++ server/lib/ethui/mcp/tools/delete_stack.ex | 24 ++ server/lib/ethui/mcp/tools/execute.ex | 89 +++++ server/lib/ethui/mcp/tools/get_address.ex | 40 +++ server/lib/ethui/mcp/tools/get_block.ex | 36 ++ server/lib/ethui/mcp/tools/get_logs.ex | 36 ++ server/lib/ethui/mcp/tools/get_transaction.ex | 26 ++ server/lib/ethui/mcp/tools/impersonate.ex | 29 ++ server/lib/ethui/mcp/tools/list_stacks.ex | 22 ++ server/lib/ethui/mcp/tools/mine.ex | 29 ++ server/lib/ethui/mcp/tools/revert.ex | 24 ++ server/lib/ethui/mcp/tools/set_balance.ex | 22 ++ .../ethui/mcp/tools/set_block_timestamp.ex | 31 ++ server/lib/ethui/mcp/tools/simulate_call.ex | 42 +++ server/lib/ethui/mcp/tools/snapshot.ex | 18 + server/lib/ethui/stacks/stack.ex | 2 + server/lib/ethui_web/router.ex | 6 + server/mix.exs | 1 + server/mix.lock | 10 +- server/test/ethui/chain_test.exs | 44 +++ server/test/ethui/mcp/tools_test.exs | 201 +++++++++++ server/test/ethui_web/mcp_test.exs | 126 +++++++ 44 files changed, 1247 insertions(+), 958 deletions(-) delete mode 100644 mcp/.gitignore delete mode 100644 mcp/README.md delete mode 100644 mcp/package.json delete mode 100644 mcp/src/abi.ts delete mode 100644 mcp/src/chain.ts delete mode 100644 mcp/src/config.ts delete mode 100644 mcp/src/demo.ts delete mode 100644 mcp/src/explorer.ts delete mode 100644 mcp/src/index.ts delete mode 100644 mcp/src/stacks.ts delete mode 100644 mcp/tsconfig.json create mode 100644 server/lib/ethui/chain.ex create mode 100644 server/lib/ethui/mcp/auth.ex create mode 100644 server/lib/ethui/mcp/explorer.ex create mode 100644 server/lib/ethui/mcp/server.ex create mode 100644 server/lib/ethui/mcp/stack_info.ex create mode 100644 server/lib/ethui/mcp/tool.ex create mode 100644 server/lib/ethui/mcp/tools/create_stack.ex create mode 100644 server/lib/ethui/mcp/tools/delete_stack.ex create mode 100644 server/lib/ethui/mcp/tools/execute.ex create mode 100644 server/lib/ethui/mcp/tools/get_address.ex create mode 100644 server/lib/ethui/mcp/tools/get_block.ex create mode 100644 server/lib/ethui/mcp/tools/get_logs.ex create mode 100644 server/lib/ethui/mcp/tools/get_transaction.ex create mode 100644 server/lib/ethui/mcp/tools/impersonate.ex create mode 100644 server/lib/ethui/mcp/tools/list_stacks.ex create mode 100644 server/lib/ethui/mcp/tools/mine.ex create mode 100644 server/lib/ethui/mcp/tools/revert.ex create mode 100644 server/lib/ethui/mcp/tools/set_balance.ex create mode 100644 server/lib/ethui/mcp/tools/set_block_timestamp.ex create mode 100644 server/lib/ethui/mcp/tools/simulate_call.ex create mode 100644 server/lib/ethui/mcp/tools/snapshot.ex create mode 100644 server/test/ethui/chain_test.exs create mode 100644 server/test/ethui/mcp/tools_test.exs create mode 100644 server/test/ethui_web/mcp_test.exs 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/mcp/.gitignore b/mcp/.gitignore deleted file mode 100644 index 06e6038..0000000 --- a/mcp/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -dist -*.tsbuildinfo diff --git a/mcp/README.md b/mcp/README.md deleted file mode 100644 index 14846a5..0000000 --- a/mcp/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# @ethui/stacks-mcp - -MCP server for [ethui Stacks](../). Lets an agent provision disposable forked -anvil sandboxes, inspect the chain, simulate & execute calls, and drive anvil -cheatcodes — with every result deep-linked into the ethui explorer for a human -to verify. - -## Why - -Stacks spins up forked anvil environments on demand. This MCP gives an agent the -full loop: **provision → experiment → verify → tear down**. The human watches in -the explorer; the agent does the work. - -## Config - -Runs over stdio. Point it at hosted Stacks (default) or a local instance. - -| Env | Default | Notes | -| --- | --- | --- | -| `STACKS_API` | `https://api.stacks.ethui.dev` | Stacks REST base | -| `STACKS_TOKEN` | — | JWT for `/stacks` CRUD (7-day). Omit against a no-auth local instance | -| `EXPLORER_BASE` | `https://explorer.ethui.dev` | Explorer for deep links | -| `FOUNDRY_OUT` | — | Path to a foundry `out/` dir for ABI decoding | - -### Getting a token (hosted) - -``` -curl -X POST https://api.stacks.ethui.dev/auth/send-code -d '{"email":"you@x.com"}' -curl -X POST https://api.stacks.ethui.dev/auth/verify-code -d '{"email":"you@x.com","code":"123456"}' -# -> { "token": "" } (valid 7 days) -``` - -## Claude Desktop / Claude Code - -```json -{ - "mcpServers": { - "ethui-stacks": { - "command": "node", - "args": ["/absolute/path/to/stacks/mcp/dist/index.js"], - "env": { - "STACKS_TOKEN": "", - "FOUNDRY_OUT": "/path/to/your/foundry/out" - } - } - } -} -``` - -## Build - -``` -pnpm --filter @ethui/stacks-mcp build -``` - -## End-to-end demo - -Runs the fork-mainnet whale flow against live Stacks and prints explorer links: - -``` -export STACKS_TOKEN= -export FORK_URL= -pnpm --filter @ethui/stacks-mcp demo -``` - -Optional overrides: `USDC`, `WHALE`, `RECIPIENT`. - -## Tools - -- `create_stack` / `list_stacks` / `delete_stack` — sandbox lifecycle -- `get_block` / `get_transaction` / `get_address` / `get_logs` — read + explorer links - (`get_transaction` decodes call + events when `FOUNDRY_OUT` is set) -- `deploy_contract` — deploy a compiled contract from `out/` to a stack -- `simulate_call` / `execute` — run the experiment (`execute` takes `from` to - impersonate any sender with no key) -- cheatcodes: `impersonate` / `set_balance` / `mine` / `set_block_timestamp` / - `snapshot` / `revert` - -## Demo paths - -**Deploy your own:** `create_stack` → `deploy_contract Counter` → `execute` / -`simulate_call` → `get_transaction` (decoded) → open explorer link → `delete_stack`. - -**Fork mainnet:** `create_stack {fork_url, fork_block_number}` → `impersonate` -a whale (or `execute` with `from`) → move funds → inspect in explorer → -`delete_stack`. diff --git a/mcp/package.json b/mcp/package.json deleted file mode 100644 index 499f75f..0000000 --- a/mcp/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@ethui/stacks-mcp", - "version": "0.0.0", - "description": "MCP server for ethui Stacks — provision forked anvil sandboxes, inspect chain, simulate & execute, drive anvil cheatcodes", - "type": "module", - "private": true, - "bin": { - "ethui-stacks-mcp": "./dist/index.js" - }, - "scripts": { - "build": "tsc", - "dev": "tsc --watch", - "start": "node dist/index.js", - "demo": "node dist/demo.js" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "viem": "^2.21.0", - "zod": "^3.23.0" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} diff --git a/mcp/src/abi.ts b/mcp/src/abi.ts deleted file mode 100644 index c7635aa..0000000 --- a/mcp/src/abi.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { - decodeFunctionData, - parseAbiItem, - parseEventLogs, - slice, - type Abi, - type Hex, - type Log, -} from "viem"; - -export interface Artifact { - name: string; - abi: Abi; - bytecode: Hex; -} - -interface Loaded { - out: string; - byName: Map; - merged: Abi; -} - -let cache: Loaded | null = null; - -function walkJson(dir: string): string[] { - const found: string[] = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) found.push(...walkJson(full)); - else if (entry.endsWith(".json")) found.push(full); - } - return found; -} - -function load(out: string): Loaded { - if (cache && cache.out === out) return cache; - - const byName = new Map(); - const merged: Abi[number][] = []; - - for (const file of walkJson(out)) { - let json: { abi?: Abi; bytecode?: { object?: string } }; - try { - json = JSON.parse(readFileSync(file, "utf8")); - } catch { - continue; - } - if (!json.abi) continue; - - const name = file.split("/").pop()!.replace(/\.json$/, ""); - const bytecode = (json.bytecode?.object ?? "0x") as Hex; - byName.set(name, { name, abi: json.abi, bytecode }); - merged.push(...json.abi); - } - - cache = { out, byName, merged }; - return cache; -} - -export function getArtifact(out: string, name: string): Artifact { - const artifact = load(out).byName.get(name); - if (!artifact) throw new Error(`Contract "${name}" not found in ${out}`); - return artifact; -} - -export function decodeCalldata(out: string, data: Hex) { - if (!data || data === "0x") return null; - try { - return decodeFunctionData({ abi: load(out).merged, data }); - } catch { - return null; - } -} - -export function decodeLogs(out: string, logs: Log[]) { - try { - return parseEventLogs({ abi: load(out).merged, logs }); - } catch { - return []; - } -} - -// Fallback when the selector isn't in out/: resolve the signature via the -// openchain/4byte database, then decode args from the recovered signature. -export async function decodeVia4byte(data: Hex) { - if (!data || data.length < 10) return null; - const selector = slice(data, 0, 4); - try { - const res = await fetch( - `https://api.openchain.xyz/signature-database/v1/lookup?function=${selector}&filter=true`, - ); - if (!res.ok) return null; - const json = (await res.json()) as { - result?: { function?: Record }; - }; - const candidates = json.result?.function?.[selector] ?? []; - for (const { name } of candidates) { - try { - const item = parseAbiItem(`function ${name}`); - const decoded = decodeFunctionData({ abi: [item], data }); - return { signature: name, ...decoded, source: "4byte" as const }; - } catch {} - } - } catch {} - return null; -} diff --git a/mcp/src/chain.ts b/mcp/src/chain.ts deleted file mode 100644 index 60a3ecc..0000000 --- a/mcp/src/chain.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - createPublicClient, - createWalletClient, - http, - type Address, - type Hash, - type Hex, -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; - -// anvil's default funded account 0 — public, deterministic dev key. -export const ANVIL_ACCOUNT_0 = - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; - -export function clientFor(rpc: string) { - return createPublicClient({ transport: http(rpc) }); -} - -export function walletFor(rpc: string, privateKey?: Hex) { - const account = privateKeyToAccount(privateKey ?? ANVIL_ACCOUNT_0); - return createWalletClient({ account, transport: http(rpc) }); -} - -// viem returns bigints; JSON.stringify can't serialize them. Bigints become strings. -export function jsonSafe(value: T): T { - return JSON.parse( - JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)), - ); -} - -export async function getBlock(rpc: string, block: bigint | "latest") { - const client = clientFor(rpc); - return block === "latest" - ? client.getBlock() - : client.getBlock({ blockNumber: block }); -} - -export async function getTransaction(rpc: string, hash: Hash) { - const client = clientFor(rpc); - const [tx, receipt] = await Promise.all([ - client.getTransaction({ hash }), - client.getTransactionReceipt({ hash }).catch(() => null), - ]); - return { tx, receipt }; -} - -export async function getAddress(rpc: string, address: Address) { - const client = clientFor(rpc); - const [balance, nonce, code] = await Promise.all([ - client.getBalance({ address }), - client.getTransactionCount({ address }), - client.getCode({ address }), - ]); - return { address, balance, nonce, isContract: !!code && code !== "0x", code }; -} - -export async function getLogs( - rpc: string, - params: { address?: Address; fromBlock?: bigint; toBlock?: bigint }, -) { - const client = clientFor(rpc); - return client.getLogs({ - address: params.address, - fromBlock: params.fromBlock, - toBlock: params.toBlock, - }); -} - -export interface CallParams { - to: Address; - data?: Hex; - value?: bigint; - from?: Address; -} - -export async function simulateCall(rpc: string, params: CallParams) { - const client = clientFor(rpc); - const result = await client.call({ - to: params.to, - data: params.data, - value: params.value, - account: params.from, - }); - return { data: result.data ?? "0x" }; -} - -export async function execute( - rpc: string, - params: CallParams, - privateKey?: Hex, -) { - const wallet = walletFor(rpc, privateKey); - const public_ = clientFor(rpc); - const hash = await wallet.sendTransaction({ - chain: null, - to: params.to, - data: params.data, - value: params.value, - }); - const receipt = await public_.waitForTransactionReceipt({ hash }); - return { hash, receipt }; -} - -// anvil signs on behalf of an impersonated address — no private key needed. -export async function executeAs(rpc: string, from: Address, params: CallParams) { - const client = clientFor(rpc); - await anvil(rpc, "anvil_impersonateAccount", [from]); - try { - const hash = (await client.request({ - method: "eth_sendTransaction", - params: [ - { - from, - to: params.to, - data: params.data, - value: params.value != null ? `0x${params.value.toString(16)}` : undefined, - }, - ], - } as never)) as Hash; - const receipt = await client.waitForTransactionReceipt({ hash }); - return { hash, receipt }; - } finally { - await anvil(rpc, "anvil_stopImpersonatingAccount", [from]); - } -} - -export async function deployContract( - rpc: string, - abi: readonly unknown[], - bytecode: Hex, - args: unknown[], - privateKey?: Hex, -) { - const wallet = walletFor(rpc, privateKey); - const public_ = clientFor(rpc); - const hash = await wallet.deployContract({ - chain: null, - abi: abi as never, - bytecode, - args, - }); - const receipt = await public_.waitForTransactionReceipt({ hash }); - return { hash, address: receipt.contractAddress, receipt }; -} - -// Raw anvil_/evm_ cheatcode passthrough. -export async function anvil(rpc: string, method: string, params: unknown[]) { - const client = clientFor(rpc); - return client.request({ method, params } as never); -} diff --git a/mcp/src/config.ts b/mcp/src/config.ts deleted file mode 100644 index a87e9fe..0000000 --- a/mcp/src/config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Hex } from "viem"; - -export interface Config { - stacksApi: string; - stacksToken: string | undefined; - explorerBase: string; - foundryOut: string | undefined; - privateKey: Hex | undefined; -} - -export function loadConfig(): Config { - return { - stacksApi: process.env.STACKS_API ?? "https://api.stacks.ethui.dev", - stacksToken: process.env.STACKS_TOKEN, - explorerBase: process.env.EXPLORER_BASE ?? "https://explorer.ethui.dev", - foundryOut: process.env.FOUNDRY_OUT, - privateKey: process.env.PRIVATE_KEY as Hex | undefined, - }; -} diff --git a/mcp/src/demo.ts b/mcp/src/demo.ts deleted file mode 100644 index c91a1f2..0000000 --- a/mcp/src/demo.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { encodeFunctionData, formatUnits, parseAbiItem, type Address, type Hex } from "viem"; -import { loadConfig } from "./config.js"; -import { StacksClient } from "./stacks.js"; -import { explorerLinks } from "./explorer.js"; -import { clientFor, executeAs } from "./chain.js"; -import { decodeVia4byte } from "./abi.js"; - -// Fork-mainnet whale demo: fork -> impersonate a USDC whale -> transfer to -// anvil account 0 -> decode the tx -> print explorer links -> tear down. -const USDC = (process.env.USDC ?? - "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") as Address; -const WHALE = (process.env.WHALE ?? - "0x28C6c06298d514Db089934071355E5743bf21d60") as Address; -const RECIPIENT = (process.env.RECIPIENT ?? - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") as Address; - -const balanceOf = parseAbiItem("function balanceOf(address) view returns (uint256)"); -const transfer = parseAbiItem("function transfer(address,uint256) returns (bool)"); - -async function main() { - const cfg = loadConfig(); - const forkUrl = process.env.FORK_URL; - if (!cfg.stacksToken) throw new Error("Set STACKS_TOKEN"); - if (!forkUrl) throw new Error("Set FORK_URL (a mainnet rpc to fork)"); - - const stacks = new StacksClient(cfg); - - console.log("→ creating forked stack…"); - const stack = await stacks.createStack({ fork_url: forkUrl }); - const rpc = stack.urls.http_rpc; - const links = explorerLinks(cfg, rpc); - console.log(` stack ${stack.slug} @ ${rpc}`); - - try { - const client = clientFor(rpc); - const read = (owner: Address) => - client.readContract({ address: USDC, abi: [balanceOf], functionName: "balanceOf", args: [owner] }) as Promise; - - const whaleBal = await read(WHALE); - console.log(` whale USDC: ${formatUnits(whaleBal, 6)}`); - const amount = whaleBal / 2n; - - const data = encodeFunctionData({ abi: [transfer], functionName: "transfer", args: [RECIPIENT, amount] }) as Hex; - - console.log("→ impersonating whale, transferring half…"); - const { hash } = await executeAs(rpc, WHALE, { to: USDC, data }); - console.log(` tx ${hash}`); - console.log(` decoded: ${JSON.stringify(await decodeVia4byte(data))}`); - - const recipientBal = await read(RECIPIENT); - console.log(` recipient USDC: ${formatUnits(recipientBal, 6)}`); - - console.log("\nExplorer:"); - console.log(` tx: ${links.tx(hash)}`); - console.log(` recipient: ${links.address(RECIPIENT)}`); - } finally { - console.log("\n→ tearing down…"); - await stacks.deleteStack(stack.slug); - console.log(" done"); - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/mcp/src/explorer.ts b/mcp/src/explorer.ts deleted file mode 100644 index 376e818..0000000 --- a/mcp/src/explorer.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Config } from "./config.js"; - -// Explorer encodes the RPC url as base64 in the path: atob(params.rpc). -// The stack's api key is already inside http_rpc, so the browser self-auths. -function encodeRpc(rpc: string): string { - return Buffer.from(rpc, "utf8").toString("base64"); -} - -export function explorerLinks(cfg: Config, rpc: string) { - const base = `${cfg.explorerBase}/rpc/${encodeRpc(rpc)}`; - return { - root: base, - tx: (hash: string) => `${base}/tx/${hash}`, - address: (addr: string) => `${base}/address/${addr}`, - block: (n: number | string) => `${base}/block/${n}`, - }; -} diff --git a/mcp/src/index.ts b/mcp/src/index.ts deleted file mode 100644 index 4bcc754..0000000 --- a/mcp/src/index.ts +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env node -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -import type { Address, Hash, Hex } from "viem"; -import { loadConfig } from "./config.js"; -import { StacksClient } from "./stacks.js"; -import { explorerLinks } from "./explorer.js"; -import { - anvil, - deployContract, - execute, - executeAs, - getAddress, - getBlock, - getLogs, - getTransaction, - jsonSafe, - simulateCall, -} from "./chain.js"; -import { decodeCalldata, decodeLogs, decodeVia4byte, getArtifact } from "./abi.js"; - -function toWeiHex(decimal: string): Hex { - return `0x${BigInt(decimal).toString(16)}`; -} - -function text(value: unknown) { - return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; -} - -const cfg = loadConfig(); -const stacks = new StacksClient(cfg); - -const server = new McpServer({ name: "ethui-stacks-mcp", version: "0.0.0" }); - -server.tool( - "create_stack", - "Fork any chain into a fresh disposable anvil sandbox. Returns rpc url + explorer link. Use fork_url + fork_block_number to fork mainnet at a block.", - { - slug: z.string().optional().describe("Optional stack name; auto-generated if omitted"), - fork_url: z.string().optional().describe("RPC url to fork from (e.g. mainnet)"), - fork_block_number: z.number().int().optional().describe("Block to fork at"), - }, - async (args) => { - const stack = await stacks.createStack(args); - const links = explorerLinks(cfg, stack.urls.http_rpc); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - slug: stack.slug, - status: stack.status, - rpc: stack.urls.http_rpc, - ws: stack.urls.ws_rpc, - explorer: links.root, - }, - null, - 2, - ), - }, - ], - }; - }, -); - -server.tool("list_stacks", "List running stacks with their rpc + explorer urls.", {}, async () => { - const list = await stacks.listStacks(); - const rows = list.map((s) => ({ - slug: s.slug, - status: s.status, - rpc: s.urls?.http_rpc, - explorer: s.urls?.http_rpc ? explorerLinks(cfg, s.urls.http_rpc).root : undefined, - })); - return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] }; -}); - -server.tool( - "delete_stack", - "Tear down a stack by slug.", - { slug: z.string().describe("Stack slug to destroy") }, - async ({ slug }) => { - await stacks.deleteStack(slug); - return { content: [{ type: "text", text: `Deleted stack ${slug}` }] }; - }, -); - -server.tool( - "get_block", - "Get a block on a stack. Returns block data + explorer link.", - { - slug: z.string().describe("Stack slug"), - block: z - .union([z.number().int(), z.literal("latest")]) - .default("latest") - .describe("Block number, or 'latest'"), - }, - async ({ slug, block }) => { - const rpc = await stacks.rpcFor(slug); - const b = await getBlock(rpc, block === "latest" ? "latest" : BigInt(block)); - const links = explorerLinks(cfg, rpc); - return text({ block: jsonSafe(b), explorer: links.block(b.number?.toString() ?? "latest") }); - }, -); - -server.tool( - "get_transaction", - "Get a transaction and its receipt on a stack. Returns data + explorer link.", - { slug: z.string().describe("Stack slug"), hash: z.string().describe("Tx hash") }, - async ({ slug, hash }) => { - const rpc = await stacks.rpcFor(slug); - const result = await getTransaction(rpc, hash as Hash); - const links = explorerLinks(cfg, rpc); - let call = cfg.foundryOut ? decodeCalldata(cfg.foundryOut, result.tx.input) : null; - if (!call) call = await decodeVia4byte(result.tx.input); - const events = - cfg.foundryOut && result.receipt ? decodeLogs(cfg.foundryOut, result.receipt.logs) : []; - return text({ - ...jsonSafe(result), - decoded: jsonSafe({ call, events }), - explorer: links.tx(hash), - }); - }, -); - -server.tool( - "get_address", - "Get balance, nonce, and code for an address on a stack. Returns data + explorer link.", - { slug: z.string().describe("Stack slug"), address: z.string().describe("Address") }, - async ({ slug, address }) => { - const rpc = await stacks.rpcFor(slug); - const info = await getAddress(rpc, address as Address); - const links = explorerLinks(cfg, rpc); - return text({ ...jsonSafe(info), explorer: links.address(address) }); - }, -); - -server.tool( - "get_logs", - "Fetch event logs on a stack, optionally filtered by address and block range.", - { - slug: z.string().describe("Stack slug"), - address: z.string().optional().describe("Filter by contract address"), - fromBlock: z.number().int().optional().describe("Start block"), - toBlock: z.number().int().optional().describe("End block"), - }, - async ({ slug, address, fromBlock, toBlock }) => { - const rpc = await stacks.rpcFor(slug); - const logs = await getLogs(rpc, { - address: address as Address | undefined, - fromBlock: fromBlock != null ? BigInt(fromBlock) : undefined, - toBlock: toBlock != null ? BigInt(toBlock) : undefined, - }); - return text({ count: logs.length, logs: jsonSafe(logs) }); - }, -); - -server.tool( - "simulate_call", - "Dry-run a call on a stack (eth_call, no state change). Returns return data.", - { - slug: z.string().describe("Stack slug"), - to: z.string().describe("Target contract address"), - data: z.string().optional().describe("Calldata hex (0x...)"), - value: z.string().optional().describe("Wei value as decimal string"), - from: z.string().optional().describe("Caller address (defaults to node)"), - }, - async ({ slug, to, data, value, from }) => { - const rpc = await stacks.rpcFor(slug); - const result = await simulateCall(rpc, { - to: to as Address, - data: data as Hex | undefined, - value: value != null ? BigInt(value) : undefined, - from: from as Address | undefined, - }); - return text(result); - }, -); - -server.tool( - "execute", - "Send a transaction on a stack and wait for the receipt. Signs with PRIVATE_KEY or anvil account 0 by default. Pass 'from' to send as any address via impersonation (no key needed). Returns hash, receipt + explorer link.", - { - slug: z.string().describe("Stack slug"), - to: z.string().describe("Target address"), - data: z.string().optional().describe("Calldata hex (0x...)"), - value: z.string().optional().describe("Wei value as decimal string"), - from: z.string().optional().describe("Impersonate this sender (no key needed)"), - }, - async ({ slug, to, data, value, from }) => { - const rpc = await stacks.rpcFor(slug); - const params = { - to: to as Address, - data: data as Hex | undefined, - value: value != null ? BigInt(value) : undefined, - }; - const result = from - ? await executeAs(rpc, from as Address, params) - : await execute(rpc, params, cfg.privateKey); - const links = explorerLinks(cfg, rpc); - return text({ ...jsonSafe(result), explorer: links.tx(result.hash) }); - }, -); - -server.tool( - "deploy_contract", - "Deploy a compiled contract from the foundry out/ dir to a stack. Returns address + explorer link.", - { - slug: z.string().describe("Stack slug"), - contract: z.string().describe("Contract name, e.g. 'Counter'"), - args: z.array(z.any()).optional().describe("Constructor args"), - }, - async ({ slug, contract, args }) => { - if (!cfg.foundryOut) throw new Error("FOUNDRY_OUT not set"); - const artifact = getArtifact(cfg.foundryOut, contract); - if (!artifact.bytecode || artifact.bytecode === "0x") - throw new Error(`${contract} has no bytecode (interface or abstract?)`); - const rpc = await stacks.rpcFor(slug); - const result = await deployContract( - rpc, - artifact.abi, - artifact.bytecode, - args ?? [], - cfg.privateKey, - ); - const links = explorerLinks(cfg, rpc); - return text({ - contract, - address: result.address, - txHash: result.hash, - explorer: result.address ? links.address(result.address) : links.tx(result.hash), - }); - }, -); - -const cheat = ( - name: string, - description: string, - shape: z.ZodRawShape, - build: (a: Record) => { method: string; params: unknown[] }, -) => - server.tool(name, description, { slug: z.string().describe("Stack slug"), ...shape }, async (a) => { - const rpc = await stacks.rpcFor(a.slug as string); - const { method, params } = build(a as Record); - const result = await anvil(rpc, method, params); - return text({ method, result: jsonSafe(result) ?? "ok" }); - }); - -cheat( - "impersonate", - "Impersonate an address so subsequent txs can be sent as it (whale testing).", - { address: z.string().describe("Address to impersonate") }, - (a) => ({ method: "anvil_impersonateAccount", params: [a.address] }), -); - -cheat( - "set_balance", - "Set an address's native balance.", - { address: z.string().describe("Address"), wei: z.string().describe("Balance in wei (decimal)") }, - (a) => ({ method: "anvil_setBalance", params: [a.address, toWeiHex(a.wei!)] }), -); - -cheat( - "mine", - "Mine blocks immediately.", - { blocks: z.string().optional().describe("How many (default 1)") }, - (a) => ({ method: "anvil_mine", params: [`0x${BigInt(a.blocks ?? "1").toString(16)}`] }), -); - -cheat( - "set_block_timestamp", - "Set the timestamp of the next block (unix seconds).", - { timestamp: z.string().describe("Unix seconds") }, - (a) => ({ method: "evm_setNextBlockTimestamp", params: [Number(a.timestamp)] }), -); - -cheat( - "snapshot", - "Snapshot current chain state; returns an id to revert to.", - {}, - () => ({ method: "evm_snapshot", params: [] }), -); - -cheat( - "revert", - "Revert chain state to a snapshot id.", - { id: z.string().describe("Snapshot id from snapshot") }, - (a) => ({ method: "evm_revert", params: [a.id] }), -); - -server.tool( - "explorer_link", - "Build an ethui explorer deep link for a stack — to a tx, address, block, or the stack root. Hand this to a human to inspect.", - { - slug: z.string().describe("Stack slug"), - tx: z.string().optional().describe("Tx hash"), - address: z.string().optional().describe("Address"), - block: z.string().optional().describe("Block number"), - }, - async ({ slug, tx, address, block }) => { - const rpc = await stacks.rpcFor(slug); - const links = explorerLinks(cfg, rpc); - const url = tx - ? links.tx(tx) - : address - ? links.address(address) - : block - ? links.block(block) - : links.root; - return text({ url }); - }, -); - -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/mcp/src/stacks.ts b/mcp/src/stacks.ts deleted file mode 100644 index 8b6e3f4..0000000 --- a/mcp/src/stacks.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { Config } from "./config.js"; - -export interface StackUrls { - http_rpc: string; - ws_rpc: string; - explorer: string; - [key: string]: string; -} - -export interface Stack { - slug: string; - status: string; - urls: StackUrls; - chain_id?: string; - anvil_opts?: Record; -} - -export interface CreateStackParams { - slug?: string; - fork_url?: string; - fork_block_number?: number; -} - -// list/show return url fields flat; create nests them under `urls`. -export function normalizeStack(s: Record): Stack { - const nested = s.urls as StackUrls | undefined; - const urls: StackUrls = nested ?? { - http_rpc: s.http_rpc as string, - ws_rpc: s.ws_rpc as string, - explorer: (s.explorer as string) ?? (s.http_rpc as string), - }; - return { - slug: s.slug as string, - status: s.status as string, - urls, - chain_id: s.chain_id as string | undefined, - anvil_opts: s.anvil_opts as Record | undefined, - }; -} - -export class StacksClient { - constructor(private cfg: Config) {} - - private async req(path: string, init?: RequestInit): Promise { - const headers: Record = { - "content-type": "application/json", - ...(init?.headers as Record), - }; - if (this.cfg.stacksToken) { - headers.authorization = `Bearer ${this.cfg.stacksToken}`; - } - - const res = await fetch(`${this.cfg.stacksApi}${path}`, { ...init, headers }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`Stacks ${init?.method ?? "GET"} ${path} -> ${res.status}: ${body}`); - } - if (res.status === 204) return undefined as T; - return (await res.json()) as T; - } - - async createStack(params: CreateStackParams): Promise { - const anvil_opts: Record = {}; - if (params.fork_url) anvil_opts.fork_url = params.fork_url; - if (params.fork_block_number != null) - anvil_opts.fork_block_number = params.fork_block_number; - - const body: Record = { anvil_opts }; - if (params.slug) body.slug = params.slug; - - const { data } = await this.req<{ data: Stack }>("/stacks", { - method: "POST", - body: JSON.stringify(body), - }); - // The create/show `urls` omit the per-stack key until provisioning catches - // up; resolve it from the api-key endpoint, which is populated immediately, - // then wait until anvil actually answers. - const stack = await this.resolveStack(data.slug); - await this.waitForAnvil(stack.urls.http_rpc); - return stack; - } - - async listStacks(): Promise { - const { data } = await this.req<{ data: Record[] }>("/stacks"); - return data.map((s) => normalizeStack(s)); - } - - async getStack(slug: string): Promise { - const { data } = await this.req<{ data: Stack }>(`/stacks/${slug}`); - return data; - } - - async apiKeyToken(slug: string): Promise { - const { data } = await this.req<{ data: { token: string } }>( - `/stacks/${slug}/api-keys`, - ); - return data.token; - } - - // Stack services live at {scheme}//{slug}.{baseHost}; baseHost is the api - // host without its "api." prefix (api.stacks.ethui.dev -> stacks.ethui.dev). - private origins(slug: string) { - const api = new URL(this.cfg.stacksApi); - const baseHost = api.host.replace(/^api\./, ""); - const ws = api.protocol === "https:" ? "wss:" : "ws:"; - return { - http: `${api.protocol}//${slug}.${baseHost}`, - ws: `${ws}//${slug}.${baseHost}`, - }; - } - - // The show/list `urls` lag behind provisioning, but the api-key exists at - // once — so build the keyed urls ourselves and wait for anvil to answer. - async resolveStack(slug: string): Promise { - const token = await this.apiKeyToken(slug); - const o = this.origins(slug); - const urls: StackUrls = { - http_rpc: `${o.http}/${token}`, - ws_rpc: `${o.ws}/${token}`, - explorer: `${o.http}/${token}`, - }; - return { slug, status: "running", urls }; - } - - async waitForAnvil(rpc: string, tries = 45, delayMs = 2000): Promise { - for (let i = 0; i < tries; i++) { - try { - const r = await fetch(rpc, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), - }); - if (r.ok && (await r.json())?.result) return; - } catch {} - await new Promise((res) => setTimeout(res, delayMs)); - } - throw new Error(`anvil for ${rpc} did not respond in time`); - } - - async rpcFor(slug: string): Promise { - return (await this.resolveStack(slug)).urls.http_rpc; - } - - async deleteStack(slug: string): Promise { - await this.req(`/stacks/${slug}`, { method: "DELETE" }); - } -} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json deleted file mode 100644 index 172662e..0000000 --- a/mcp/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": false, - "sourceMap": true - }, - "include": ["src/**/*"] -} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c0654cf..315de4c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,2 @@ packages: - 'frontend' - - 'mcp' diff --git a/server/config/config.exs b/server/config/config.exs index 8e09098..bb36d9d 100644 --- a/server/config/config.exs +++ b/server/config/config.exs @@ -90,6 +90,8 @@ config :ethui, Ethui.Stacks, docker_host: System.get_env("DOCKER_HOST", "172.17.0.1"), chain_id_prefix: 0x00EE +config :ethui, :explorer_base, System.get_env("EXPLORER_BASE", "https://explorer.ethui.dev") + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" 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..2413ef4 --- /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, _} -> {:error, "stack #{slug} is not running"} + 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..9c9dc0f --- /dev/null +++ b/server/lib/ethui/mcp/explorer.ex @@ -0,0 +1,26 @@ +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}" + + @spec block(Stack.t(), String.t() | integer) :: String.t() + 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..0bbfafa --- /dev/null +++ b/server/lib/ethui/mcp/tools/create_stack.ex @@ -0,0 +1,57 @@ +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} <- Server.create(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 + + 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..abb4d27 --- /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 30 + @receipt_interval 100 + + 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..ebd2e64 --- /dev/null +++ b/server/lib/ethui/mcp/tools/get_logs.ex @@ -0,0 +1,36 @@ +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 + + schema do + field(:slug, :string, required: true) + field(:address, :string, description: "Contract address to filter by") + field(:from_block, :string, default: "earliest", description: "Defaults to earliest") + 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)]) do + {:ok, %{count: length(logs), logs: logs, explorer: Explorer.root(stack)}} + end + end) + end + + defp filter(params) do + %{ + "fromBlock" => Chain.block_param(params.from_block), + "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 +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/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..5d084e6 100644 --- a/server/lib/ethui_web/router.ex +++ b/server/lib/ethui_web/router.ex @@ -47,6 +47,12 @@ defmodule EthuiWeb.Router do get "/healthz", Api.HealthzController, :index end + scope "/", host: "api." do + pipe_through [:base] + + 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/tools_test.exs b/server/test/ethui/mcp/tools_test.exs new file mode 100644 index 0000000..d60a97b --- /dev/null +++ b/server/test/ethui/mcp/tools_test.exs @@ -0,0 +1,201 @@ +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 "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 block.explorer =~ "/block/0x0" + + 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", %{frame: frame, slug: slug} do + assert {:ok, %{count: 0}} = + call( + Tools.GetLogs, + %{slug: slug, from_block: "earliest", 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..e34ceaf --- /dev/null +++ b/server/test/ethui_web/mcp_test.exs @@ -0,0 +1,126 @@ +defmodule EthuiWeb.MCPTest do + use Ethui.DataCase, async: false + + import Plug.Conn + import Plug.Test + + alias Anubis.Server.Transport.StreamableHTTP + 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, session: initialize()} + end + + test "lists every tool", %{session: session} do + assert %{"result" => %{"tools" => tools}} = request(session, "tools/list", %{}) + + 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 "rejects a tool call without a token", %{session: session} do + assert %{"result" => result} = + request(session, "tools/call", %{"name" => "list_stacks", "arguments" => %{}}) + + assert result["isError"] + assert [%{"text" => text}] = result["content"] + assert text =~ "Authorization" + end + + test "runs a tool for an authenticated caller", %{session: session} do + {:ok, user} = Accounts.send_verification_code("mcp-http@example.com") + {:ok, token} = Accounts.generate_token(user) + + assert %{"result" => result} = + request(session, "tools/call", %{"name" => "list_stacks", "arguments" => %{}}, + authorization: "Bearer #{token}" + ) + + refute result["isError"] + assert [%{"text" => "[]"}] = result["content"] + end + + test "reports unknown tools as protocol errors", %{session: session} do + assert %{"error" => error} = + request(session, "tools/call", %{"name" => "nope", "arguments" => %{}}) + + assert error["message"] =~ "not found" or error["code"] + end + + defp initialize do + conn = + post_mcp(%{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "initialize", + "params" => %{ + "protocolVersion" => @protocol_version, + "clientInfo" => %{"name" => "test", "version" => "1.0.0"}, + "capabilities" => %{} + } + }) + + assert conn.status == 200 + [session_id] = get_resp_header(conn, "mcp-session-id") + + post_mcp(%{"jsonrpc" => "2.0", "method" => "notifications/initialized"}, + "mcp-session-id": session_id + ) + + session_id + 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 + |> conn("/mcp", Jason.encode!(body)) + |> put_req_header("content-type", "application/json") + |> put_req_header("accept", "application/json, text/event-stream") + |> then(fn conn -> + Enum.reduce(headers, conn, fn {key, value}, acc -> + put_req_header(acc, to_string(key), value) + end) + end) + |> StreamableHTTP.Plug.call(StreamableHTTP.Plug.init(server: Ethui.MCP.Server)) + end +end From 6735fb35fad0cec8701b928d3e57e9b0c67cc1ec Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Sat, 25 Jul 2026 12:15:58 +0100 Subject: [PATCH 12/13] fix(mcp): address review findings on the elixir MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booting a forked anvil outlasts a default GenServer call timeout, so `ensure_running` takes an explicit one and `anvil_url` catches the exit rather than letting callers crash. This covers the proxy path too. Explorer block links carry decimal numbers, matching how the frontend builds them; a block without a number links to the stack root instead of a dangling path. `EXPLORER_BASE` moves to runtime config, where the rest of the env-driven settings live — read from config.exs it was baked in at build time. Sessions now need a bearer token before one is spawned, so an anonymous caller can no longer hold session processes on the public endpoint. The MCP pipeline skips `accepts`, since clients negotiate json and text/event-stream on the same path. Also: roll back the stack row when its anvil fails to boot, default `get_logs` from the fork block instead of scanning upstream history, widen the receipt window, and preload api keys when listing without a user so rpc urls keep their token. The HTTP test now dispatches through the endpoint. Driving the plug directly skipped the router, so it could not have caught the auth gate. Co-Authored-By: Claude Opus 5 (1M context) --- server/config/config.exs | 2 - server/config/runtime.exs | 2 + server/lib/ethui/chain.ex | 2 +- server/lib/ethui/mcp/explorer.ex | 5 +- server/lib/ethui/mcp/tools/create_stack.ex | 14 +++- server/lib/ethui/mcp/tools/execute.ex | 4 +- server/lib/ethui/mcp/tools/get_logs.ex | 19 ++++- server/lib/ethui/services/anvil.ex | 7 +- server/lib/ethui/stacks.ex | 2 +- server/lib/ethui/stacks/server.ex | 2 + server/lib/ethui_web/router.ex | 7 +- server/test/ethui/mcp/explorer_test.exs | 21 +++++ server/test/ethui/mcp/tools_test.exs | 2 +- server/test/ethui_web/mcp_test.exs | 90 +++++++++++----------- 14 files changed, 116 insertions(+), 63 deletions(-) create mode 100644 server/test/ethui/mcp/explorer_test.exs diff --git a/server/config/config.exs b/server/config/config.exs index bb36d9d..8e09098 100644 --- a/server/config/config.exs +++ b/server/config/config.exs @@ -90,8 +90,6 @@ config :ethui, Ethui.Stacks, docker_host: System.get_env("DOCKER_HOST", "172.17.0.1"), chain_id_prefix: 0x00EE -config :ethui, :explorer_base, System.get_env("EXPLORER_BASE", "https://explorer.ethui.dev") - # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" 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/chain.ex b/server/lib/ethui/chain.ex index 2413ef4..f47605d 100644 --- a/server/lib/ethui/chain.ex +++ b/server/lib/ethui/chain.ex @@ -37,7 +37,7 @@ defmodule Ethui.Chain do defp anvil_url(slug) do case Server.anvil_url(slug) do {:ok, url} -> {:ok, url} - {:error, _} -> {:error, "stack #{slug} is not running"} + {:error, reason} -> {:error, "stack #{slug} is not reachable: #{reason}"} end end diff --git a/server/lib/ethui/mcp/explorer.ex b/server/lib/ethui/mcp/explorer.ex index 9c9dc0f..7c3f413 100644 --- a/server/lib/ethui/mcp/explorer.ex +++ b/server/lib/ethui/mcp/explorer.ex @@ -19,7 +19,10 @@ defmodule Ethui.MCP.Explorer do @spec address(Stack.t(), String.t()) :: String.t() def address(stack, address), do: "#{root(stack)}/address/#{address}" - @spec block(Stack.t(), String.t() | integer) :: String.t() + @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) diff --git a/server/lib/ethui/mcp/tools/create_stack.ex b/server/lib/ethui/mcp/tools/create_stack.ex index 0bbfafa..4cb8d97 100644 --- a/server/lib/ethui/mcp/tools/create_stack.ex +++ b/server/lib/ethui/mcp/tools/create_stack.ex @@ -24,7 +24,7 @@ defmodule Ethui.MCP.Tools.CreateStack do def execute(params, frame) do with {:ok, user} <- Auth.current_user(frame), {:ok, stack} <- Stacks.create_stack(user, attrs(params)), - {:ok, _pid} <- Server.create(stack) do + {:ok, _pid} <- start(stack) do {:ok, stack.slug |> Stacks.get_stack_by_slug() |> StackInfo.describe()} else {:error, %Ecto.Changeset{} = changeset} -> {:error, changeset_error(changeset)} @@ -36,6 +36,18 @@ defmodule Ethui.MCP.Tools.CreateStack do |> 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 diff --git a/server/lib/ethui/mcp/tools/execute.ex b/server/lib/ethui/mcp/tools/execute.ex index abb4d27..11e926a 100644 --- a/server/lib/ethui/mcp/tools/execute.ex +++ b/server/lib/ethui/mcp/tools/execute.ex @@ -7,8 +7,8 @@ defmodule Ethui.MCP.Tools.Execute do use Ethui.MCP.Tool - @receipt_attempts 30 - @receipt_interval 100 + @receipt_attempts 100 + @receipt_interval 200 schema do field(:slug, :string, required: true) diff --git a/server/lib/ethui/mcp/tools/get_logs.ex b/server/lib/ethui/mcp/tools/get_logs.ex index ebd2e64..c7609d3 100644 --- a/server/lib/ethui/mcp/tools/get_logs.ex +++ b/server/lib/ethui/mcp/tools/get_logs.ex @@ -6,10 +6,17 @@ defmodule Ethui.MCP.Tools.GetLogs do 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, default: "earliest", description: "Defaults to earliest") + + 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 @@ -17,15 +24,15 @@ defmodule Ethui.MCP.Tools.GetLogs do @impl true def execute(%{slug: slug} = params, frame) do with_stack(frame, slug, fn stack -> - with {:ok, logs} <- rpc(stack, "eth_getLogs", [filter(params)]) do + 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) do + defp filter(params, stack) do %{ - "fromBlock" => Chain.block_param(params.from_block), + "fromBlock" => Chain.block_param(params[:from_block] || from_block(stack)), "toBlock" => Chain.block_param(params.to_block), "address" => params[:address], "topics" => params[:topics] @@ -33,4 +40,8 @@ defmodule Ethui.MCP.Tools.GetLogs do |> 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/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..c160bbc 100644 --- a/server/lib/ethui/stacks/server.ex +++ b/server/lib/ethui/stacks/server.ex @@ -70,6 +70,8 @@ defmodule Ethui.Stacks.Server do _ -> {:error, "Stack not found"} end + catch + :exit, {:timeout, _} -> {:error, "Stack is taking too long to start"} end def graph_ip_from_slug(proxied_path, slug, target_port) do diff --git a/server/lib/ethui_web/router.ex b/server/lib/ethui_web/router.ex index 5d084e6..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 @@ -48,7 +53,7 @@ defmodule EthuiWeb.Router do end scope "/", host: "api." do - pipe_through [:base] + pipe_through [:base, :mcp] forward "/mcp", Anubis.Server.Transport.StreamableHTTP.Plug, server: Ethui.MCP.Server 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 index d60a97b..292b4f5 100644 --- a/server/test/ethui/mcp/tools_test.exs +++ b/server/test/ethui/mcp/tools_test.exs @@ -83,7 +83,7 @@ defmodule Ethui.MCP.ToolsTest do ) assert block.number == "0x0" - assert block.explorer =~ "/block/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) diff --git a/server/test/ethui_web/mcp_test.exs b/server/test/ethui_web/mcp_test.exs index e34ceaf..3319726 100644 --- a/server/test/ethui_web/mcp_test.exs +++ b/server/test/ethui_web/mcp_test.exs @@ -1,10 +1,6 @@ defmodule EthuiWeb.MCPTest do - use Ethui.DataCase, async: false + use EthuiWeb.ConnCase, async: false - import Plug.Conn - import Plug.Test - - alias Anubis.Server.Transport.StreamableHTTP alias Ethui.Accounts @protocol_version "2025-06-18" @@ -17,11 +13,19 @@ defmodule EthuiWeb.MCPTest do Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, enabled: true) on_exit(fn -> Application.put_env(:ethui, EthuiWeb.Plugs.Authenticate, original) end) - {:ok, session: initialize()} + {: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} do - assert %{"result" => %{"tools" => tools}} = request(session, "tools/list", %{}) + test "lists every tool", %{session: session, auth: auth} do + assert %{"result" => %{"tools" => tools}} = request(session, "tools/list", %{}, auth) names = Enum.map(tools, & &1["name"]) @@ -31,59 +35,53 @@ defmodule EthuiWeb.MCPTest do assert length(names) == 15 end - test "rejects a tool call without a token", %{session: session} do - assert %{"result" => result} = - request(session, "tools/call", %{"name" => "list_stacks", "arguments" => %{}}) - - assert result["isError"] - assert [%{"text" => text}] = result["content"] - assert text =~ "Authorization" + 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} do - {:ok, user} = Accounts.send_verification_code("mcp-http@example.com") - {:ok, token} = Accounts.generate_token(user) - + test "runs a tool for an authenticated caller", %{session: session, auth: auth} do assert %{"result" => result} = - request(session, "tools/call", %{"name" => "list_stacks", "arguments" => %{}}, - authorization: "Bearer #{token}" - ) + 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} do + test "reports unknown tools as protocol errors", %{session: session, auth: auth} do assert %{"error" => error} = - request(session, "tools/call", %{"name" => "nope", "arguments" => %{}}) + request(session, "tools/call", %{"name" => "nope", "arguments" => %{}}, auth) assert error["message"] =~ "not found" or error["code"] end - defp initialize do - conn = - post_mcp(%{ - "jsonrpc" => "2.0", - "id" => 1, - "method" => "initialize", - "params" => %{ - "protocolVersion" => @protocol_version, - "clientInfo" => %{"name" => "test", "version" => "1.0.0"}, - "capabilities" => %{} - } - }) + defp initialize(headers) do + conn = post_mcp(initialize_body(), headers) assert conn.status == 200 - [session_id] = get_resp_header(conn, "mcp-session-id") + [session_id] = Plug.Conn.get_resp_header(conn, "mcp-session-id") - post_mcp(%{"jsonrpc" => "2.0", "method" => "notifications/initialized"}, - "mcp-session-id": session_id + post_mcp( + %{"jsonrpc" => "2.0", "method" => "notifications/initialized"}, + Keyword.put(headers, :"mcp-session-id", session_id) ) session_id end - defp request(session, method, params, headers \\ []) do + 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( %{ @@ -111,16 +109,16 @@ defmodule EthuiWeb.MCPTest do defp decode(body), do: Jason.decode!(body) - defp post_mcp(body, headers \\ []) do + defp post_mcp(body, headers) do :post - |> conn("/mcp", Jason.encode!(body)) - |> put_req_header("content-type", "application/json") - |> put_req_header("accept", "application/json, text/event-stream") + |> 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 -> - put_req_header(acc, to_string(key), value) + Plug.Conn.put_req_header(acc, to_string(key), value) end) end) - |> StreamableHTTP.Plug.call(StreamableHTTP.Plug.init(server: Ethui.MCP.Server)) + |> Phoenix.ConnTest.dispatch(@endpoint, :post, "http://api.lvh.me/mcp", Jason.encode!(body)) end end From 72e3e7f119d3edeed06ded7ff06970157d654883 Mon Sep 17 00:00:00 2001 From: joaocosta9 Date: Sat, 25 Jul 2026 13:18:02 +0100 Subject: [PATCH 13/13] fix(mcp): return a tool error when a stack's anvil cannot boot Bad fork options kill anvil outright, and the call to read its url then exits with `:normal` rather than a timeout, which the earlier catch missed. The agent saw "Tool execution crashed" instead of something it could act on. Found by running a mainnet fork locally: a fork_block_number ahead of the chain head made anvil exit 1. Co-Authored-By: Claude Opus 5 (1M context) --- server/lib/ethui/stacks/server.ex | 2 ++ server/test/ethui/mcp/tools_test.exs | 30 +++++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/server/lib/ethui/stacks/server.ex b/server/lib/ethui/stacks/server.ex index c160bbc..881cb3a 100644 --- a/server/lib/ethui/stacks/server.ex +++ b/server/lib/ethui/stacks/server.ex @@ -71,7 +71,9 @@ 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/test/ethui/mcp/tools_test.exs b/server/test/ethui/mcp/tools_test.exs index 292b4f5..742bce4 100644 --- a/server/test/ethui/mcp/tools_test.exs +++ b/server/test/ethui/mcp/tools_test.exs @@ -62,6 +62,24 @@ defmodule Ethui.MCP.ToolsTest do 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" @@ -169,13 +187,11 @@ defmodule Ethui.MCP.ToolsTest do call(Tools.Impersonate, %{slug: slug, address: @whale, stop: true}, frame) end - test "returns no logs on a fresh chain", %{frame: frame, slug: slug} do - assert {:ok, %{count: 0}} = - call( - Tools.GetLogs, - %{slug: slug, from_block: "earliest", to_block: "latest"}, - frame - ) + 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