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:
- The command server (
pyro-server/pyro-client) - a persistent RISC OS environment you can send*command lines to and get streamed output and a return code back. - The sysrq server - a line-based protocol for discrete, out-of-band operations (filesystem, input, config, reset/terminate, ...) independent of running a command.
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.py. PYROMCP_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 list, claude 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
Servers → View 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: Return, Escape, Tab, Delete/Backspace, Left, Right, Up, Down (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 (trace, traceblock, traceswi, ...) - 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:
riscos_debug_pause()is asynchronous: it returns immediately, but the actual halt only takes effect the next time the emulation loop reaches its check (typically very soon - a SWI call, exception, timer tick, or abreak-tagged hit - but not necessarily the very next instruction). Pollriscos_debug_status()to confirm the CPU has genuinely stopped before trusting that registers/memory reflect a fully paused state.- A
break-tagged watchpoint or address-based tracepoint halts the CPU before it has advanced past the instruction that triggered it - a watchpoint's memory hook fires as the access completes, a tracepoint's code hook fires before the instruction executes. Simply callingriscos_debug_resume()would therefore re-trigger on the exact same access, forever. To avoid this,riscos_debug_resume()removes a watchpoint or address-based tracepoint that caused the pause as part of resuming - callriscos_debug_watchpoint('add', ...)/riscos_debug_tracepoint('add', ...)again to re-arm it for the next hit. Pattern-based tracepoints (a function-name pattern rather than a literal address) are left alone, since the literal address that fired can't be matched back to the pattern that added it. - A
break-tagged SWI trap is different: it's triggered from plain SWI-dispatch code rather than an in-flight CPU hook, so resuming is genuine forward progress, not a same-instant re-trigger - it is not disarmed automatically, and fires again on every further call to the same SWI. Callriscos_debug_switrap('remove', swi)explicitly to stop that.
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:
'into'- every instruction is stepped one at a time, including the first instruction of any real ARM code a SWI ends up invoking (a claimed vector, a callback, ...).'over'- a SWI call runs to completion as a single atomic step, regardless of how much ARM code it invokes internally; only ARM instructions outside of SWI dispatch are stepped one at a time. This needs no return-address breakpoint and no special-casing forOS_WriteS(which advances the PC past an embedded string, not by a fixed amount) orOS_CallASWI/OS_CallASWIR12(which resolve their target SWI dynamically from a register) - by the time a SWI's Python handler returns, any real ARM code it invoked has already run to completion via its own recursiveexecute()/emulate()call, so the PC is already correct.
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:
breakexceptionhalts on a genuinely unclaimed CPU exception - a branch through zero (a call through a NULL pointer), a data abort, a prefetch abort, or an undefined instruction that nothing (not even the FP emulator) claimed. The halt happens at the point Pyromaniac's own fallback handling (RISCOS.exception_dispatch()) is about to raise the exception through to RISC OS's environment handler (DataAbortHandler,UndefinedHandler, ...) - so registers/memory reflect the exception having just been entered (abort-mode CPSR, LR pointing back at the faulting code,riscos_backtrace()from there), before that handler's own code runs, not instead of it: resuming lets it proceed normally. Deliberately does not fire on the earlier, raw point where an undefined-encoding instruction is first noticed (before the Kernel/FP emulator/any other vector claimant has had a chance to handle it) - breaking there would trip on every ordinary FP instruction, since the FP emulator's own instructions look identical to a genuinely undefined one until it has actually run.breakerrorhalts when any RISC OS error is generated by a genuine SWI call (an actual ARMSWIinstruction dispatched throughKernel.swi_dispatch()), regardless of X-bit hiding - both an X-bit-hidden error (which would otherwise just silently set the V flag and continue) and a propagating non-X error trigger it, sinceTrace.swi_leave()(where this is checked) runs either way. Halts before the error is delivered to a non-X caller'sErrorV/error environment handler. Only covers the SWI-dispatch path - a few internal Pyromaniac helpers used by pure-Python*commandimplementations (e.g.*Delete's "file not found") construct and raise aRISCOSErrordirectly without going throughKernel.swi_dispatch(), and aren't covered, since there's no X-bit distinction being made there in the first place.
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 (USR, SVC, ...). |
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 toggle; None/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 (report, trace, traceon, traceoff, break) |
| 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 |