jsbeeb-mcp
v4.3.0
Published
MCP server for jsbeeb — lets AI assistants control a headless BBC Micro emulator
Maintainers
Readme
jsbeeb-mcp
An MCP (Model Context Protocol) server that exposes jsbeeb, a headless emulator of the BBC Micro and the Acorn Atom, to AI assistants (Claude, Cursor, etc.).
Write a BASIC program, run it, get the text output and a screenshot — all without opening a browser.
Quick start — no install needed
npx jsbeeb-mcpThat's it. npx downloads and runs the server on demand.
Connecting to your LLM
Claude Desktop
Add to your config file and restart Claude:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"jsbeeb": {
"command": "npx",
"args": ["jsbeeb-mcp"]
}
}
}Claude Code (CLI)
claude mcp add jsbeeb -- npx jsbeeb-mcpOr add directly in .claude/settings.json (project-level) or
~/.claude/settings.json (global):
{
"mcpServers": {
"jsbeeb": {
"command": "npx",
"args": ["jsbeeb-mcp"]
}
}
}Cursor
Add to .cursor/mcp.json in your project root or ~/.cursor/mcp.json globally:
{
"mcpServers": {
"jsbeeb": {
"command": "npx",
"args": ["jsbeeb-mcp"]
}
}
}VS Code (GitHub Copilot)
Add to .vscode/mcp.json in your project:
{
"servers": {
"jsbeeb": {
"type": "stdio",
"command": "npx",
"args": ["jsbeeb-mcp"]
}
}
}Anything else that speaks MCP
Run the server and point your client at it:
npx jsbeeb-mcp
# server speaks JSON-RPC over stdioTools
One-shot convenience tools (no session management needed)
run_basic
Boot a BBC Micro, load a BASIC program, run it, return text output and an optional screenshot, then clean up.
{
"source": "10 PRINT \"HELLO WORLD\"\n20 GOTO 10",
"model": "B-DFS1.2",
"timeout_secs": 10,
"screenshot": true
}run_disc
Boot a BBC Micro, load a disc image, autoboot it (SHIFT+BREAK), return text
output and an optional screenshot, then clean up. The disc is a file on the
machine running the server (image_path), or a reference the way jsbeeb's own
URLs name one (image_ref): sth:Acornsoft/Elite.zip for a Stairway to Hell disc,
hfe:<path> for one from the BBC disc archive, bitshifters:<path> for one
of the Bitshifters' demos, github:owner/repo[@ref]/path for a file in a GitHub
repo (@~latest takes the tag of its latest release), or an http(s): or
file: URL. Zips are opened and the disc inside used.
{
"image_path": "/path/to/game.ssd",
"model": "B-DFS1.2",
"timeout_secs": 30,
"screenshot": true
}{
"image_ref": "sth:Acornsoft/Elite.zip",
"model": "B-DFS1.2",
"timeout_secs": 30
}Session-based tools
For multi-step interaction (debugging, iterative development):
| Tool | Description |
| ------------------ | -------------------------------------------------------------- |
| create_machine | Boot any machine jsbeeb has (BBC B, Master 128, Acorn Atom), returns a session_id; optional tube attaches a 65C02 second processor, and econet a network with a file server a Master can log on to (station_id sets the station number), and video_nula a VideoNuLA |
| destroy_machine | Free a session |
| load_basic | Tokenise + load BBC BASIC source into PAGE |
| type_input | Type text at the current keyboard prompt, then one RETURN unless the text ends in a newline |
| run_until_prompt | Run until BASIC/OS prompt, return captured screen text |
| screenshot | Capture the last fully-painted frame as a PNG image, plus the frame counter |
| read_memory | Read bytes from the memory map (with hex dump), reporting which bank was paged; bank / shadow pick another |
| write_memory | Poke bytes into memory, into a chosen bank or shadow RAM if asked |
| read_registers | Get 6502 CPU registers (PC, A, X, Y, S, P), the frame counter and elapsed cycles |
| run_for_cycles | Run N CPU cycles, or up to a breakpoint; reports cycles_run, the count actually run (drains output by default — use clear: false to peek without consuming) |
| run_frames | Advance N painted frames — use this, not run_for_cycles, to step the display |
| run_until_address | Run until the PC reaches an address (in one sideways bank if given), or timeout_secs pass; reports reached, stopped_reason and the registers |
| start_coverage | Start counting every instruction address the host CPU executes |
| stop_coverage | Stop counting and summarise: stretches of code run, by bank, and the hottest addresses |
| load_disc | Put a disc in drive 0 or 1, from a file, an archive or a URL |
| load_tape | Put a .uef tape (or a zip holding one) in the cassette recorder and press PLAY, from a file or a URL |
| key_down | Press and hold a key, by the machine's name for it (SHIFT, A, RETURN, F0, COLON_STAR) or by BBC internal key number, INKEY number, or matrix col/row |
| key_up | Release a held key, named the same ways |
| keyboard_state | What is held (name, matrix position, internal key number) and whether typing is pending |
| release_all_keys | Release everything and drop typing an interrupted type_input left pending |
| flush_keyboard_buffer | Throw away keys the OS has buffered but not yet read |
| reset | Reset the machine; with autoboot: true, holds SHIFT during reset (SHIFT+BREAK) |
| boot_disc | Load a disc image (file, archive or URL) and autoboot it (SHIFT+BREAK) |
| save_state | Snapshot the whole machine server-side, returns a state_id |
| restore_state | Put a session back to a saved state (same model, tube, Econet, station and VideoNuLA only) |
| list_states | List saved states, newest first |
| delete_state | Discard a saved state and free its memory |
Running to an address
run_until_address is a one-off execute breakpoint: it runs until the PC
reaches address, stopping before that instruction runs, or gives up after
timeout_secs of emulated time (30 by default). bank makes it stop only while
that sideways bank is paged in, as set_breakpoint does. The response says
whether it got there (reached) and why it stopped: address, timeout, or
breakpoint when a breakpoint set with set_breakpoint fired first, with the
registers, cycles_run and the text output. An execute breakpoint on address
itself does not change that: the response is reached: true and address,
with the breakpoint attached. A hit an earlier call left unreported comes back
first as pending_breakpoint, with cycles_run 0 and nothing run. The next
run starts with the instruction it stopped at.
Code coverage
start_coverage counts every instruction the host CPU executes, at whatever
address and, in &8000–&BFFF, in whichever sideways bank (or on a Master,
ANDY) was paged in, while any tool runs the machine. stop_coverage stops
counting and returns a summary rather than the counts:
instructions_executedandunique_addressesranges: each stretch of code run, from the first to the last instruction address in it (addresses no more than 3 bytes apart, the longest 6502 instruction, count as one stretch), with itsbank(0 to 15, orandy), how many addresses ran and how many times. Pastmax_ranges(100) only the busiest are listed, andranges_totalsays how many there werehot_spots: themax_hot_spots(20) addresses run most often
Starting again discards the counts so far; restore_state, reset and
boot_disc do not, so counting carries on across attempts. destroy_machine
stops counting along with the machine. On a Master, code run from HAZEL or
shadow RAM is counted at its plain address. An instruction is counted just
before it runs, so one the machine is stopped at is already in the counts even
if stop_coverage comes before it runs, and is counted again when a state
saved at that stop is restored and run on.
Paged memory
read_memory and save_memory read whatever the machine has paged in, so
every response carries paging: romsel, the sideways bank at &8000–&BFFF,
and on a Master acccon, whose bit 2 puts shadow RAM at &3000–&7FFF. To
sample a particular bank regardless, pass bank (0–15) or, on a Master,
shadow (true for shadow RAM, false for main); the map is put back afterwards.
write_memory takes the same two.
Checkpointing with save_state
Booting is the slow part of a session, so snapshot a prepared machine once and restore it between attempts rather than creating a new one each time:
create_machine → (load disc, set things up) → save_state → { state_id }
↳ try something → restore_state → try something else → restore_state → ...The snapshot covers CPU, RAM, sideways RAM, video, sound chip, discs and tube.
Memory, registers and the cycle count rewind on restore; breakpoints and the
frame counter deliberately carry on. So does the tape: a state saved before a
CHAIN"" restores with the tape already past the program, so load_tape it
again to read it a second time. Econet's file server isn't in the snapshot
either, so a Master restored from one may need to log on again.
A state saved where an execute breakpoint or run_until_address stopped the
machine runs on past that instruction when next run, as the machine it came from
would have. Read and write breakpoints stop after their instruction has run, so
a state saved at one, like any other state, stops at an execute breakpoint on
its PC straight away.
Snapshots stay on the server and only their IDs cross the connection, so they
are cheap to pass around but must be freed with delete_state when finished —
they outlive the session they came from. The server holds at most 100 of them
(roughly 0.4MB each) and refuses a further save_state rather than evicting one
you may be about to restore. That is also what lets one state seed
several machines: restore_state accepts any session of the same model,
fitted the same way (tube, Econet, station number and VideoNuLA), so a single starting
point can be run forward in parallel.
Composable keyboard control
The key_down, key_up, and reset tools are low-level primitives that can be
composed for full manual control. For example, to autoboot a disc:
key_down SHIFT → reset → run_for_cycles (1s) → key_up SHIFT → run_until_promptOr use reset with autoboot: true / boot_disc / run_disc for common cases.
Both report shift_held_at_reset, so a boot that lands in the tape filing
system can be told apart from a disc that does not autoboot.
Games read the keyboard matrix directly rather than through the OS, by internal
key number (what OSBYTE 121 takes). key_down and key_up accept internal,
a negative inkey number, or col and row, so a test can press exactly the
key the game's constant names; the response gives the name and numbers of the
matrix key that moved, which also measures what a name maps to.
A breakpoint that fires part way through type_input leaves the rest of the
text still to be typed, and the typist keeps the keyboard until it is. Until
then key_down and key_up refuse rather than dropping the key; reset and
boot_disc drop the pending text themselves. keyboard_state shows what is
held and whether typing is pending, and release_all_keys clears both.
Key names are the machine's own, the same ones keyboard_state reports,
and every press lands on the keyboard matrix directly with no host keyboard
layout in between. On a BBC: SHIFT, CTRL, RETURN, SPACE, DELETE,
ESCAPE, TAB, CAPSLOCK, SHIFTLOCK, COPY, UP, DOWN, LEFT,
RIGHT, F0–F9, A–Z, K0–K9 for the digits, the engraved
punctuation keys (COMMA, PERIOD, SLASH, MINUS, SEMICOLON_PLUS,
COLON_STAR, AT, LEFT_SQUARE_BRACKET, RIGHT_SQUARE_BRACKET,
UNDERSCORE_POUND, HAT_TILDE, PIPE_BACKSLASH) and the Master's numpad
(NUMPAD0–NUMPAD9, NUMPADENTER, etc.). The Atom has its own set (REPT,
LOCK, COPY, MINUS_EQUALS, etc.); an unknown name is refused with the
full list for the model.
What works
- ✅ BBC BASIC programs (tokenised and loaded directly into memory)
- ✅ Text output capture (position, colour, mode)
- ✅ Screenshots (real Video chip output → PNG via
sharp) - ✅ Memory read/write
- ✅ CPU register inspection
- ✅ BBC B (8271 and 1770, and a stock B with no sideways RAM) and Master 128 models, booting DFS, ADFS or (with
econet) ANFS, and the Acorn Atom - ✅ Multiple concurrent sessions
- ✅ Econet, with a file server a Master can log on to (
*NET, then*I AM SYST); a BBC B gets the network hardware but no NFS ROM - ✅ Whole-machine state snapshots (checkpoint once, restore between attempts)
- ✅ Disc image loading and autoboot:
.ssd/.dsd/.adffiles and zips, discs from the Stairway to Hell and BBC disc archives, the Bitshifters' demos,github:repos and anyhttp(s):URL, into either drive - ✅ Tapes:
.ueffiles and zips, from a file or a URL - ✅ Low-level keyboard control (key_down/key_up)
- ✅ Breakpoints on execute, read or write, optionally only while a given sideways bank is paged in
- ✅ Running to an address, and code coverage by address and sideways bank
Known limitations
- Boot text: the VDU capture hook is installed after the initial boot
completes, so the OS startup banner isn't captured. Everything after the
first
>prompt is captured. - No assembler built in: to run machine code, poke it via
write_memoryandCALLit from BASIC, or use the BBC's own inline assembler in BASIC. - Sound: the sound chip runs but produces no audio output (headless mode).
Architecture
server.js # MCP server — tool definitions, session store
examples/ # Standalone scripts demonstrating MachineSession directlyMachineSession lives in jsbeeb itself (src/machine-session.js) and is
imported from the package's root, jsbeeb. It wraps jsbeeb's TestMachine
with a real Video instance (full video chip into a 1024×625 RGBA
framebuffer), VDU text capture, and screenshot support via sharp.
Framebuffer snapshots are taken inside the paint_ext vsync callback (before
the buffer is cleared), ensuring screenshots always show a complete frame. The
same callback drives run_frames, which halts the CPU on the paint itself
rather than after a cycle count — a frame is 40000 cycles with interlace on but
39936 with it off, so a fixed cycle step drifts against the display and makes a
sprite caught mid-redraw look like a bug in the program under test.
