1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
|
# 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
<tool-root>/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":"<json>"}]}`, 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 `vgsp`.
## 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.
|