From 7413fcbdc4ce1835f65c7552461b1d53d33ed837 Mon Sep 17 00:00:00 2001 From: Axl Date: Thu, 10 Sep 2026 23:00:06 +0700 Subject: [PATCH 1/9] Add an MCP server for AI clients, and a bytecode gadget finder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sevm mcp serves the debugger over MCP stdio as a third frontend, shaped for a consumer that cannot scroll panes or watch them change: - every windowed read takes offset/size and returns {items, total, truncated}; every navigation tool returns the same stop report (pc, opcode, gas, sp, location, stack top, stack delta since the previous stop); sevm_diff_since_last_stop reports stack pushes/pops with values, changed memory ranges and gas spent - 37 tools: session lifecycle (web3.py driver scripts and .t.sol Foundry tests, restart with new args), navigation, windowed state reads, decoded storage/locals/arguments/logs, source and disassembly windows, breakpoints and watchpoints, Solidity evaluation against the paused frame, live mutation (stack/gas/memory/storage/pc), and a raw gdb-verb passthrough (vm.* cheatcodes, Yul, jump, reseat) - failures return {"error": ...} dicts with the fix in the message Both targets and a new find HEX command share a fresh find_needle inspect op: every offset of a byte pattern in the RUNNING code, with instruction alignment and the nearest preceding JUMPDEST — the address an indirect jump can reach. bytes(CodeStream) iterates from the current pc, so the op reads _raw_code_bytes to search the whole program. Verified: full suite 802 passed; stdio JSON-RPC smoke (initialize, tools/list, call_tool); in-memory client round trip; and a TokyoPayload gadget hunt through the driver locating POP4/CALLDATALOAD/DELEGATECALL/ STOP gadgets in the optimized runtime. --- README.md | 24 + docs/commands.md | 1 + docs/mcp.md | 133 +++++ pyproject.toml | 2 + src/sevm/cli.py | 28 + src/sevm/commands/help.py | 1 + src/sevm/commands/inspecting.py | 45 ++ src/sevm/mcp_server/__init__.py | 21 + src/sevm/mcp_server/driver.py | 890 +++++++++++++++++++++++++++++++ src/sevm/mcp_server/serialize.py | 125 +++++ src/sevm/mcp_server/tools.py | 318 +++++++++++ src/sevm/session/inspect_ops.py | 58 +- tests/test_commands.py | 14 + tests/test_mcp_server.py | 264 +++++++++ uv.lock | 695 +++++++++++++++++++++++- 15 files changed, 2611 insertions(+), 8 deletions(-) create mode 100644 docs/mcp.md create mode 100644 src/sevm/mcp_server/__init__.py create mode 100644 src/sevm/mcp_server/driver.py create mode 100644 src/sevm/mcp_server/serialize.py create mode 100644 src/sevm/mcp_server/tools.py create mode 100644 tests/test_mcp_server.py diff --git a/README.md b/README.md index 9c54eb3..e175797 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ - [Use Foundry cheatcodes](#use-foundry-cheatcodes) - [Run Yul at the prompt](#run-yul-at-the-prompt) - [Work without source](#work-without-source) + - [Find a gadget](#find-a-gadget) + - [Debug from an AI client (MCP)](#debug-from-an-ai-client-mcp) - [Compile a project](#compile-a-project) - [Check the environment](#check-the-environment) - [Reference](#reference) @@ -549,6 +551,27 @@ When the dispatcher does test it, `info address` reports the wrapper it jumps to implementation's JUMPDEST behind it, which is the pc every caller converges on. See [docs/commands.md](docs/commands.md#selectors-and-the-dispatcher). +### Find a gadget + +`find HEX` reports every offset in the running code where the byte pattern occurs, with +the context a jump-oriented chain needs — instruction alignment and the nearest preceding +JUMPDEST, the address an indirect jump can actually reach: + +```bash +(sevm) find 5b50505050 +2 occurrence(s) of 5b50505050 in Counter + 0x0184 JUMPDEST (JUMPDEST, jump via 0x0184) + 0x0279 5b50505050 (inside operand, jump via 0x0210) +``` + +### Debug from an AI client (MCP) + +`sevm mcp` serves the whole debugger over the Model Context Protocol (stdio) — the third +frontend after the console and the TUI, shaped for a consumer that cannot scroll panes or +watch them change: windowed reads with explicit truncation, a uniform stop report after +every navigation, and a diff of what the last step changed. See +[docs/mcp.md](docs/mcp.md) for the tool list and client configuration. + ### Compile a project `sevm compile` runs the same build the debugger does and reports what it produced, which is @@ -617,6 +640,7 @@ an `overrides` line so a stale environment variable cannot hide. | [docs/expressions.md](docs/expressions.md) | evaluating Solidity, reading and writing local variables | | [docs/assembly.md](docs/assembly.md) | Yul builtins at the prompt, what is refused and why | | [docs/foundry.md](docs/foundry.md) | projects, library install, the build cache, cheatcodes | +| [docs/mcp.md](docs/mcp.md) | the MCP server for AI clients: tools, data shapes, configuration | ## Development diff --git a/docs/commands.md b/docs/commands.md index 90b2fdd..1758267 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -70,6 +70,7 @@ as `reverted: "..."`, `panic 0x11`, or a custom error with its arguments. | `ptype EXPR` | report the Solidity type | | `display EXPR` | re-evaluate at every stop | | `x/NFU ADDR` | examine memory, gdb syntax; a bare `x` continues past the last dump, format reused | +| `find HEX` | every offset in the running code where the byte pattern occurs, with instruction alignment and the nearest preceding JUMPDEST — gadget hunting | | `bt` / `f N` / `up` / `down` | call stack and frame selection | | `l` / `list` / `disas` | source listing / disassembly | | `info registers` | pc, gas + refund, depth, stack height, memory and calldata sizes, `msg.*`, `tx.origin`, static flag | diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..89f273e --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,133 @@ +# MCP server — sevm for AI clients + +`sevm mcp` runs the debugger as a [Model Context Protocol](https://modelcontextprotocol.io) +server over stdio. It is the third frontend (after the console and the TUI), built for a +consumer that cannot scroll a pane, watch colour, or press F5: an LLM stepping a +transaction needs different data shapes than a human at a terminal. + +[back to README](../README.md) + +## Why the MCP surface is shaped differently + +| A human... | An AI client... | So the tools... | +|---|---|---| +| scrolls the memory pane | gets one fixed response | every windowed read takes `offset`/`words` and returns `{items, total, truncated}` — narrow the window, don't ask for more | +| watches panes change live | only sees tool results | `sevm_diff_since_last_stop` reports stack pushes/pops, changed memory ranges and gas after each step | +| knows where they are | must be told, every time | every navigation tool returns the same stop report (pc, opcode, gas, sp, location, stack top, stack delta) | +| reads syntax highlighting | pays per token | output is plain JSON: full-width hex words, decimal counts, no markup | +| restarts on a typo | cannot recover silently | failures return `{"error": "..."}` with the fix in the message | + +## Running it + +```bash +sevm mcp # if installed globally (uv tool install .) +# or from a checkout: +uv --directory /path/to/debugger run sevm mcp +# longer targets need a longer first-stop budget: +sevm mcp --timeout 300 +``` + +Any MCP client that speaks stdio can connect. Generic configuration: + +```json +{ + "mcpServers": { + "sevm": { + "command": "sevm", + "args": ["mcp"] + } + } +} +``` + +or, without a global install: + +```json +{ + "mcpServers": { + "sevm": { + "command": "uv", + "args": ["--directory", "C:/path/to/debugger", "run", "sevm", "mcp"] + } + } +} +``` + +One debug session is active at a time; starting a new target replaces it. + +## Tool reference + +### Lifecycle + +| Tool | Meaning | +|---|---| +| `sevm_start_session` | compile and start a target — a web3.py driver script (extra `args` forwarded, `@path` args read from file) or a `.t.sol` Foundry test (`match`/`match_contract` select tests). Returns the first stop report | +| `sevm_stop_session` | dispose the session (chain state is discarded) | +| `sevm_restart_session` | re-run from scratch: fresh chain, breakpoints kept, optional new script args | + +### Navigation — every tool returns the uniform stop report + +| Tool | Meaning | +|---|---| +| `sevm_continue` | run to the next breakpoint/watchpoint/error, or program end | +| `sevm_step_opcodes` | `count` opcodes (≤1000) | +| `sevm_step_lines` / `sevm_next_lines` | Solidity lines, into / over internal calls | +| `sevm_finish_frame` | run to the end of the current frame | +| `sevm_run_to` | a pc, or a location like `Bank.sol:46` / `*0x108` | +| `sevm_get_status` | the stop report alone | +| `sevm_diff_since_last_stop` | what the last navigation changed | + +### Inspection (windowed, truncation-flagged) + +| Tool | Meaning | +|---|---| +| `sevm_read_memory` | 32-byte words, region-annotated; `beyond: true` marks unallocated reads-as-zero | +| `sevm_read_stack` | operand stack, index 0 = top | +| `sevm_read_storage` | decoded layout by default (names, types, packed slots), or raw slots | +| `sevm_read_calldata` | windowed; first window includes selector + signature | +| `sevm_get_backtrace` | Solidity and EVM frames interleaved | +| `sevm_get_locals` / `sevm_get_arguments` | decoded locals / ABI-decoded call arguments | +| `sevm_disassemble` | rows `{pc, text, jumpdest, line}` around a pc | +| `sevm_read_source` | numbered source window, current line flagged | +| `sevm_get_gas_profile` | spend by opcode and by source line | +| `sevm_get_logs` | events so far, names decoded | +| `sevm_list_contracts` / `sevm_list_functions` | the compiled surface | + +### Search + +| Tool | Meaning | +|---|---| +| `sevm_find_bytes` | hex pattern in `code` (gadget hunting: pc, instruction alignment, nearest preceding JUMPDEST), `memory`, or `calldata` | + +### Breakpoints + +| Tool | Meaning | +|---|---| +| `sevm_set_breakpoint` | `File.sol:LINE`, `*0xPC`, or a function name; optional Solidity `condition` | +| `sevm_set_opcode_breakpoint` | every occurrence of an opcode (`SSTORE`, `DELEGATECALL`, ...) | +| `sevm_set_watchpoint` | storage value written/read/accessed, or `*0x40` memory | +| `sevm_list_breakpoints` / `sevm_delete_breakpoint` | management | + +### Mutation and evaluation + +| Tool | Meaning | +|---|---| +| `sevm_evaluate` | real Solidity against the paused frame (state, mappings, locals, `keccak256`, `abi.encode`, units); `keep: true` keeps side effects like gdb's `call` | +| `sevm_set_gas` | overwrite the gas meter; at an out-of-gas stop, continuing retries the failed instruction | +| `sevm_set_stack_slot` | rewrite an operand before its opcode consumes it | +| `sevm_write_memory` / `sevm_write_storage` | raw writes | +| `sevm_set_pc` | move the pc (JUMPDESTs only) | +| `sevm_command` | raw gdb-verb passthrough for everything else: `vm.*` cheatcodes, Yul, `jump`, `reseat`, `x/32xb 0x40` | + +## A first session + +``` +sevm_start_session(target="exploit.py", contracts="contracts/", optimize=true) +sevm_find_bytes(pattern="5b50505050", scope="code") # gadget hunt +sevm_set_breakpoint(location="*0x1a2") # or "deposit", "SSTORE" +sevm_continue() +sevm_read_memory(offset=0x40, words=4) +sevm_evaluate(expression="balances[msg.sender]") +sevm_diff_since_last_stop() +sevm_restart_session(args=["0x"]) +``` diff --git a/pyproject.toml b/pyproject.toml index 77930b8..3d24c47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ dependencies = [ "rlp>=4.0", "packaging>=23.0", "requests>=2.31", + "mcp>=2.2.0", + "anyio>=4.15.1", ] [project.scripts] diff --git a/src/sevm/cli.py b/src/sevm/cli.py index 5c28682..4ccbd08 100644 --- a/src/sevm/cli.py +++ b/src/sevm/cli.py @@ -168,6 +168,22 @@ def build_parser() -> argparse.ArgumentParser: "doctor", help="report the compiler, runtimes and caches sevm found here" ) + mcp_cmd = sub.add_parser( + "mcp", + help="run the MCP server for AI clients (stdio transport)", + description=( + "Run sevm as a Model Context Protocol server over stdio. An MCP client " + "starts/steps debug sessions and reads structured, windowed state — " + "see docs/mcp.md for the tool list and client configuration." + ), + ) + mcp_cmd.add_argument( + "--timeout", + type=float, + default=120.0, + help="seconds to wait for the target to reach contract code (default: 120)", + ) + return parser @@ -522,9 +538,21 @@ def main(argv: list[str] | None = None) -> int: return cmd_compile(args) if args.command == "doctor": return cmd_doctor(args) + if args.command == "mcp": + return cmd_mcp(args) parser.print_help() return 1 +def cmd_mcp(args: argparse.Namespace) -> int: + """Serve the debugger over MCP stdio; logs go to stderr only.""" + from .mcp_server import DebugDriver, build_server + + driver = DebugDriver(timeout=args.timeout) + server = build_server(driver) + server.run() + return 0 + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/sevm/commands/help.py b/src/sevm/commands/help.py index b1be7b7..1dbb5d8 100644 --- a/src/sevm/commands/help.py +++ b/src/sevm/commands/help.py @@ -43,6 +43,7 @@ [cyan]bt[/cyan] / [cyan]f[/cyan] N / [cyan]up[/cyan] / [cyan]down[/cyan] call stack, EVM and Solidity frames [cyan]l[/cyan]ist [LINE] source listing [cyan]disas[/cyan]semble disassembly around the pc + [cyan]find[/cyan] HEX offsets of a byte pattern in the running code [cyan]sig[/cyan] FUNC the signature and 4-byte selector: [dim]sig withdraw[/dim] [cyan]copy[/cyan] [CMD] put a command's output on the system clipboard [cyan]i[/cyan]nfo TOPIC registers, breakpoints, frame, args, locals, storage, diff --git a/src/sevm/commands/inspecting.py b/src/sevm/commands/inspecting.py index 05e944a..55cf50b 100644 --- a/src/sevm/commands/inspecting.py +++ b/src/sevm/commands/inspecting.py @@ -161,6 +161,50 @@ def cmd_backtrace(proc: CommandProcessor, args: list[str], rest: str) -> Command return result +def cmd_find(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult: + """`find ` — every offset in the running code where the byte pattern occurs. + + Gadget hunting: reports whether each hit starts on an instruction boundary, and + the nearest preceding JUMPDEST — the address an indirect jump can actually reach. + """ + proc.require_stop() + if not args: + return CommandResult(error="usage: find e.g. find 60515255") + pattern = args[0].lower().removeprefix("0x") + if ( + not pattern + or len(pattern) % 2 + or any(c not in "0123456789abcdef" for c in pattern) + ): + return CommandResult(error=f"not a hex pattern: {args[0]!r}") + found = proc.inspect("find_needle", pattern) + hits, total = found["hits"], found["total"] + name = proc.snapshot.contract_name if proc.snapshot else "the running code" + if not hits: + return CommandResult().add(f"[dim]no occurrence of {pattern} in {name}[/dim]") + result = CommandResult().add( + f"[dim]{total} occurrence(s) of {pattern} in {name}[/dim]" + ) + for hit in hits[:20]: + flags = [] + if hit["jumpdest"]: + flags.append("JUMPDEST") + elif hit["instruction_aligned"]: + flags.append("aligned") + else: + flags.append("inside operand") + if hit["nearest_jumpdest"] is not None: + flags.append(f"jump via 0x{hit['nearest_jumpdest']:04x}") + line = f" [dim]L{hit['line']}[/dim]" if hit["line"] else "" + result.add( + f" [cyan]0x{hit['pc']:04x}[/cyan] {_escape(hit['text'])}" + f" [dim]({', '.join(flags)})[/dim]{line}" + ) + if total > len(hits): + result.add(f"[dim]... and {total - len(hits)} more[/dim]") + return result + + def cmd_frame(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult: snap = proc.require_stop() if not args: @@ -294,4 +338,5 @@ def cmd_disassemble(proc: CommandProcessor, args: list[str], rest: str) -> Comma "l": cmd_list, "disassemble": cmd_disassemble, "disas": cmd_disassemble, + "find": cmd_find, } diff --git a/src/sevm/mcp_server/__init__.py b/src/sevm/mcp_server/__init__.py new file mode 100644 index 0000000..a3b652a --- /dev/null +++ b/src/sevm/mcp_server/__init__.py @@ -0,0 +1,21 @@ +"""The MCP frontend: an AI-oriented, headless third frontend over the debugger. + +Where the console renders Rich markup and the TUI draws panes, this frontend +returns structured JSON with explicit windows and truncation flags — an LLM +cannot scroll a pane or watch colour, but it can narrow an offset. Launch with +`sevm mcp` (stdio transport). + +Where things live: + + driver.py `DebugDriver`: owns the one session, mirrors `sevm run`'s wiring, + and remembers the previous stop for diffs + serialize.py value formatting: hex words, memory windows, the stop report + tools.py the tool registrations over the driver +""" + +from __future__ import annotations + +from .driver import DebugDriver, DriverError +from .tools import build_server + +__all__ = ["DebugDriver", "DriverError", "build_server"] diff --git a/src/sevm/mcp_server/driver.py b/src/sevm/mcp_server/driver.py new file mode 100644 index 0000000..4165a33 --- /dev/null +++ b/src/sevm/mcp_server/driver.py @@ -0,0 +1,890 @@ +"""The MCP frontend's session driver. + +The console and the TUI are thin renderers over `CommandProcessor`; this is the +third frontend, and the only thing it draws is JSON. `DebugDriver` owns one +debug session (single active target, replaced by the next start), mirrors the +wiring `sevm run` does for both target kinds, and remembers the previous stop so +it can answer "what changed?" — the question a human answers by watching panes. +""" + +from __future__ import annotations + +import os +import threading +from typing import Any + +from ..commands.parsing import expand_file_args +from ..commands.processor import CommandProcessor +from ..commands.render import _plain +from ..compile import CompileError, compile_foundry_project, find_foundry_root +from ..evaluate import Evaluator, make_eval_hook +from ..frames import FrameSnapshot +from ..session import DebugSession, Finished, SessionError, StepMode +from .serialize import ( + WORD, + backtrace_rows, + hex_short, + hex_word, + memory_window, + snapshot_report, + stack_entry, +) + +# Caps that keep one tool call from flooding a model's context window. Every +# truncated result says so, so the model knows to narrow its next request. +MAX_MEMORY_WORDS = 64 +MAX_DISASM_ROWS = 128 +MAX_SOURCE_LINES = 101 +MAX_FIND_HITS = 200 +MAX_MEMORY_SCAN = 65536 + + +class DriverError(RuntimeError): + """A tool-level failure with an actionable message.""" + + +class DebugDriver: + """One live debug target at a time, driven entirely from tool calls.""" + + def __init__(self, timeout: float = 120.0) -> None: + self.timeout = timeout + self._lock = threading.RLock() + self._session: DebugSession | None = None + self._processor: CommandProcessor | None = None + self._target_path: str | None = None + self._target_kind: str | None = None # "script" | "foundry" + self._prev_snapshot: FrameSnapshot | None = None + self.notices: list[str] = [] + + # -- lifecycle --------------------------------------------------------- + + def _notice(self, message: str) -> None: + self.notices.append(message) + + def _require(self) -> tuple[DebugSession, CommandProcessor]: + if self._session is None or self._processor is None: + raise DriverError( + "no active debug session; call sevm_start_session first " + "(script_path for a web3.py driver or a .t.sol Foundry test)" + ) + return self._session, self._processor + + def start( + self, + target: str, + args: list[str] | None = None, + contracts: str | None = None, + solc: str | None = None, + optimize: bool = False, + match: str | None = None, + match_contract: str | None = None, + timeout: float | None = None, + ) -> dict[str, Any]: + """Compile and start a debug target; replaces any existing session.""" + with self._lock: + self.stop() + self.notices = [] + if timeout is not None: + self.timeout = timeout + if not os.path.isfile(target): + raise DriverError(f"no such file: {target}") + self._target_path = target + if target.endswith(".sol"): + self._target_kind = "foundry" + return self._start_foundry(target, solc, optimize, match, match_contract) + self._target_kind = "script" + return self._start_script(target, args or [], contracts, solc, optimize) + + def _start_script( + self, + script: str, + args: list[str], + contracts: str | None, + solc: str | None, + optimize: bool, + ) -> dict[str, Any]: + # Same wiring as `sevm run