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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<hex>` | 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 |
Expand Down
125 changes: 125 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
@@ -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<new payload>"])
```
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ dependencies = [
"rlp>=4.0",
"packaging>=23.0",
"requests>=2.31",
"mcp>=2.2.0",
"anyio>=4.15.1",
]

[project.scripts]
Expand Down
36 changes: 32 additions & 4 deletions src/sevm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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())
4 changes: 4 additions & 0 deletions src/sevm/commands/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<hex> 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,
Expand Down
Loading
Loading