# 20 — MCP server (risc-v-gsp-rm-debugger agent interface) > Status: implemented, documented and tested (audit: docs/27-TOOL-AUDIT.md; value demos: docs/25-VALUE-VALIDATION.md). ## 1. What it is `plugins/mcp_server.py` is the risc-v-gsp-rm-debugger **MCP (Model Context Protocol) server** — roadmap [`R3`](08-ROADMAP.md#r3-mcp-server-plugin) and [`H7`](../RISC-V-GSP-RM-DEBUGGER-ROADMAP.md). It exposes the *active* `Session` as MCP tools so an AI agent (Claude Code, opencode, any MCP client) can inspect symbols, set breakpoints/watchpoints, run the target, read memory, snapshot, analyse/compare recordings, inject services, and drive the harnesses. * **Stdlib-only**: JSON-RPC 2.0 over newline-delimited stdin/stdout, no MCP library, no network listener unless explicitly asked. * **Plugin first**: `register(ge)` adds an `mcp-serve` REPL/CLI command that serves the session already loaded by risc-v-gsp-rm-debugger. * **Standalone too**: the same file runs as a script that launches its own session (`--kernel`, `--backend`, `--record`) and then serves MCP. * **Read-only by default**: memory writes, remote calls and injection require explicit startup flags. * **Defensive capabilities**: tools owned by other components (mutation and portability harnesses, native trace, injection) return a structured `not_available` tool error when the module/file is absent instead of crashing. The tool list always advertises every capability. The core risc-v-gsp-rm-debugger package is untouched; everything lives in the plugin. ## 2. Running it All commands run from `open-iov/debug/risc-v-gsp-rm-vm/`. ### 2.1 Standalone (the usual MCP client mode) ```bash # bundled demo, QEMU, recorded session python3 plugins/mcp_server.py --stdio --record # native simulator, recorded python3 plugins/mcp_server.py --stdio --backend native --kernel firmware/uproc_demo/build/uproc_demo.elf --record # enable writes / injection (never on by default) python3 plugins/mcp_server.py --stdio --allow-write --allow-inject # local loopback TCP instead of stdio (for clients that cannot spawn a process) python3 plugins/mcp_server.py --tcp 127.0.0.1:8765 --record ``` Flags: `--stdio` (default), `--tcp HOST:PORT` (loopback only), `--kernel ELF`, `--backend qemu|native`, `--record [PATH]`, `--name NAME`, `--plugins PATH` (repeatable), `--allow-write`, `--allow-inject`. Logs go to stderr; **stdout carries protocol frames only**. ### 2.2 Plugin mode (serve a live session) ```bash ./risc-v-gsp-rm-debugger.sh attach --kernel firmware/uproc_demo/build/uproc_demo.elf \ --plugin plugins/mcp_server.py risc-v-gsp-rm-debugger> mcp-serve # read/observe risc-v-gsp-rm-debugger> mcp-serve --allow-write # + memory writes / remote calls risc-v-gsp-rm-debugger> mcp-serve --allow-inject # + service injection ``` `mcp-serve` blocks until the MCP client disconnects or calls the `close` tool, then returns to the REPL. In plugin mode `close` stops the server but **does not** close the session (the REPL still owns it); in standalone mode `close` also closes the launched session. Both `--stdio` and `--tcp` behave identically. The bundled plugin directory is loaded by default, so `mcp_server.py` is auto-discovered; the command only serves when invoked. ## 3. Client configuration The MCP server must be started **before** the session it should serve, so client configs use standalone mode. Use absolute paths. ### 3.1 Claude Code / `.mcp.json` ```json { "mcpServers": { "risc-v-gsp-rm-debugger": { "command": "python3", "args": [ "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py", "--stdio", "--backend", "native", "--kernel", "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/build/uproc_demo.elf", "--record" ] } } } ``` Or, one line: `claude mcp add risc-v-gsp-rm-debugger -- python3 /plugins/mcp_server.py --stdio --record`. ### 3.2 opencode (`opencode.json`) ```json { "$schema": "https://opencode.ai/config.json", "mcp": { "risc-v-gsp-rm-debugger": { "type": "local", "command": [ "python3", "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py", "--stdio", "--backend", "native", "--record" ], "enabled": true } } } ``` For an agent that must write memory or inject, add `--allow-write` / `--allow-inject` to `args` and keep the session recorded (the recording is the audit trail). ## 4. Protocol | Request | Behaviour | |---|---| | `initialize` | returns `protocolVersion` (echoes `2024-11-05`, `2025-03-26` or `2025-06-18`; otherwise the server default), `serverInfo`, `capabilities.tools` | | `notifications/initialized` | accepted silently (notifications never get a response) | | `ping` | `{}` | | `tools/list` | the 24-tool catalogue with JSON input schemas | | `tools/call` | `{"name": ..., "arguments": {...}}` → `{"content":[{"type":"text","text":""}]}`, or `isError: true` for tool errors | JSON-RPC errors: `-32700` parse error, `-32600` invalid request, `-32601` unknown method, `-32602` unknown tool / bad params. Tool-level failures (bad address, symbol not found, gating, capability absent) are returned as `isError: true` results whose text is a JSON object with `error` and `message` — the server never dies on a bad call. Tool result payloads are always a single JSON text block so agents can parse them deterministically. ## 5. Tool catalogue | Tool | Arguments | Class | Notes | |---|---|---|---| | `session_info` | — | read | backend, PC, symbols, recording, breakpoints/watches, enabled capabilities | | `symbols_resolve` | `target` | read | symbol, `symbol+0xNN` or hex address | | `bp_set` | `target`, `kind=sw\|hw` | observe | hits recorded as `bp_hit` | | `bp_clear` | `target` | observe | removes every breakpoint at that address | | `watch_set` | `target`, `size=8`, `kind=w\|r\|a` | observe | hits recorded as `watch_hit` | | `run` | `duration=1.0`, `max_stops`, `timeout` | observe | bounded; services hooks/watchpoints | | `run_until` | `target`, `timeout=5.0` | observe | temporary breakpoint | | `step` | `count=1` (max 10000) | observe | dispatches hooks/watchpoints | | `read_mem` | `addr`, `size=64` (max 1 MiB) | read | lowercase hex | | `write_mem` | `addr`, `hex` | **write** | requires `--allow-write`; recorded as `mem_write` | | `hexdump` | `addr`, `size=64` (max 1 MiB) | read | symbol-annotated text | | `snapshot` | `addr`, `size` (max 16 MiB), `label=mcp` | read | zlib + sha256 into the session | | `call_function` | `target`, `args=[]`, `timeout=5.0` | **write** | requires `--allow-write`; Frida-like remote call | | `inject_service` | `service`, `slot=6`, `load_addr`, `cmd=1`, `arg0`, `arg1`, `path=remote\|firmware\|both`, `settle=0.25` | **inject** | requires `--allow-inject`; service ELF built with `make sdk` | | `trace_start` | `exec=true`, `start`, `end`, `max_events` | trace | native backend + recording session only | | `trace_stop` | — | trace | flushes `blobs/trace.jsonl`, returns counts | | `analyze_current` | `max_rows=40` | read | `render_summary` for the active recording | | `compare_sessions` | `a`, `b`, `symbols=true`, `max_rows=40` | read | A/B recorded sessions | | `export_hits` | `session`, `out` | read | Ghidra `greeneye_hits.tsv` | | `probe_firmware` | `elf`, `instructions=2000`, `duration=1.0`, `no_relocate`, `symbols`, `timeout=300` | harness | QEMU-only probe subprocess; first divergence | | `mutation_run` | `kernel`, `out`, `iterations=10`, `seed=1`, `timeout=0.25`, `settle=0.2`, `memory_region`, `allow_foreign_elf` | harness | `harness/mutation_harness.py`, QEMU-only | | `portability_matrix_run` | `targets[]`, `out`, `probe_instructions=2000`, `scenario_duration=0.4`, `symbols`, `max_rows` | harness | `harness/portability_matrix.py`, QEMU-only | | `plugins_list` | — | read | bundled plugins, `GREENEYE_PLUGINS`, REPL commands | | `close` | — | session | stop serving; standalone also closes the session | Capability classification follows the manifest in [15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) §5: read / observe are unattended-safe; write and inject require the explicit startup flags and a recorded session. `bp_set`, `watch_set`, `run` and `step` mutate target *observation state* only and stay in the observe class. Harness and probe tools shell out to the existing CLIs with bounded budgets and return their stdout/stderr; they never touch hardware, a guest or a kernel module. ## 6. Example agent sessions ### 6.1 Monitor an automated firmware-release run ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_info","arguments":{}}} {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"symbols_resolve","arguments":{"target":"rpc_ping"}}} {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"bp_set","arguments":{"target":"rpc_ping"}}} {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run","arguments":{"duration":0.5,"max_stops":100}}} {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"analyze_current","arguments":{"max_rows":20}}} ``` The agent watches `bp_hit`/`watch_hit` counts in the report, snapshots the control block, runs the release stimulus, then diffs the new recording against the golden one: ```json {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"snapshot","arguments":{"addr":"gsp_ctrl","size":392,"label":"release"}}} {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"compare_sessions","arguments":{"a":"dumps/golden","b":"dumps/release","symbols":true}}} ``` ### 6.2 Dump memory ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hexdump","arguments":{"addr":"0x80000840","size":128}}} {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_mem","arguments":{"addr":"0x80000840","size":16}}} {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"snapshot","arguments":{"addr":"0x80000000","size":4096,"label":"ram-window"}}} ``` ### 6.3 Inject a service Start the server with `--allow-write --allow-inject --record`, then: ```json {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inject_service","arguments":{"service":"sdk/build/injected_demo.elf","slot":6,"cmd":1,"arg0":65,"arg1":66,"path":"both"}}} ``` The result contains the load address/entry and both the direct (`remote`) and firmware-mailbox (`firmware`) return values; every segment write and call is recorded, and the session can be compared like any other run. ## 7. Security rules 1. **Local only.** Default transport is stdin/stdout. `--tcp` binds loopback addresses only (`127.0.0.1`, `localhost`, `::1`); any other host is refused at argument parse time. There is no auth, so never expose it. 2. **No guest, no hardware.** The server drives the simulator/QEMU session only. It does not touch `/dev`, a kernel module, an mdev, a guest or a physical GPU; the agent runs against the simulator by default ([H7](../RISC-V-GSP-RM-DEBUGGER-ROADMAP.md), [15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) §5). 3. **Read-only by default.** `--allow-write` enables `write_mem` and `call_function`; `--allow-inject` enables `inject_service`. The agent's client config decides which manifest class it gets; a read/observe agent is configured without those flags. 4. **Sessions are the audit trail.** Writes, calls, injections and snapshots land in `events.jsonl`; run with `--record` (the trace tools require it). A finding without a session is not a finding. 5. **Budgets everywhere.** `run` defaults to 1 s, `step` is capped, reads and snapshots have size caps, and harness subprocesses have timeouts. A wedged target is left stopped and restarted clean, never poked. 6. **Protocol hygiene.** stdout is reserved for JSON-RPC; diagnostics go to stderr. One bad tool call returns `isError` and the server keeps serving. 7. **No product changes.** MCP can propose evidence, not merge emulation changes; human approval still gates `open-iov-vfio`. ## 8. Absent capabilities `probe_firmware`, `mutation_run`, `portability_matrix_run`, `trace_*`, `inject_service`, `analysis` and the Ghidra export are checked at call time. If a module/file is missing the tool returns: ```json {"content":[{"type":"text","text":"{\"error\": \"not_available\", \"message\": \"mutation harness not present: ...\"}"}],"isError": true} ``` so an agent can plan around a capability instead of seeing the server crash. The tool list is stable regardless of what is present. ## 9. Testing ```bash python3 -m unittest tests.test_mcp -v # 34 tests, no external deps ``` The suite drives `initialize`/`tools/list`/`tools/call` over in-memory streams (including unknown method/tool, parse errors, write/inject gating, no-stdout-pollution and structured `not_available`), plus a standalone subprocess test on the native backend that skips cleanly when the native simulator is absent. ## 10. Roadmap * **R3 (done)** — MCP plugin exposing sessions as agent tools; local stdio, no network by default; capability gating. * **H7 remaining** — named harness entry points and budgets, a session index for agents, enforced capability manifest files, and the physical-escalation gate. Those build on this server, not around it.