diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..7072f11 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,26 @@ +name: Verify + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: dtolnay/rust-toolchain@stable + - uses: astral-sh/setup-uv@v10.2.0 + with: + python-version: "3.12" + - run: uv sync --locked + - run: uv run ruff check src tests examples + - run: uv run ruff format --check src tests examples + - run: uv run mypy src + - run: uv run pytest -q tests/test_stepping.py::test_loop_iterates_with_next + - run: uv run pytest -q + + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --workspace --locked diff --git a/README.md b/README.md index 452e294..b44ec57 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,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) @@ -568,6 +570,24 @@ 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. Each +hit includes instruction alignment and the nearest preceding JUMPDEST: + +```bash +(sevm) find 5b +``` + +### Debug from an AI client (MCP) + +`sevm mcp` serves the debugger over the Model Context Protocol (stdio). It provides +windowed reads with explicit truncation, a stop report after every navigation, and a +diff of what the last step changed. Operand provenance (`why N`) traces stack values +through recorded opcodes. The server also checks compiled runtime bytecode against a +deployment and exports structLog traces. 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 @@ -636,6 +656,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 | | [docs/headless.md](docs/headless.md) | REVM engine protocol for external frontends | ## Development diff --git a/docs/commands.md b/docs/commands.md index 90b2fdd..fce43c1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -70,6 +70,10 @@ 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 | +| `parity [Contract] 0x` | prove the compiled runtime matches the deployed bytecode; reports the first divergence, ignoring the metadata tail and immutables | +| `provenance on\|off\|status` | opt-in per-opcode recording | +| `why N` | trace $stack[N] back to its origins: constants, calldata, MSTORE/SSTORE chains, frame-entry slots | | `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..8fda76f --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,125 @@ +# sevm MCP server for AI clients + +`sevm mcp` runs the REVM debugger as a [Model Context Protocol](https://modelcontextprotocol.io) +server over stdio. Its tools return compact JSON for clients that inspect and step +through transactions. + +[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 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 +uv run sevm mcp +sevm mcp --timeout 300 +``` + +Any MCP client that speaks stdio can connect. Generic configuration: + +```json +{ + "mcpServers": { + "sevm": { + "command": "sevm", + "args": ["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 web3.py driver script or a `.t.sol` Foundry test. Extra `args` go to scripts, and `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 + +| 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 with region names. `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. The first window includes selector and 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, provenance, and bytecode parity + +| Tool | Meaning | +|---|---| +| `sevm_find_bytes` | hex pattern in `code` (gadget hunting: pc, instruction alignment, nearest preceding JUMPDEST), `memory`, or `calldata` | +| `sevm_set_provenance` / `sevm_why_stack(index)` | record opcode steps within a frame and trace stack values to constants, calldata, memory, storage, and frame entry | +| `sevm_export_trace` | the recording as anvil/geth structLog JSON (to a file, or a windowed inline slice) | +| `sevm_check_parity` | compare a compiled runtime against deployed bytecode. `sevm_start_session(reference_runtime_hex=...)` stops on divergence | + +**Provenance semantics**: REVM pauses at each opcode while recording is enabled. +The slice follows recorded values and positions within a frame. It skips the +transition into a nested call because the two frames have different stacks. +Hand mutations (`set $stack[i]`) are not recorded, so ask `why` before mutating. + +### Breakpoints + +| Tool | Meaning | +|---|---| +| `sevm_set_breakpoint` | `File.sol:LINE`, `*0xPC`, or a function name, with an 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 c8557f6..7c61ac6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,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 e47ff6d..f66758a 100644 --- a/src/sevm/cli.py +++ b/src/sevm/cli.py @@ -167,6 +167,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 @@ -490,7 +506,7 @@ def _debug( for command in startup_commands: frontend._emit(frontend.commands.execute(command)) frontend.run(first_event=first) - return 0 + return 0 if session.exit_error is None else 1 try: from .tui.app import SevmApp @@ -502,16 +518,16 @@ def _debug( from .console import ConsoleFrontend ConsoleFrontend(session, evaluator).run(first_event=first) - return 0 + return 0 if session.exit_error is None else 1 app = SevmApp( session, evaluator, first_event=first, startup_commands=startup_commands ) # Mouse on: every pane renders Textual `Content`, which the framework can - # select/highlight/copy (drag to select, ctrl+c to copy). `--no-mouse` hands + # select, highlight and copy (drag to select, ctrl+c to copy); `--no-mouse` hands # selection back to the terminal. app.run(mouse=not args.no_mouse) - return 0 + return 0 if session.exit_error is None else 1 def main(argv: list[str] | None = None) -> int: @@ -525,9 +541,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..d0dbb1d 100644 --- a/src/sevm/commands/help.py +++ b/src/sevm/commands/help.py @@ -43,6 +43,10 @@ [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]parity[/cyan] [Contract] 0x prove the build matches the deployed runtime + [cyan]provenance[/cyan] on|off record every opcode (feeds `why`) + [cyan]why[/cyan] N what produced the value in $stack[N] [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..47ed254 100644 --- a/src/sevm/commands/inspecting.py +++ b/src/sevm/commands/inspecting.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING from ..frames import FrameSnapshot @@ -161,6 +162,145 @@ 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_provenance(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult: + """`provenance on|off|status` — opt-in per-opcode recording for `why`.""" + prov = proc.session.provenance + if not args or args[0] not in ("on", "off", "status"): + return CommandResult(error="usage: provenance on|off|status") + if args[0] in ("on", "off"): + prov.enabled = args[0] == "on" + state = "on" if prov.enabled else "off" + return CommandResult().add( + f"[yellow]provenance recording {state}[/yellow] " + f"[dim]({len(prov.records)} opcode(s) recorded)[/dim]" + ) + + +def cmd_why(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult: + """`why N` — what produced the value in $stack[N] (0 = top). + + Walks the recording newest→oldest: constants, DUP/SWAP copies, consumed + operands, MSTORE→MLOAD and SSTORE→SLOAD chains, calldata windows, and the + frame-entry slots anything else traces back to. + """ + snap = proc.require_stop() + if not args or not args[0].lstrip("-").isdigit(): + return CommandResult(error="usage: why ") + index = int(args[0]) + prov = proc.session.provenance + if not prov.records: + return CommandResult( + error="nothing recorded; `provenance on` first, then navigate" + ) + from ..session.provenance import why_stack + + bottom_up = tuple(int(entry.value) for entry in reversed(snap.stack)) + result = why_stack(prov.records, snap.depth, bottom_up, index) + if result.get("error"): + return CommandResult(error=result["error"]) + out = CommandResult().add( + f"[dim]why $stack[{index}] ({len(result['origins'])} origin(s)):[/dim]" + ) + for origin in result["origins"]: + chain = " <- ".join(f"0x{hop['pc']:04x} {hop['op']}" for hop in origin["via"][:6]) + more = " …" if len(origin["via"]) > 6 else "" + out.add( + f" [{origin['kind']}] {_escape(origin['detail'])}" + + (f" = {origin['value'][:18]}…" if origin.get("value") else "") + + (f" [dim]{chain}{more}[/dim]" if chain else "") + ) + return out + + +def cmd_parity(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult: + """`parity [Contract] 0x` — prove the compiled runtime matches a deployment. + + Breakpoint pcs and gadget offsets are only true for the exact bytes on chain: + paste the deployed runtime (explorer `cast code` output) and this reports the + first divergence, ignoring the metadata tail and immutable values. + """ + snap = proc.require_stop() + hex_arg = None + contract = None + for arg in args: + if arg.lower().startswith("0x") and len(arg) > 2: + hex_arg = arg[2:] + elif re.fullmatch(r"[0-9a-fA-F]+", arg) and len(arg) % 2 == 0: + hex_arg = arg + else: + contract = arg + if hex_arg is None: + return CommandResult(error="usage: parity [Contract] 0x") + try: + reference = bytes.fromhex(hex_arg) + except ValueError: + return CommandResult(error=f"not hex: {hex_arg[:16]}…") + name = contract or snap.contract_name + art = proc.project.artifact(name) if name else None + if art is None or not art.deployed_bytecode: + return CommandResult(error=f"no compiled artifact for {name!r}") + from ..compile import compare_runtime + + verdict = compare_runtime(art, reference) + if verdict["match"]: + return CommandResult().add( + f"[green]runtime matches {art.name}[/green] " + f"[dim](compiled {verdict['compiled_size']}B, reference " + f"{verdict['reference_size']}B; metadata/immutables ignored)[/dim]" + ) + return CommandResult().add( + f"[bold red]runtime DIVERGES from {art.name}[/bold red] at " + f"0x{verdict['first_diff_offset']:04x} " + f"[dim](compiled {verdict['compiled_size']}B, reference " + f"{verdict['reference_size']}B; every pc past this point describes a " + f"different program — rebuild with the deployed solc/flags)[/dim]" + ) + + def cmd_frame(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult: snap = proc.require_stop() if not args: @@ -294,4 +434,8 @@ def cmd_disassemble(proc: CommandProcessor, args: list[str], rest: str) -> Comma "l": cmd_list, "disassemble": cmd_disassemble, "disas": cmd_disassemble, + "find": cmd_find, + "parity": cmd_parity, + "provenance": cmd_provenance, + "why": cmd_why, } diff --git a/src/sevm/commands/mutation.py b/src/sevm/commands/mutation.py index 5b58066..c881706 100644 --- a/src/sevm/commands/mutation.py +++ b/src/sevm/commands/mutation.py @@ -29,7 +29,7 @@ def cmd_set(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult # A bare local name is a stack slot, not storage, and has to be written as one. if re.fullmatch(r"[A-Za-z_$][\w$]*", lhs) and proc.is_local(lhs): - value = int(proc.evaluate(rhs).value) + value = _rhs_value(proc, rhs) written = proc.inspect( "write_local", lhs, value, internal_index=proc.selected_internal ) @@ -51,8 +51,21 @@ def cmd_set(proc: CommandProcessor, args: list[str], rest: str) -> CommandResult ) +def _rhs_value(proc: CommandProcessor, rhs: str) -> int: + """A decimal or 0x-hex literal parses without solc; anything else evaluates. + + The fast path is not just cheaper: a bare `0x` literal of address or bytes + width is not a valid Solidity expression, so `set $stack[0] = 0x
` + would fail outright instead of writing the value. + """ + text = rhs.strip() + if re.fullmatch(r"0[xX][0-9a-fA-F]+|\d+", text): + return _integer(text, "set") + return int(proc.evaluate(text).value) + + def _set_convenience(proc: CommandProcessor, lhs: str, rhs: str) -> CommandResult: - value = int(proc.evaluate(rhs).value) + value = _rhs_value(proc, rhs) name = lhs[1:] if name == "pc": proc.inspect("set_pc", value) diff --git a/src/sevm/commands/processor.py b/src/sevm/commands/processor.py index 1312459..1202aeb 100644 --- a/src/sevm/commands/processor.py +++ b/src/sevm/commands/processor.py @@ -324,6 +324,8 @@ def render_event(self, event: Any) -> CommandResult: result.add("[green]program finished[/green]") else: result.add(f"[red]program raised {event.error}[/red]") + if event.traceback: + result.add(f"[dim]{_escape(event.traceback)}[/dim]") return result if isinstance(event, Paused): result.lines.extend(self.describe_stop(event.snapshot)) diff --git a/src/sevm/compile/__init__.py b/src/sevm/compile/__init__.py index 9d9696a..c095442 100644 --- a/src/sevm/compile/__init__.py +++ b/src/sevm/compile/__init__.py @@ -5,7 +5,8 @@ Where things live: - model.py `SourceFile`, `Artifact`, `Project`, and bytecode identity + model.py `SourceFile`, `Artifact`, `Project`, bytecode identity, + and `compare_runtime` for deployed-build parity solc.py calling solc, and reading .sol files in solcbin.py finding or downloading a solc binary this machine can run wasm.py solc's WebAssembly build, for machines no binary runs on @@ -28,7 +29,7 @@ resolve_dependencies, unresolved_prefixes, ) -from .model import Artifact, CompileError, Project, SourceFile +from .model import Artifact, CompileError, Project, SourceFile, compare_runtime from .solc import DEFAULT_SOLC_VERSION, compile_standard, ensure_solc from .solcbin import platform_key from .versions import resolve_solc_version @@ -44,6 +45,7 @@ "FoundryConfig", "Project", "SourceFile", + "compare_runtime", "compile_foundry_project", "compile_project", "compile_standard", diff --git a/src/sevm/compile/model.py b/src/sevm/compile/model.py index 75b7fd2..ec3f7c9 100644 --- a/src/sevm/compile/model.py +++ b/src/sevm/compile/model.py @@ -143,6 +143,11 @@ def _strip_metadata(code: bytes) -> bytes: def _code_fingerprint(code: bytes, artifact: Artifact | None) -> bytes: """Metadata-stripped, immutable-masked hash of runtime code.""" + return hashlib.sha256(_masked_body(code, artifact)).digest() + + +def _masked_body(code: bytes, artifact: Artifact | None) -> bytes: + """The metadata-stripped, immutable-masked bytes a fingerprint hashes.""" body = bytearray(_strip_metadata(code)) if artifact is not None: for refs in artifact.immutable_references.values(): @@ -150,4 +155,39 @@ def _code_fingerprint(code: bytes, artifact: Artifact | None) -> bytes: start, length = int(ref["start"]), int(ref["length"]) if start + length <= len(body): body[start : start + length] = b"\x00" * length - return hashlib.sha256(bytes(body)).digest() + return bytes(body) + + +def compare_runtime(artifact: Artifact, reference: bytes) -> dict: + """Compare a compiled artifact's runtime code against a reference deployment. + + For debugging a *deployed* target, pc parity is only as good as the build: + if the compiled body diverges from the on-chain code anywhere before the + trailing metadata tail, every breakpoint pc and gadget offset silently + describes a different program. This reports the first divergence instead. + + Both sides are metadata-stripped and immutable-masked, matching how + `artifact_for_code` identifies running code, so a differing metadata tail or + differing immutable values do not count as divergence. + """ + compiled = _masked_body(artifact.deployed_bytecode, artifact) + other = _masked_body(reference, artifact) + if compiled == other: + return { + "match": True, + "difference": "none", + "compiled_size": len(artifact.deployed_bytecode), + "reference_size": len(reference), + "first_diff_offset": None, + } + first = next( + (i for i, (a, b) in enumerate(zip(compiled, other, strict=False)) if a != b), + min(len(compiled), len(other)), + ) + return { + "match": False, + "difference": "code", + "compiled_size": len(artifact.deployed_bytecode), + "reference_size": len(reference), + "first_diff_offset": first, + } diff --git a/src/sevm/console.py b/src/sevm/console.py index 79719ee..44dafb0 100644 --- a/src/sevm/console.py +++ b/src/sevm/console.py @@ -45,9 +45,20 @@ def on_first_stop(self, event) -> None: for line in self.commands.describe_stop(event.snapshot): self.console.print(line) elif isinstance(event, Finished): - self.console.print( - "[yellow]the program finished without hitting the debugger[/yellow]" - ) + if event.ok: + self.console.print( + "[yellow]the program finished without hitting the debugger[/yellow]" + ) + else: + # The script died before any EVM opcode ran; the traceback is the + # only diagnosis there is, so it must reach the user. Raw text, + # escaped: brackets in a traceback are not style tags. + self.console.print( + f"[bold red]the program raised {escape_markup(str(event.error))}" + "[/bold red]" + ) + if event.traceback: + self.console.print(f"[dim]{escape_markup(event.traceback)}[/dim]") def run(self, first_event=None) -> None: """Read-eval-print until the user quits. 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..5faf529 --- /dev/null +++ b/src/sevm/mcp_server/driver.py @@ -0,0 +1,1004 @@ +"""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._provenance_next = False + 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, + reference_runtime_hex: str | None = None, + provenance: bool = False, + ) -> dict[str, Any]: + """Compile and start a debug target; replaces any existing session. + + `provenance=true` records every executed opcode from the start, enabling + `sevm_why_stack` and `sevm_export_trace` without a second pass. + `reference_runtime_hex` (the deployed runtime, as an explorer or + `cast code` prints it) hard-fails the start when the compiled contract + diverges beyond the metadata tail: pcs and gadget offsets silently + describe a different program otherwise. + """ + 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 + self._provenance_next = bool(provenance) + if target.endswith(".sol"): + self._target_kind = "foundry" + report = self._start_foundry( + target, solc, optimize, match, match_contract + ) + else: + self._target_kind = "script" + report = self._start_script(target, args or [], contracts, solc, optimize) + if reference_runtime_hex: + verdict = self.check_parity(reference_runtime_hex) + if not verdict["match"]: + detail = ( + f"first divergence at 0x{verdict['first_diff_offset']:04x} " + f"(compiled {verdict['compiled_size']}B, reference " + f"{verdict['reference_size']}B)" + ) + self.stop() + raise DriverError( + f"bytecode parity failed for {verdict['contract']}: {detail}; " + "rebuild with the deployed solc version and flags — every pc " + "past the divergence describes a different program" + ) + report["parity"] = verdict + return report + + 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