jsbeeb-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +109 -0
  2. package/package.json +33 -0
  3. package/server.js +407 -0
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # jsbeeb-mcp
2
+
3
+ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that
4
+ exposes a headless [BBC Micro emulator](https://github.com/mattgodbolt/jsbeeb)
5
+ to AI assistants (Claude, Cursor, etc.).
6
+
7
+ Write a BASIC program, run it, get the text output and a screenshot — all
8
+ without opening a browser.
9
+
10
+ ## Setup
11
+
12
+ ```bash
13
+ npm install
14
+ ```
15
+
16
+ ## Running
17
+
18
+ ```bash
19
+ # via npx (no install needed)
20
+ npx jsbeeb-mcp
21
+
22
+ # or if installed globally
23
+ npm install -g jsbeeb-mcp
24
+ jsbeeb-mcp
25
+ ```
26
+
27
+ ## Connecting to Claude Desktop
28
+
29
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "jsbeeb": {
35
+ "command": "npx",
36
+ "args": ["jsbeeb-mcp"]
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ ## Tools
43
+
44
+ ### `run_basic` _(convenience — no session management needed)_
45
+
46
+ One-shot: boot a BBC Micro, load a BASIC program, run it, return text output
47
+ and an optional screenshot, then clean up.
48
+
49
+ ```json
50
+ {
51
+ "source": "10 PRINT \"HELLO WORLD\"\n20 GOTO 10",
52
+ "model": "B-DFS1.2",
53
+ "timeout_secs": 10,
54
+ "screenshot": true
55
+ }
56
+ ```
57
+
58
+ ### Session-based tools
59
+
60
+ For multi-step interaction (debugging, iterative development):
61
+
62
+ | Tool | Description |
63
+ | ------------------ | -------------------------------------------------------------- |
64
+ | `create_machine` | Boot a BBC Micro (B or Master), returns a `session_id` |
65
+ | `destroy_machine` | Free a session |
66
+ | `load_basic` | Tokenise + load BBC BASIC source into PAGE |
67
+ | `type_input` | Type text at the current keyboard prompt (RETURN is automatic) |
68
+ | `run_until_prompt` | Run until BASIC/OS prompt, return captured screen text |
69
+ | `screenshot` | Capture the current screen as a PNG image |
70
+ | `read_memory` | Read bytes from the memory map (with hex dump) |
71
+ | `write_memory` | Poke bytes into memory |
72
+ | `read_registers` | Get 6502 CPU registers (PC, A, X, Y, S, P) |
73
+ | `run_for_cycles` | Run exactly N 2MHz CPU cycles |
74
+ | `load_disc` | Load an `.ssd`/`.dsd` disc image into drive 0 |
75
+
76
+ ## What works
77
+
78
+ - ✅ BBC BASIC programs (tokenised and loaded directly into memory)
79
+ - ✅ Text output capture (position, colour, mode)
80
+ - ✅ Screenshots (real Video chip output → PNG via `sharp`)
81
+ - ✅ Memory read/write
82
+ - ✅ CPU register inspection
83
+ - ✅ BBC B and Master models
84
+ - ✅ Multiple concurrent sessions
85
+ - ✅ Disc image loading (`.ssd`/`.dsd`)
86
+
87
+ ## Known limitations
88
+
89
+ - **Boot text**: the VDU capture hook is installed after the initial boot
90
+ completes, so the OS startup banner isn't captured. Everything after the
91
+ first `>` prompt is captured.
92
+ - **No assembler built in**: to run machine code, poke it via `write_memory`
93
+ and `CALL` it from BASIC, or use the BBC's own inline assembler in BASIC.
94
+ - **Sound**: the sound chip runs but produces no audio output (headless mode).
95
+
96
+ ## Architecture
97
+
98
+ ```
99
+ server.js # MCP server — tool definitions, session store
100
+ examples/ # Standalone scripts demonstrating MachineSession directly
101
+ ```
102
+
103
+ `MachineSession` lives in jsbeeb itself (`src/machine-session.js`) and is
104
+ imported here as `jsbeeb/machine-session`. It wraps jsbeeb's `TestMachine`
105
+ with a real `Video` instance (full video chip into a 1024×625 RGBA
106
+ framebuffer), VDU text capture, and screenshot support via `sharp`.
107
+
108
+ Framebuffer snapshots are taken inside the `paint_ext` vsync callback (before
109
+ the buffer is cleared), ensuring screenshots always show a complete frame.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "jsbeeb-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for jsbeeb — lets AI assistants control a headless BBC Micro emulator",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "jsbeeb-mcp": "./server.js"
9
+ },
10
+ "files": [
11
+ "server.js",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node server.js",
16
+ "test": "node test-mcp-client.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.26.0",
23
+ "jsbeeb": "^1.3.2",
24
+ "sharp": "^0.34.5"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/mattgodbolt/jsbeeb-mcp.git"
29
+ },
30
+ "keywords": ["bbc-micro", "mcp", "emulator", "jsbeeb", "ai"],
31
+ "author": "Matt Godbolt",
32
+ "license": "GPL-3.0-or-later"
33
+ }
package/server.js ADDED
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * jsbeeb MCP Server
4
+ *
5
+ * Exposes a headless BBC Micro emulator to AI assistants via the Model
6
+ * Context Protocol. Start it with:
7
+ *
8
+ * node server.js
9
+ *
10
+ * and connect it from Claude Desktop, Cursor, or any MCP-compatible client
11
+ * by adding it to mcp_servers in the client config.
12
+ *
13
+ * Capabilities:
14
+ * - Boot a BBC B or BBC Master
15
+ * - Load and run BBC BASIC programs
16
+ * - Type at the keyboard
17
+ * - Capture text output
18
+ * - Take screenshots (PNG, base64-encoded)
19
+ * - Read/write memory
20
+ * - Inspect CPU registers
21
+ * - Persistent sessions (multiple machines at once)
22
+ * - One-shot `run_basic` convenience tool (no session management needed)
23
+ */
24
+
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
27
+ import { z } from "zod";
28
+ import { MachineSession } from "jsbeeb/machine-session";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Session store
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const sessions = new Map(); // sessionId → MachineSession
35
+
36
+ function requireSession(sessionId) {
37
+ const s = sessions.get(sessionId);
38
+ if (!s) throw new Error(`No session with id "${sessionId}". Call create_machine first.`);
39
+ return s;
40
+ }
41
+
42
+ function newSessionId() {
43
+ return crypto.randomUUID();
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // MCP server
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const server = new McpServer({
51
+ name: "jsbeeb",
52
+ version: "0.1.0",
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Tool: create_machine
57
+ // ---------------------------------------------------------------------------
58
+
59
+ server.tool(
60
+ "create_machine",
61
+ "Boot a BBC Micro emulator and return a session ID for use with all other tools. " +
62
+ "The machine runs until the BASIC prompt before this call returns.",
63
+ {
64
+ model: z
65
+ .enum(["B-DFS1.2", "B-DFS2.26", "Master", "Master-MOS3.20"])
66
+ .default("B-DFS1.2")
67
+ .describe("BBC Micro model to emulate"),
68
+ boot_timeout_secs: z.number().default(30).describe("Max seconds of emulated time to wait for the boot prompt"),
69
+ },
70
+ async ({ model, boot_timeout_secs }) => {
71
+ const session = new MachineSession(model);
72
+ await session.initialise();
73
+ const bootOutput = await session.boot(boot_timeout_secs);
74
+ const id = newSessionId();
75
+ sessions.set(id, session);
76
+ return {
77
+ content: [
78
+ {
79
+ type: "text",
80
+ text: JSON.stringify({
81
+ session_id: id,
82
+ model,
83
+ boot_output: bootOutput,
84
+ }),
85
+ },
86
+ ],
87
+ };
88
+ },
89
+ );
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Tool: destroy_machine
93
+ // ---------------------------------------------------------------------------
94
+
95
+ server.tool(
96
+ "destroy_machine",
97
+ "Destroy a BBC Micro session and free its resources.",
98
+ { session_id: z.string().describe("Session ID from create_machine") },
99
+ async ({ session_id }) => {
100
+ const s = sessions.get(session_id);
101
+ if (s) {
102
+ s.destroy();
103
+ sessions.delete(session_id);
104
+ }
105
+ return { content: [{ type: "text", text: "Session destroyed." }] };
106
+ },
107
+ );
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Tool: load_disc
111
+ // ---------------------------------------------------------------------------
112
+
113
+ server.tool(
114
+ "load_disc",
115
+ "Insert a disc image (.ssd or .dsd file) into drive 0 of the emulator. " +
116
+ "After loading, use type_input to issue DFS commands (e.g. '*RUN hello', '*DIR', 'CHAIN\"\"').",
117
+ {
118
+ session_id: z.string().describe("Session ID from create_machine"),
119
+ image_path: z.string().describe("Absolute path to an .ssd or .dsd disc image file"),
120
+ },
121
+ async ({ session_id, image_path }) => {
122
+ const session = requireSession(session_id);
123
+ await session.loadDisc(image_path);
124
+ return { content: [{ type: "text", text: `Disc image loaded: ${image_path}` }] };
125
+ },
126
+ );
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Tool: load_basic
130
+ // ---------------------------------------------------------------------------
131
+
132
+ server.tool(
133
+ "load_basic",
134
+ "Tokenise BBC BASIC source code and load it into the emulator's PAGE memory, " +
135
+ "exactly as if you had typed it in and saved it. Does NOT run the program.",
136
+ {
137
+ session_id: z.string().describe("Session ID from create_machine"),
138
+ source: z.string().describe("BBC BASIC source code (plain text, BBC dialect)"),
139
+ },
140
+ async ({ session_id, source }) => {
141
+ const session = requireSession(session_id);
142
+ await session.loadBasic(source);
143
+ return { content: [{ type: "text", text: "BASIC program loaded into PAGE." }] };
144
+ },
145
+ );
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Tool: type_input
149
+ // ---------------------------------------------------------------------------
150
+
151
+ server.tool(
152
+ "type_input",
153
+ "Type text at the current keyboard prompt (simulates key presses). " +
154
+ "A newline/RETURN is automatically sent after the text. " +
155
+ "Use run_until_prompt after this to collect output.",
156
+ {
157
+ session_id: z.string().describe("Session ID from create_machine"),
158
+ text: z.string().describe("Text to type (e.g. 'RUN' or '10 PRINT\"HELLO\"')"),
159
+ },
160
+ async ({ session_id, text }) => {
161
+ const session = requireSession(session_id);
162
+ await session.type(text);
163
+ return { content: [{ type: "text", text: `Typed: ${text}` }] };
164
+ },
165
+ );
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Tool: run_until_prompt
169
+ // ---------------------------------------------------------------------------
170
+
171
+ server.tool(
172
+ "run_until_prompt",
173
+ "Run the emulator until it returns to a keyboard input prompt (e.g. the BASIC prompt after RUN completes). " +
174
+ "Returns all text output that was written to the screen since the last call.",
175
+ {
176
+ session_id: z.string().describe("Session ID from create_machine"),
177
+ timeout_secs: z.number().default(60).describe("Max emulated seconds to run before giving up"),
178
+ clear: z
179
+ .boolean()
180
+ .default(true)
181
+ .describe(
182
+ "If true (default), clear the output buffer after returning it. " +
183
+ "Pass false to peek at accumulated output without consuming it.",
184
+ ),
185
+ },
186
+ async ({ session_id, timeout_secs, clear }) => {
187
+ const session = requireSession(session_id);
188
+ const output = await session.runUntilPrompt(timeout_secs, { clear });
189
+ return {
190
+ content: [
191
+ {
192
+ type: "text",
193
+ text: JSON.stringify(output),
194
+ },
195
+ ],
196
+ };
197
+ },
198
+ );
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Tool: screenshot
202
+ // ---------------------------------------------------------------------------
203
+
204
+ server.tool(
205
+ "screenshot",
206
+ "Capture the current BBC Micro screen as a PNG image. " +
207
+ "Returns a base64-encoded PNG of the full 1024×625 emulated display. " +
208
+ "Tip: call run_until_prompt first to let the screen settle.",
209
+ {
210
+ session_id: z.string().describe("Session ID from create_machine"),
211
+ active_only: z
212
+ .boolean()
213
+ .default(true)
214
+ .describe("If true, crop to the active display area and apply 2× pixel scaling for clarity"),
215
+ },
216
+ async ({ session_id, active_only }) => {
217
+ const session = requireSession(session_id);
218
+ const png = active_only ? await session.screenshotActive() : await session.screenshot();
219
+ return {
220
+ content: [
221
+ {
222
+ type: "image",
223
+ data: png.toString("base64"),
224
+ mimeType: "image/png",
225
+ },
226
+ ],
227
+ };
228
+ },
229
+ );
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Tool: read_memory
233
+ // ---------------------------------------------------------------------------
234
+
235
+ server.tool(
236
+ "read_memory",
237
+ "Read bytes from the BBC Micro's memory map. " + "Returns an array of decimal byte values plus a hex dump.",
238
+ {
239
+ session_id: z.string().describe("Session ID from create_machine"),
240
+ address: z.number().int().min(0).max(0xffff).describe("Start address (0–65535)"),
241
+ length: z.number().int().min(1).max(256).default(16).describe("Number of bytes to read (max 256)"),
242
+ },
243
+ async ({ session_id, address, length }) => {
244
+ const session = requireSession(session_id);
245
+ const bytes = session.readMemory(address, length);
246
+ const hexDump = formatHexDump(address, bytes);
247
+ return {
248
+ content: [
249
+ {
250
+ type: "text",
251
+ text: JSON.stringify({
252
+ address,
253
+ addressHex: `0x${address.toString(16).toUpperCase()}`,
254
+ bytes,
255
+ hexDump,
256
+ }),
257
+ },
258
+ ],
259
+ };
260
+ },
261
+ );
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Tool: write_memory
265
+ // ---------------------------------------------------------------------------
266
+
267
+ server.tool(
268
+ "write_memory",
269
+ "Write bytes into the BBC Micro's memory. " +
270
+ "Useful for poking machine code, modifying variables, or patching running programs.",
271
+ {
272
+ session_id: z.string().describe("Session ID from create_machine"),
273
+ address: z.number().int().min(0).max(0xffff).describe("Start address (0–65535)"),
274
+ bytes: z.array(z.number().int().min(0).max(255)).describe("Array of byte values to write"),
275
+ },
276
+ async ({ session_id, address, bytes }) => {
277
+ const session = requireSession(session_id);
278
+ session.writeMemory(address, bytes);
279
+ return {
280
+ content: [
281
+ {
282
+ type: "text",
283
+ text: `Wrote ${bytes.length} byte(s) at 0x${address.toString(16).toUpperCase()}.`,
284
+ },
285
+ ],
286
+ };
287
+ },
288
+ );
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Tool: read_registers
292
+ // ---------------------------------------------------------------------------
293
+
294
+ server.tool(
295
+ "read_registers",
296
+ "Read the current 6502 CPU register state (PC, A, X, Y, stack pointer, processor status).",
297
+ { session_id: z.string().describe("Session ID from create_machine") },
298
+ async ({ session_id }) => {
299
+ const session = requireSession(session_id);
300
+ const regs = session.registers();
301
+ return { content: [{ type: "text", text: JSON.stringify(regs) }] };
302
+ },
303
+ );
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Tool: run_for_cycles
307
+ // ---------------------------------------------------------------------------
308
+
309
+ server.tool(
310
+ "run_for_cycles",
311
+ "Run the emulator for an exact number of 2MHz CPU cycles. " +
312
+ "Useful for precise timing, or just to advance the clock a bit between interactions.",
313
+ {
314
+ session_id: z.string().describe("Session ID from create_machine"),
315
+ cycles: z.number().int().min(1).describe("Number of 2MHz CPU cycles to execute"),
316
+ clear: z
317
+ .boolean()
318
+ .default(true)
319
+ .describe("If true (default), clear the output buffer after returning it. Pass false to peek."),
320
+ },
321
+ async ({ session_id, cycles, clear }) => {
322
+ const session = requireSession(session_id);
323
+ await session.runFor(cycles);
324
+ const output = session.drainOutput({ clear });
325
+ return {
326
+ content: [
327
+ {
328
+ type: "text",
329
+ text: JSON.stringify({ cycles_run: cycles, output }),
330
+ },
331
+ ],
332
+ };
333
+ },
334
+ );
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Tool: run_basic (convenience: one-shot, no session management needed)
338
+ // ---------------------------------------------------------------------------
339
+
340
+ server.tool(
341
+ "run_basic",
342
+ "One-shot convenience tool: boot a BBC Micro, load a BASIC program, run it, " +
343
+ "return all text output and a screenshot, then destroy the session. " +
344
+ "Perfect for quickly trying out ideas without managing sessions.",
345
+ {
346
+ source: z.string().describe("BBC BASIC source code to run"),
347
+ model: z.enum(["B-DFS1.2", "Master"]).default("B-DFS1.2").describe("BBC Micro model"),
348
+ timeout_secs: z.number().default(30).describe("Max emulated seconds to allow the program to run"),
349
+ screenshot: z.boolean().default(true).describe("Include a screenshot of the final screen state"),
350
+ },
351
+ async ({ source, model, timeout_secs, screenshot: wantScreenshot }) => {
352
+ const session = new MachineSession(model);
353
+ try {
354
+ await session.initialise();
355
+ await session.boot(30);
356
+ await session.loadBasic(source);
357
+ await session.type("RUN");
358
+ const output = await session.runUntilPrompt(timeout_secs);
359
+
360
+ const result = { output };
361
+
362
+ if (wantScreenshot) {
363
+ const png = await session.screenshotActive();
364
+ return {
365
+ content: [
366
+ { type: "text", text: JSON.stringify(result) },
367
+ { type: "image", data: png.toString("base64"), mimeType: "image/png" },
368
+ ],
369
+ };
370
+ }
371
+
372
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
373
+ } finally {
374
+ session.destroy();
375
+ }
376
+ },
377
+ );
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Helpers
381
+ // ---------------------------------------------------------------------------
382
+
383
+ function formatHexDump(startAddr, bytes) {
384
+ const lines = [];
385
+ for (let i = 0; i < bytes.length; i += 16) {
386
+ const chunk = bytes.slice(i, i + 16);
387
+ const addr = (startAddr + i).toString(16).toUpperCase().padStart(4, "0");
388
+ const hex = chunk.map((b) => b.toString(16).toUpperCase().padStart(2, "0")).join(" ");
389
+ const ascii = chunk.map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".")).join("");
390
+ lines.push(`${addr} ${hex.padEnd(47)} |${ascii}|`);
391
+ }
392
+ return lines.join("\n");
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Start server
397
+ // ---------------------------------------------------------------------------
398
+
399
+ async function main() {
400
+ const transport = new StdioServerTransport();
401
+ await server.connect(transport);
402
+ }
403
+
404
+ main().catch((err) => {
405
+ console.error("Failed to start jsbeeb MCP server:", err);
406
+ process.exit(1);
407
+ });