Pyromaniac

Pyromaniac MCP server

Introduction

The Pyromaniac MCP server lets an MCP client (Claude Code, Claude Desktop, the API's MCP connector, ...) drive a RISC OS Pyromaniac instance directly: run commands, read/write files, take screenshots, and inject keyboard/mouse input.

It builds on two mechanisms Pyromaniac already provides:

Unlike those tools, the MCP server owns the Pyromaniac process itself: it launches pyro_server.py as a subprocess with both sockets enabled, and can stop and relaunch that process entirely, not just reset the RISC OS environment running inside it.

Requirements

Install the MCP Python SDK and PyYAML:

$ pip install -r scripts/pyro-mcp/requirements.txt

If you're working from a built/installed Pyromaniac archive (the one crosscompile/Makefile's cross_install produces, alongside pyro, pyro-server and pyro-client) rather than a source checkout, use the pyro-mcp command it ships instead: it builds and caches its own Python 3 virtualenv from pyromaniac-resources/pyro-mcp/requirements.txt (separate from the Python 2.7 environment pyro/pyro-server/pyro-client use, since the MCP SDK and PyYAML are Python 3 only), also ensures that Python 2.7 environment exists and is on PATH (needed because pyro_mcp.py launches pyro_server.py as a subprocess via its shebang line), then runs pyro_mcp.pyPYROMCP_ENVDIR overrides where that Python 3 environment lives, the same way PYROMANIAC_ENVDIR does for the Python 2.7 one. In that case, substitute pyro-mcp for python3 scripts/pyro-mcp/pyro_mcp.py everywhere below.

Configuration (environment variables)

Variable Meaning Default
PYROMCP_PYRO_SERVER Path to pyro_server.py found relative to this script's checkout
PYROMCP_HOST Host to bind/connect to 127.0.0.1
PYROMCP_PYROSERVER_PORT Port for the pyro-server command socket 18795 (deliberately distinct from pyro-server's own default of 18794, so an MCP-managed instance doesn't collide with one you're running by hand)
PYROMCP_SYSRQ_PORT Port for the sysrq socket 8810
PYROMCP_CONFIG_FILE YAML config file the MCP server owns and passes to Pyromaniac on every launch, so RISC OS always boots with the same configuration pyromcp-config.yaml next to the script
PYROMCP_LOG File capturing the Pyromaniac process's stdout/stderr, for diagnostics pyromcp.log next to the script

Wiring into MCP clients

All of these register the same command as a stdio MCP server: python3 scripts/pyro-mcp/pyro_mcp.py. Run it from whatever directory you want the RISC OS native filesystem to see (typically the project you're working on) - pyro_server.py inherits that directory unchanged, and it's what filesystem.native_directory's default ('.') resolves against, so this is how your own files stay reachable rather than hidden behind the Pyromaniac checkout. PYROMCP_PYRO_SERVER doesn't need to be an absolute path for this to work - it's resolved relative to this script's own location, not the launch directory - but set it explicitly if pyro_server.py isn't where the default expects. Run it standalone for a quick smoke test of the server itself (it will just sit waiting for stdio JSON-RPC input):

$ python3 scripts/pyro-mcp/pyro_mcp.py

Claude Code

Register it against your user, not a single project, so it's available in every checkout: from a terminal (or via ! inside an interactive session):

$ claude mcp add pyromaniac -s user -- python3 scripts/pyro-mcp/pyro_mcp.py

This is CLI-only - there is no in-chat/slash-command equivalent. The in-chat /mcp command manages servers already configured this way (status, OAuth, tool listing); it can't register a new one. -s/--scope controls where claude mcp add writes the definition: user (~/.claude.json, just you, all projects - what we want here), local (default when -s is omitted, also ~/.claude.json, but just you and this project), or project (.mcp.json, shared via git - copy scripts/pyro-mcp/mcp.json.example to .mcp.json if you want it checked in for a team instead). Other subcommands: claude mcp listclaude mcp get <name>claude mcp remove <name>.

Codex CLI

$ codex mcp add pyromaniac -- python3 scripts/pyro-mcp/pyro_mcp.py

codex mcp add writes to ~/.codex/config.toml (user scope) by default - no flag needed, that's what we want here. A project-scoped .codex/config.toml also exists (trusted projects only) and overrides the user one, so don't create one for this unless you deliberately want a per-project override. Either way the section name is mcp_servers with an underscore, not mcp-servers:

[mcp_servers.pyromaniac]
command = "python3"
args = ["scripts/pyro-mcp/pyro_mcp.py"]

OpenCode

There's no add subcommand or scope flag - it's just which config file you edit. Add to ~/.config/opencode/opencode.json (global, tied to you across all projects - what we want here) rather than a per-project opencode.json in a project root (which would take precedence over the global one if both define the same server), under mcp, with command as an array and type: "local":

{
  "mcp": {
    "pyromaniac": {
      "type": "local",
      "command": ["python3", "scripts/pyro-mcp/pyro_mcp.py"],
      "enabled": true
    }
  }
}

Gemini CLI

gemini mcp add defaults to project scope (.gemini/settings.json), so add -s/--scope user explicitly to tie it to you instead, across all projects:

$ gemini mcp add -s user pyromaniac python3 scripts/pyro-mcp/pyro_mcp.py

or edit ~/.gemini/settings.json (user - what we want here) directly:

{
  "mcpServers": {
    "pyromaniac": {
      "command": "python3",
      "args": ["scripts/pyro-mcp/pyro_mcp.py"]
    }
  }
}

Antigravity

In the IDE: open an Agent session, use the ... dropdown in the side panel, choose MCP Servers to open the MCP store, then Manage MCP ServersView raw config and add the same mcpServers block shown above for Gemini CLI. This edits mcp_config.json, typically under ~/.gemini/config/ (or ~/.gemini/antigravity/ on some installs) - the same file is picked up by both the Antigravity CLI and IDE. Unlike Gemini CLI/Qwen Code, there's no separate project-scoped file here - this config is already tied to you across all projects, so no scope flag or choice of file is needed.

Qwen Code

$ qwen mcp add pyromaniac python3 scripts/pyro-mcp/pyro_mcp.py

qwen mcp add defaults to user scope (~/.qwen/settings.json) - no flag needed, that's what we want here - unlike Gemini CLI, whose default is the other way round. A project-scoped .qwen/settings.json also exists (qwen mcp add -s project ...) if you deliberately want a per-project override; either way it's the same mcpServers structure shown above for Gemini CLI (Qwen Code is a fork of Gemini CLI and shares its config format).

Process lifecycle

The server launches Pyromaniac lazily, on the first tool call, rather than requiring an explicit "start" step. pyro_server.py runs its command socket permanently - it does not depend on a driven -- <command> process to stay alive - and, given sysrqserver.enable=true, the sysrq socket alongside it. Closing its stdin is its designed-in clean shutdown signal (it issues a sysrq system-terminate internally and exits with return code 0), which is what the MCP server uses in preference to a signal wherever it can.

The instance defaults to graphics.implementation=cairo, so riscos_screenshot() works out of the box (the default null implementation can't render anything). This is only a default, not a forced value: since a manual --config always wins over --config-file regardless of order, the MCP server only passes it when the config file doesn't already set graphics.implementation itself - so riscos_config_set('graphics.implementation', ..., persist=True) (or editing the config file directly) does take effect on the next riscos_restart(), overriding the built-in default rather than being silently overridden by it.

pyro_server.py is launched without an explicit cwd=, so it inherits pyro_mcp.py's own working directory unchanged - whatever directory the MCP client actually started it in. This matters because filesystem.native_directory defaults to '.', resolved against that inherited directory: it's what makes the native filesystem land on your own project rather than the Pyromaniac checkout. A user-scoped registration (see "Wiring into MCP clients" above) still gets this right per project, since the MCP client itself launches the server with its own current project directory as the cwd - the user scope only controls where the registration lives, not what directory the server runs in.

Tools

Tool What it does
riscos_status() Report whether the MCP-managed Pyromaniac process is running, and whether its pyro-server and sysrq sockets are responding. Also starts the process if it isn't already running.
riscos_run(command, timeout_s=30) Run a RISC OS command line and return {output, return_code}. The RISC OS session (current directory, system variables, loaded modules) persists across calls. There is no stdin, so a command that prompts will hang until timeout_s.
riscos_reset() Reboot RISC OS (*OS_Reset) - including recovering a stuck/hung riscos_run(). Usually a fast warm reboot (process stays up); falls back to a full process restart if the request arrives while completely idle, reported as warm=False.
riscos_restart() Fully stop and relaunch the Pyromaniac process (a cold restart), using the MCP-managed config file so it boots with the same configuration.
riscos_config_list() List all configuration options and their current values, keyed by Group.key.
riscos_config_get(key) Get the current value of one configuration option.
riscos_config_set(key, value, persist=False) Set a configuration option. Live-only by default (lost on riscos_restart()); pass persist=True to also write it to the MCP-managed config file.
riscos_read_file(path) Read a file from the RISC OS filesystem (e.g. '$.MyDir.MyFile') as UTF-8 text.
riscos_write_file(path, content) Write UTF-8 text to a file on the RISC OS filesystem, creating or overwriting it.
riscos_list(path='$') List the contents of a RISC OS directory.
riscos_screenshot() Capture the current RISC OS display as a PNG image.
riscos_send_text(text) Type text at the keyboard, followed by Return. Queues into the keyboard buffer - only consumed once something actively reads it (a running command, the desktop, ...).
riscos_send_key(key) Press a single named key: ReturnEscapeTabDelete/BackspaceLeftRightUpDown (case-insensitive). Same buffering caveat as riscos_send_text.
riscos_click(x, y) Click the Select (leftmost) mouse button at a screen position (OS units).
riscos_clickmenu(x, y) Click the Menu (middle) mouse button at a screen position.
riscos_clickadjust(x, y) Click the Adjust (rightmost) mouse button at a screen position.
riscos_backtrace() Report a C-style backtrace of the current call stack, when one is available.
riscos_debug_trace(count=200, clear=False) Retrieve recently captured trace output lines.
riscos_debug_pause()riscos_debug_resume()riscos_debug_status() Halt/resume RISC OS code execution for debugging, and report whether/why it's currently halted. See "Debugging tools" below.
riscos_debug_step(mode='into', count=1) Take one or more single-instruction steps, 'into' or 'over' a SWI call. See "Debugging tools" below.
riscos_debug_registers() Report the ARM register set, decoded NZCV flags and mode name, and whether execution is paused.
riscos_debug_read_memory(address, length) Read memory as a formatted hex dump.
riscos_debug_write_memory(address, data) Write memory. data is a hexadecimal-encoded byte string (e.g. 'deadbeef'), the same encoding GDB's M packet uses.
riscos_debug_disassemble(address, length=64) Disassemble memory, with live register/memory annotation.
riscos_debug_option(action, name='') List/enable/disable/toggle Pyromaniac's internal tracing debug options (tracetraceblocktraceswi, ...) - the same ones *PyromaniacDebug controls. Also covers breakexception/breakerror (see "Breaking on exceptions and errors" below) - they're just two more entries in the same registry, nothing extra needed to reach them.
riscos_debug_watchpoint(action, address='', actions='report') List/add/remove memory watchpoints, optionally with a break action.
riscos_debug_tracepoint(action, address='', actions='report') List/add/remove execution tracepoints (by address or function pattern).
riscos_debug_switrap(action, swi='', actions='report') List/add/remove SWI traps.
riscos_debug_breakpoint(action, address='') List/add/remove address breakpoints - sugar over riscos_debug_tracepoint with actions='break'.

See "Debugging tools" below for response shapes and the semantics behind break-tagged watchpoints/tracepoints/switraps.

Sysrq requests and the idle emulation loop

Sysrq requests (used by the config tools and, in later stages, the filesystem/input tools) are only drained while the RISC OS emulation loop is actively running - normally while a riscos_run() command is executing. Without help, a request sent while the instance sits idle between commands would simply queue up unserviced. pyro_server.py's idle wait loop is patched to drain the sysrq queue itself so these tools work at any time, not just mid-command.

This idle draining is deliberately conservative for the two sysrqs that raise a control-flow exception rather than just replying (system-reboot and system-terminate): outside of an active command's execution context there is no safe way to perform Pyromaniac's normal in-place reboot handling, so if one of those arrives while idle, the process exits cleanly instead (riscos_reset() detects this and relaunches automatically, reporting warm=False). A reset requested while a command is running - including a stuck one - is unaffected by this and still gets a genuine warm reboot, since the emulation loop also drains sysrqs on every instruction while it's busy.

Debugging tools

Pause semantics: boundary-granularity, not instruction-exact

riscos_debug_pause() and a break-tagged watchpoint/tracepoint/switrap both work by setting state that Pyromaniac's emulation loop (RISCOS.emulate()) checks at the top of its own loop - the same boundary every sysrq is already limited to. This means:

Ticker/IRQ suspension whilst halted

Any debug halt - riscos_debug_pause(), a break-tagged watchpoint/ tracepoint/switrap, or a riscos_debug_step() boundary - suspends the system ticker and defers hardware IRQ delivery for as long as the halt lasts, restoring both only on a genuine riscos_debug_resume() (not on taking a further step). A pending IRQ is queued rather than dropped, and delivered once execution actually resumes. Without this, a halt lasting any real length of time (or a single-step session taking many steps) would let ticker/IRQ events queue up and land all at once the moment execution continued.

Single-instruction stepping

riscos_debug_step(mode='into', count=1) steps one or more real ARM instructions, in one of two modes:

Like riscos_debug_pause(), this is asynchronous: it returns immediately, and the step(s) only complete the next time the emulation loop reaches a step boundary. Poll riscos_debug_status()reason will be 'step', with info containing {'kind': 'instruction'|'swi', 'address': ...} - to confirm before trusting registers/memory. A count > 1 request may complete fewer steps than asked if something else interrupts it first (e.g. a break-tagged watchpoint/tracepoint/ switrap firing part way through). Interrupts are never stepped into - see "Ticker/IRQ suspension whilst halted" above.

Backtrace availability

riscos_backtrace() reports {available: bool, lines: [...]}. available: False means the current register state doesn't look like a C-style stack frame (a C module in SVC mode, or user mode) - not that the call stack is genuinely empty, which the underlying frame-pointer walker can't actually distinguish from "unavailable". Treat available: False as "can't tell right now", e.g. because execution paused somewhere that isn't in C code with a conventional frame pointer set up.

Tracing debug options vs. watchpoints/tracepoints/switraps

riscos_debug_option() is a different layer from riscos_debug_watchpoint()/tracepoint()/switrap(): those observe RISC OS execution at specific addresses/patterns/SWIs you name, whereas riscos_debug_option() flips Pyromaniac's own built-in tracing hooks - the same set *PyromaniacDebug and --debug control - which apply globally rather than to a location you pick. traceblock in particular is the one most useful for "where did execution actually go before this failed" style questions, since it remembers where each executed code block was for better exception/error reporting, at a much lower cost (~1.2x slowdown) than full instruction tracing (trace, ~60x). See pyromaniac/trace.py's module docstring for the complete option list and what each one costs.

Enabling an option that isn't registered (a typo, or one gated behind a feature not built into this instance) is accepted without error by the underlying system - it's recorded as requested, but has no effect since there's no hook to call - so riscos_debug_option() reports ok: False and a None state in that case, to make the difference visible rather than silently doing nothing.

Breaking on exceptions and errors

Two debug options are boolean halt switches rather than tracing flags, enabled/disabled the same way as any other riscos_debug_option() entry:

Both report through riscos_debug_status() exactly like any other pause - reason is 'exception' or 'error', with info giving {'label': ..., 'address': ...} (label is the exception name for 'exception', or the SWI name for 'error', which also adds 'errnum'/'errmess'). Neither needs disarming on riscos_debug_resume() the way a watchpoint/tracepoint does - the triggering condition doesn't repeat merely by resuming past it, so plain resume is enough.

Breakpoints are tracepoint sugar

Pyromaniac has no separate breakpoint mechanism. riscos_debug_breakpoint is implemented entirely as sugar over riscos_debug_tracepoint with actions='break'action='list' just filters tracepoints down to the ones tagged break. Anything documented above about tracepoints (including the resume-disarms-it behaviour) applies equally to breakpoints.

Response shapes

Most debugging tools return small dicts built from the underlying debug-*/watchpoints-*/tracepoints-*/switraps-* sysrq replies (dict-shaped replies parsed via parse_sysrq_dict(); list-shaped replies split on newlines). Notable shapes:

Tool Shape
riscos_debug_status() {paused, reason, pc, info}reason is one of 'user''watchpoint''tracepoint''switrap''step''exception''error', or None.
riscos_debug_registers() {r0..r15, pc, cpsr, spsr, flags, mode, paused}flags is an "NZCV"-style string with - for clear bits; mode is a name (USRSVC, ...).
riscos_debug_read_memory() {lines: [...]} - a formatted hex dump (address / hex bytes / ASCII text per line), not a raw byte string, since RISC OS memory can contain arbitrary binary data that wouldn't survive a text-based transport cleanly.
riscos_debug_disassemble() {lines: [...]} - pre-formatted text lines (address, instruction word, mnemonic), with live register/memory values annotated where relevant, not structured per-instruction JSON.
riscos_debug_watchpoint('list')riscos_debug_tracepoint('list')riscos_debug_switrap('list') {watchpoints: [...]}{tracepoints: [...]}{switraps: [...]}, each entry a dict of the value (address/pattern/SWI) and its actions string.
riscos_debug_option('list') {options: {name: bool, ...}} - every registered debug option and its current state.
riscos_debug_option('enable'\|'disable'\|'toggle', name) {ok: bool, 'old'|'new': bool|None}'old' for enable/disable'new' for toggleNone/ok: False means name isn't a registered option.
riscos_debug_trace() {lines: [...], dropped: N} - the most recent captured trace output lines, and how many older lines were dropped when the capture buffer filled.

Comparison with RPCEmu Extended's MCP debug tools

Pyromaniac's debugging tools are inspired by (and named to loosely match) the riscos_debug_* tool set in RPCEmu Extended's MCP server (rpcemu-extended/tools/mcp/), but the two aren't wire-compatible - they sit on genuinely different architectures (a sysrq-based control channel here, vs. RPCEmu's dedicated DebugCmd socket) and diverge where that difference matters:

Aspect RPCEmu Extended Pyromaniac
Registers Hex strings for every field Registers as sysrq-native ints; flags/mode decoded separately
Memory read One concatenated hex string Formatted hex dump (see above - a deliberate divergence)
Pause Asynchronous/deferred, reason enum Same shape, same idea
Breakpoints Own address-list mechanism Sugar over tracepoints (no separate mechanism)
Watchpoint actions Halt-or-log-only Combinable actions (reporttracetraceontraceoffbreak)
Backtrace Not provided riscos_backtrace() - no RPCEmu equivalent to match
Single-step riscos_debug_step(), always instruction-granularity riscos_debug_step(mode, count) - adds explicit 'into'/'over' SWI semantics and a count for multi-instruction requests in one call