MCP
Every Emu198x binary is an MCP server as well as an emulator. Start one with --mcp and it stops being a window you watch: another program boots the machine, runs frames, presses keys, reads a chip register, and writes out a PNG, without knowing anything about that machine's timing loop.
MCP is the Model Context Protocol — a convention for offering a set of callable tools to whatever is on the other end of a pipe. The client sends JSON-RPC frames on standard input, one per line, and the server answers on standard output. A client can ask what a server offers and then call it, without being taught the server's internals first. That is the whole idea, and everything below is a shell pipeline you can paste and run.
The mental model
- The core stays hardware-specific. A Spectrum is still a Z80 and a ULA, a C64 is still a 6510 and a VIC-II. Nothing is flattened into a common machine to make automation tidier.
- The server is a thin adapter. It translates calls into control and inspection against the core, and holds no emulation logic of its own.
- Observability is path-based. A machine publishes named paths —
cpu.pc,vic.raster_line,ppu.scanline— and a caller asks which ones exist before reading any of them. One script can therefore work across machines that share no chips at all.
What works today
All 30 machines take --mcp. The server speaks three JSON-RPC methods — initialize, tools/list and tools/call — and it accepts the notifications/initialized note a client sends after the handshake. Everything the emulator can do arrives as a tool call.
Each machine registers the same core of 32 tools, then adds whatever its hardware justifies. The Spectrum's server lists 48 tools; the Atari 2600's lists the core 32 and no more.
| Need | Tools |
|---|---|
| Advance execution | run_frames, run_ticks, step, run_until_pc, run_until_any_pc, run_until_line, run_until_mem_change |
| Wait for a state | wait_for_boot, wait_for_query_contains, wait_for_query_bool |
| Inspect | query_paths, query, query_cpu, memory_read, disasm, io_trace |
| Change memory | poke_byte, poke_word |
| Media and state | load_media, media_transport, save_snapshot, load_snapshot |
| Capture | save_screenshot, start_audio_recording, stop_audio_recording, save_audio_capture, start_video_recording, stop_video_recording |
| Input | input |
| Source-level debugging | load_debug_info, debug_symbol |
| Reset | reset |
What a machine adds on top follows its hardware. Machines with keyboards add press_key, press_keys and type_string. The NES adds dump_nametable, dump_oam and dump_palette. The C64 adds disk write-back and BASIC program loading; the Spectrum adds Z80 port access, tape autoloading, and watch logs for memory and AY register writes. Ask tools/list and you get the machine's own set, each entry carrying a JSON Schema for its arguments.
Starting a server
From an unpacked release archive:
./emu198x-spectrum --mcpOr from a source checkout:
cargo run --release -p emu198x-spectrum -- --mcpNothing prints. The server is waiting on standard input, and it boots the machine before the first call arrives.
To hand a machine to an agent instead, most MCP clients take a server list in this shape. One entry per machine you want to drive; the name on the left is yours to choose.
{
"mcpServers": {
"spectrum": {
"command": "/path/to/emu198x-spectrum",
"args": ["--mcp"]
}
}
}Driving a machine
Here is a whole session as a shell pipeline: boot a Spectrum, wait for it to finish, press a key, read the text screen back, and save a screenshot. Each line is one JSON-RPC frame.
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"wait_for_boot","arguments":{"max_frames":300}}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"press_key","arguments":{"key":"P"}}}' \
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_frames","arguments":{"frames":25}}}' \
'{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"query","arguments":{"path":"screen.text.lines"}}}' \
'{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"save_screenshot","arguments":{"path":"boot.png"}}}' \
| ./emu198x-spectrum --mcpSix replies come back on standard output. Trimmed to the part that matters, they read:
{"kind":"wait_for_boot","frames":87,"reason":"found copyright banner on row 23","row":23}
{"kind":"press_key","key":"P","hold_frames":3,"reached":25439232}
{"kind":"run_frames","frames":25,"reached":32428032,"stop_reason":"ReachedTarget"}
{"kind":"query","result":{"path":"screen.text.lines","value":[ ... ,"PRINT L "]}}Every step is answered with what the machine did, not with a promise that it did something. wait_for_boot reports the frame it stopped on and why it believed the machine had booted. press_key holds the key for three frames and releases it, the way a finger does. Pressing P on a 48K Spectrum at the BASIC prompt gives you the whole PRINT keyword, which is what the screen read back says — the trailing L is the cursor. And boot.png is on disk beside the binary.
That is the shape of every session. Advance the machine, read state back, decide what to do next from what you read. Waiting for a condition beats sleeping for an arbitrary number of frames, which is why wait_for_boot, wait_for_query_contains and run_until_pc exist.
Ask the machine what it exposes
The stable pattern for inspection is two calls. Ask what paths exist, pick one, read it. Pass a prefix so the answer stays short:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"query_paths","arguments":{"prefix":"cpu."}}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query","arguments":{"path":"cpu.pc"}}}' \
| ./emu198x-spectrum --mcpThe Spectrum answers that prefix with sixteen paths, cpu.af through cpu.sp, including cpu.iff1, cpu.im and cpu.instructions_retired. Four prefixes are on every machine: cpu., run., session. and capture.. Past those, the namespace is the hardware.
| Machine | Paths | Some of what it publishes |
|---|---|---|
| ZX Spectrum | 65 | screen., basic., tape., keyboard., boot. |
| Commodore 64 | 164 | vic., cia1., cia2., drive8., iec., memory. |
| NES | 83 | ppu., apu., mapper., sprites., cartridge. |
| MSX1 | 42 | vdp., ay., ppi., bios. |
| Atari 2600 | 39 | tia., input., cartridge. |
Read raw memory with memory_read instead — it takes an address and a length and reads the CPU bus with no side effects, which a query path is the wrong shape for.
What you need that we cannot give you
Two things, and neither ships with the emulator.
The machine's firmware. A server boots its machine before the first call arrives, so it needs the same ROMs a window needs, found the same way. The getting started page covers where each machine looks. A machine that cannot find its firmware fails at startup rather than at the first call, which is the easier failure to read.
A client that speaks stdio JSON-RPC. The server talks over standard input and output, one JSON object per line. Any MCP client can drive it, and so can a shell pipeline — the examples above are exactly that, and they are the shortest way to see whether a machine works before wiring an agent to it.
What does not work yet
- No breakpoints. There is no way to set one, list one, or attach a condition such as
a == 0to an address. What exists instead is stepping to a target:run_until_pc,run_until_any_pc,run_until_lineandrun_until_mem_changeall run whole instructions until a condition holds or a step budget runs out. That covers most of what a breakpoint is used for, and none of what a conditional one is. - Nothing is pushed. The server answers calls and never speaks first, so there are no
frame_completeor error notifications to subscribe to. A client that wants to know when something happened has to run frames and look. - Tools only. The server advertises no MCP resources and no prompts, and
resources/listcomes back as a method that does not exist. Everything is reached by calling a tool. - Standard input and output only. There is no network transport, so a client has to be able to start the binary as a child process. Driving a machine on another host is not covered.
- The surface is uneven across machines. A tool that a machine has not reached is either absent from its
tools/listor returns an error when called. Where that happens, the path-basedqueryflow is the fallback: it is on every machine, and it reports what it cannot resolve instead of guessing. - Some arguments are not in the schema. The
inputtool takes an array, and the shape of the events inside it is not written down anywhere — a wrong one is found by being rejected. Media slot names are better off: a slot has a name (tape-1on the Spectrum,cartridge-1on the NES) and the schema does not say so, butsession.profile.media_slots.idslists the names a machine has, so that one is a query rather than a guess. --mcpis missing from--help. The flag works on every machine and the help text does not mention it, so anyone who goes looking for the automation surface where a binary lists its flags will not find it.