From 2d96b99658ca7657e10e7c6787554c8d58efbc32 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sat, 31 Oct 2026 12:50:31 -0500 Subject: [PATCH 01/12] tools: the version banner parser also reads the machine and boot state The MCP server needs it too, so it moves where both can share it. --- tools/lib/trserial.mjs | 23 ++++------------- tools/lib/trserial.test.mjs | 29 +-------------------- tools/mcp/lib/banner.mjs | 36 ++++++++++++++++++++++++++ tools/mcp/test/banner.test.mjs | 47 ++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 tools/mcp/lib/banner.mjs create mode 100644 tools/mcp/test/banner.test.mjs diff --git a/tools/lib/trserial.mjs b/tools/lib/trserial.mjs index 9e83f1a2d..c259f6e23 100644 --- a/tools/lib/trserial.mjs +++ b/tools/lib/trserial.mjs @@ -9,8 +9,11 @@ import { spawnSync, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { parseBanner } from '../mcp/lib/banner.mjs'; import { defaultArduinoDataDir } from './toolchain.mjs'; +export { boardKind, parseBanner } from '../mcp/lib/banner.mjs'; + // A Teensyduino tool from the newest installed teensy-tools, or null when it is // not installed. Windows builds carry an .exe suffix. export function teensyTool(name) { @@ -127,27 +130,11 @@ function exchangeWindows(port, send, ms) { return result.stdout ?? ''; } -// TeensyROM answers 'dv' (VersionInfoToken) with a banner that names the build -// and the chip ("UID: 14470230", burned in at the factory, so it tells two -// boards of the same type apart). Returns { version, built, uid, minimal, raw } -// with nulls for anything that did not appear. `minimal` means MinimalBoot answered -// ("FW: TeensyROM v0.8.0.11(minimal)"). It stays in charge, instead of handing +// The board's answer to 'dv' (VersionInfoToken), parsed by parseBanner. +// `minimal` means MinimalBoot answered. It stays in charge, instead of handing // over to the main firmware, to run a large cartridge image, and the build it // names is its own. export function identify(port, ms = 3000) { return parseBanner(exchange(port, '\x64\x76', ms)); } -export function parseBanner(raw) { - const version = raw.match(/TeensyROM\+? v[\d.]+/)?.[0] ?? null; - const built = raw.match(/([A-Z][a-z]{2} [ \d]\d \d{4}), (\d\d:\d\d:\d\d)/); - const uid = raw.match(/UID:\s*(\d+)/)?.[1] ?? null; - return { version, built: built ? `${built[1]}, ${built[2]}` : null, uid, minimal: /\(minimal\)/.test(raw), raw }; -} - -// Which cartridge a banner belongs to. The '+' is the whole distinction, and -// it is the thing that decides whether a given hex may be written at all. -export function boardKind(version) { - if (!version) return null; - return version.includes('TeensyROM+') ? 'tr-plus' : 'tr'; -} diff --git a/tools/lib/trserial.test.mjs b/tools/lib/trserial.test.mjs index e8031bd53..1eb1afe12 100644 --- a/tools/lib/trserial.test.mjs +++ b/tools/lib/trserial.test.mjs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import test from 'node:test'; import assert from 'node:assert/strict'; -import { parsePortListing, parseBanner, boardKind } from './trserial.mjs'; +import { parsePortListing } from './trserial.mjs'; test('every board in a teensy_ports listing is kept, with the address teensy_post_compile needs', () => { const boards = parsePortListing( @@ -26,30 +26,3 @@ test('a board in the bootloader has no port, and a /dev path is a port', () => { test('an empty listing is no boards', () => { assert.deepEqual(parsePortListing(''), []); }); - -// As captured from a plain TeensyROM on 2026-09-27, and as Min_SerUSBIO.ino prints it. -const FULL = '\xcc\x64\n FW: TeensyROM v0.8.0.11\r\n Sep 25 2026, 03:03:51\r\n Teensy: 816MHz 62.1C UID: 14470230\r\n' + - ' C64 PAL Vid 60 Hz\n Boot: complete\n'; -const MINIMAL = '\n FW: TeensyROM+ v0.8.0.11(minimal)\r\n Aug 5 2026, 10:41:55\r\n Teensy: 816MHz 55.0C UID: 19277260\r\n'; - -test('the version reply gives the build, its date and the chip ID', () => { - const { raw, ...info } = parseBanner(FULL); - assert.deepEqual(info, { version: 'TeensyROM v0.8.0.11', built: 'Sep 25 2026, 03:03:51', uid: '14470230', minimal: false }); -}); - -test('MinimalBoot answering is told apart from the full firmware', () => { - const { raw, ...info } = parseBanner(MINIMAL); - assert.deepEqual(info, { version: 'TeensyROM+ v0.8.0.11', built: 'Aug 5 2026, 10:41:55', uid: '19277260', minimal: true }); - assert.equal(boardKind(info.version), 'tr-plus'); -}); - -test('no answer is all nulls', () => { - const { raw, ...info } = parseBanner(''); - assert.deepEqual(info, { version: null, built: null, uid: null, minimal: false }); -}); - -test('the + in the version is what tells the cartridges apart', () => { - assert.equal(boardKind('TeensyROM+ v0.8.0.11'), 'tr-plus'); - assert.equal(boardKind('TeensyROM v0.8.0.11'), 'tr'); - assert.equal(boardKind(null), null); -}); diff --git a/tools/mcp/lib/banner.mjs b/tools/mcp/lib/banner.mjs new file mode 100644 index 000000000..a4eaaba4d --- /dev/null +++ b/tools/mcp/lib/banner.mjs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// +// The banner a TeensyROM answers VersionInfoToken with: +// +// FW: TeensyROM+ v0.8.0.11 "(minimal)" follows when MinimalBoot answers +// Sep 25 2026, 03:03:51 +// Teensy: 816MHz 62.1C UID: 14470230 +// C128 NTSC Vid 60 Hz main image only +// Boot: complete main image only +// +// The UID is burned in at the factory, so it tells two boards of one type apart. + +export function parseBanner(raw) { + const version = raw.match(/TeensyROM\+? v[\d.]+/)?.[0] ?? null; + const built = raw.match(/([A-Z][a-z]{2} [ \d]\d \d{4}), (\d\d:\d\d:\d\d)/); + const uid = raw.match(/UID:\s*(\d+)/)?.[1] ?? null; + const machine = raw.match(/^\s*(C64|C128)\s+(NTSC|PAL)\b/m); + const boot = raw.match(/Boot: (complete|in progress)/)?.[1] ?? null; + return { + version, + built: built ? `${built[1]}, ${built[2]}` : null, + uid, + minimal: /\(minimal\)/.test(raw), + machine: machine?.[1] ?? null, + video: machine?.[2] ?? null, + boot, + raw, + }; +} + +// The '+' is the whole distinction between the cartridges, and it decides +// whether a given hex may be written at all. +export function boardKind(version) { + if (!version) return null; + return version.includes('TeensyROM+') ? 'tr-plus' : 'tr'; +} diff --git a/tools/mcp/test/banner.test.mjs b/tools/mcp/test/banner.test.mjs new file mode 100644 index 000000000..5ad7f4d3f --- /dev/null +++ b/tools/mcp/test/banner.test.mjs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { boardKind, parseBanner } from '../lib/banner.mjs'; + +// As captured from a plain TeensyROM on 2026-09-27, and as Min_SerUSBIO.ino prints it. +const FULL = '\xcc\x64\n FW: TeensyROM v0.8.0.11\r\n Sep 25 2026, 03:03:51\r\n Teensy: 816MHz 62.1C UID: 14470230\r\n' + + ' C64 PAL Vid 60 Hz\n Boot: complete\n'; +const MINIMAL = '\n FW: TeensyROM+ v0.8.0.11(minimal)\r\n Aug 5 2026, 10:41:55\r\n Teensy: 816MHz 55.0C UID: 19277260\r\n'; + +test('the main image\'s reply gives the build, the chip ID, the machine and the boot state', () => { + const { raw, ...info } = parseBanner(FULL); + assert.deepEqual(info, { + version: 'TeensyROM v0.8.0.11', built: 'Sep 25 2026, 03:03:51', uid: '14470230', minimal: false, + machine: 'C64', video: 'PAL', boot: 'complete', + }); +}); + +test('a C128 still booting its menu says so', () => { + const info = parseBanner(' FW: TeensyROM+ v0.8.0.13\n C128 NTSC Vid 60 Hz\n Boot: in progress\n'); + assert.equal(info.machine, 'C128'); + assert.equal(info.video, 'NTSC'); + assert.equal(info.boot, 'in progress'); +}); + +test('MinimalBoot answering is told apart from the full firmware', () => { + const { raw, ...info } = parseBanner(MINIMAL); + assert.deepEqual(info, { + version: 'TeensyROM+ v0.8.0.11', built: 'Aug 5 2026, 10:41:55', uid: '19277260', minimal: true, + machine: null, video: null, boot: null, + }); + assert.equal(boardKind(info.version), 'tr-plus'); +}); + +test('no answer is all nulls', () => { + const { raw, ...info } = parseBanner(''); + assert.deepEqual(info, { + version: null, built: null, uid: null, minimal: false, machine: null, video: null, boot: null, + }); +}); + +test('the + in the version is what tells the cartridges apart', () => { + assert.equal(boardKind('TeensyROM+ v0.8.0.11'), 'tr-plus'); + assert.equal(boardKind('TeensyROM v0.8.0.11'), 'tr'); + assert.equal(boardKind(null), null); +}); From 69ff9d254e277cad3d57d51bb813a34992908eff Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sat, 31 Oct 2026 12:50:32 -0500 Subject: [PATCH 02/12] tools: an MCP server for driving a TeensyROM from an AI agent Status, directory listing, file push, launch, reset and a DMA read of the 40-column screen, over USB serial or the TCP listener. Zero-dependency Node; the folder is also a Claude Code plugin. --- tools/mcp/.claude-plugin/plugin.json | 24 ++++ tools/mcp/.mcp.json | 12 ++ tools/mcp/README.md | 53 ++++++++ tools/mcp/lib/mcp.mjs | 85 +++++++++++++ tools/mcp/lib/protocol.mjs | 74 +++++++++++ tools/mcp/lib/screen.mjs | 74 +++++++++++ tools/mcp/lib/tools.mjs | 177 +++++++++++++++++++++++++++ tools/mcp/lib/transport.mjs | 175 ++++++++++++++++++++++++++ tools/mcp/lib/trlink.mjs | 145 ++++++++++++++++++++++ tools/mcp/server.mjs | 18 +++ tools/mcp/test/fake-board.mjs | 123 +++++++++++++++++++ tools/mcp/test/protocol.test.mjs | 43 +++++++ tools/mcp/test/screen.test.mjs | 33 +++++ tools/mcp/test/server.test.mjs | 67 ++++++++++ tools/mcp/test/tools.test.mjs | 83 +++++++++++++ tools/mcp/test/transport.test.mjs | 44 +++++++ 16 files changed, 1230 insertions(+) create mode 100644 tools/mcp/.claude-plugin/plugin.json create mode 100644 tools/mcp/.mcp.json create mode 100644 tools/mcp/README.md create mode 100644 tools/mcp/lib/mcp.mjs create mode 100644 tools/mcp/lib/protocol.mjs create mode 100644 tools/mcp/lib/screen.mjs create mode 100644 tools/mcp/lib/tools.mjs create mode 100644 tools/mcp/lib/transport.mjs create mode 100644 tools/mcp/lib/trlink.mjs create mode 100644 tools/mcp/server.mjs create mode 100644 tools/mcp/test/fake-board.mjs create mode 100644 tools/mcp/test/protocol.test.mjs create mode 100644 tools/mcp/test/screen.test.mjs create mode 100644 tools/mcp/test/server.test.mjs create mode 100644 tools/mcp/test/tools.test.mjs create mode 100644 tools/mcp/test/transport.test.mjs diff --git a/tools/mcp/.claude-plugin/plugin.json b/tools/mcp/.claude-plugin/plugin.json new file mode 100644 index 000000000..9144ed779 --- /dev/null +++ b/tools/mcp/.claude-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "teensyrom", + "displayName": "TeensyROM", + "version": "0.1.0", + "description": "Drive a TeensyROM cartridge in a Commodore 64 or 128: check its state, list and push files, launch them, reset, and read the screen", + "author": { "name": "TeensyROM contributors" }, + "repository": "https://github.com/SensoriumEmbedded/TeensyROM", + "license": "MIT", + "keywords": ["c64", "c128", "commodore", "teensyrom", "retro"], + "userConfig": { + "tr_host": { + "type": "string", + "title": "TeensyROM address", + "description": "IP address or host name of a TeensyROM with its TCP listener on (host, or host:port). Leave empty to use USB serial.", + "default": "" + }, + "tr_port": { + "type": "string", + "title": "Serial port", + "description": "USB serial device of the TeensyROM, such as /dev/cu.usbmodem2101. Leave empty to use the first one found.", + "default": "" + } + } +} diff --git a/tools/mcp/.mcp.json b/tools/mcp/.mcp.json new file mode 100644 index 000000000..f26a38c1d --- /dev/null +++ b/tools/mcp/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "teensyrom": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server.mjs"], + "env": { + "TR_HOST": "${user_config.tr_host}", + "TR_PORT": "${user_config.tr_port}" + } + } + } +} diff --git a/tools/mcp/README.md b/tools/mcp/README.md new file mode 100644 index 000000000..be3f313d4 --- /dev/null +++ b/tools/mcp/README.md @@ -0,0 +1,53 @@ +# TeensyROM MCP server + +An [MCP](https://modelcontextprotocol.io) server that lets an AI agent (Claude Code, Claude Desktop, or any MCP client) drive a TeensyROM cartridge in a Commodore 64 or 128. Needs Node 24 and nothing else: no `npm install`. + +## Tools + +| Tool | What it does | Needs | +|---|---|---| +| `status` | Which image is running (main, minimal, none), firmware version, board type, host machine, whether the menu has booted, whether DMA is available | any image but an extension | +| `list_dir` | Lists a directory on the SD card, USB drive or built-in menu, with paging | main image, C64 at the menu | +| `read_screen` | The 40-column text screen as 25 lines of text, read by DMA | TR+, main image | +| `push_file` | Copies a file from this computer to the SD card or USB drive, replacing one already there | main image, C64 at the menu | +| `launch` | Runs a file on the C64 (PRG, CRT, SID, D64, …) | main or minimal image | +| `reset` | Resets the C64 to the TeensyROM menu | main or minimal image | + +A command the board can't take right now (for example `list_dir` while a game runs) comes back as a tool error carrying the board's own words, usually `Busy!`. `reset` gets the board back to the menu. + +`read_screen` does not read the C128 80-column screen. Avoid it while a C128 runs at 2 MHz (GEOS 128 in 80 columns), because a DMA read can hang the machine. + +## Connecting to the board + +Each tool call opens its own connection and closes it, so other TeensyROM apps can use the board between calls. + +- **USB serial** (the default): the first `/dev/cu.usbmodem*` or `/dev/ttyACM*` port, or the one in `TR_PORT`. macOS and Linux only for now. +- **TCP**: set `TR_HOST` to the board's address (`192.168.1.50`, or `192.168.1.50:2112`). Turn on "Enable TCP Listener" in the TeensyROM settings and power-cycle first. The board takes one TCP client at a time, and the ASID player turns the listener off. + +## Install + +### As a Claude Code plugin + +This folder is a plugin. Until it is in a marketplace, load it from a checkout: + +```sh +claude --plugin-dir tools/mcp +``` + +The plugin asks for `tr_host` and `tr_port`; leave both empty to use the first USB serial port. + +### In any MCP client + +Add a stdio server that runs `node tools/mcp/server.mjs`, with `TR_HOST` or `TR_PORT` in its environment as needed. For Claude Code without the plugin: + +```sh +claude mcp add teensyrom -- node /path/to/TeensyROM/tools/mcp/server.mjs +``` + +## Development + +```sh +node --test tools/mcp/test/*.test.mjs +``` + +The tests run against a fake board on a local TCP port (`test/fake-board.mjs`). `test/protocol.test.mjs` checks every token the server sends against the constants in `Source/Teensy`, so a firmware change to the protocol fails here. The wire protocol is in [docs/ControlComms.md](../../docs/ControlComms.md). diff --git a/tools/mcp/lib/mcp.mjs b/tools/mcp/lib/mcp.mjs new file mode 100644 index 000000000..f974ca594 --- /dev/null +++ b/tools/mcp/lib/mcp.mjs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +// +// A Model Context Protocol server over stdio: newline-delimited JSON-RPC 2.0 +// carrying the tools capability only. + +import readline from 'node:readline'; + +const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; +const PARSE_ERROR = -32700; + +function protocolVersionFor(requested) { + return SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[0]; +} + +function listing(tool) { + const { run, ...described } = tool; + return described; +} + +function toolResult(text, isError = false) { + return { content: [{ type: 'text', text }], isError }; +} + +async function callTool(tools, params) { + const tool = tools.find((candidate) => candidate.name === params?.name); + if (!tool) return toolResult(`no tool named '${params?.name}'`, true); + try { + return toolResult(await tool.run(params.arguments ?? {})); + } catch (error) { + return toolResult(error.message, true); + } +} + +function handlerFor(server) { + return { + initialize: (params) => ({ + protocolVersion: protocolVersionFor(params?.protocolVersion), + capabilities: { tools: {} }, + serverInfo: server.info, + instructions: server.instructions, + }), + ping: () => ({}), + 'tools/list': () => ({ tools: server.tools.map(listing) }), + 'tools/call': (params) => callTool(server.tools, params), + }; +} + +export function serveMcp(server, { input = process.stdin, output = process.stdout } = {}) { + const handlers = handlerFor(server); + const send = (message) => output.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`); + let queue = Promise.resolve(); + + async function answer(request) { + const handler = handlers[request.method]; + if (!handler) { + send({ id: request.id, error: { code: METHOD_NOT_FOUND, message: `unknown method ${request.method}` } }); + return; + } + try { + send({ id: request.id, result: await handler(request.params) }); + } catch (error) { + send({ id: request.id, error: { code: INVALID_PARAMS, message: error.message } }); + } + } + + function receive(line) { + if (!line.trim()) return; + let request; + try { + request = JSON.parse(line); + } catch { + send({ id: null, error: { code: PARSE_ERROR, message: 'not JSON' } }); + return; + } + if (request.id === undefined) return; + // The board answers one command at a time, so requests run in order. + queue = queue.then(() => answer(request)); + } + + const lines = readline.createInterface({ input }); + lines.on('line', receive); + return new Promise((resolve) => lines.on('close', () => queue.then(resolve))); +} diff --git a/tools/mcp/lib/protocol.mjs b/tools/mcp/lib/protocol.mjs new file mode 100644 index 000000000..5dc842f5b --- /dev/null +++ b/tools/mcp/lib/protocol.mjs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// +// The TeensyROM control protocol's wire values (docs/ControlComms.md). +// Commands and their integer fields go to the board most significant byte +// first; the board answers least significant byte first. + +export const TOKENS = { + LAUNCH_FILE: 0x6444, + RESET_C64: 0x64ee, + VERSION_INFO: 0x6476, + FW_CHECK: 0x64e0, + READ_C64_MEM: 0x64fd, + POST_FILE: 0x64bb, + DELETE_FILE: 0x64cf, + GET_DIR_NDJSON: 0x64de, + ACK: 0x64cc, + FAIL: 0x9b7f, + FW_FULL: 0x64e2, + FW_MINIMAL: 0x64e1, + DIR_START: 0x5a5a, + DIR_END: 0xa5a5, +}; + +export const DRIVES = { usb: 0, sd: 1, teensy: 2 }; + +export const IMAGES = { [TOKENS.FW_FULL]: 'main', [TOKENS.FW_MINIMAL]: 'minimal' }; + +// The firmware's own name for each wire value, so a test can check them +// against Source/Teensy. +export const FIRMWARE_NAMES = { + LaunchFileToken: TOKENS.LAUNCH_FILE, + ResetC64Token: TOKENS.RESET_C64, + VersionInfoToken: TOKENS.VERSION_INFO, + FWCheckToken: TOKENS.FW_CHECK, + ReadC64MemToken: TOKENS.READ_C64_MEM, + PostFileToken: TOKENS.POST_FILE, + DeleteFileToken: TOKENS.DELETE_FILE, + GetDirNDJSONToken: TOKENS.GET_DIR_NDJSON, + AckToken: TOKENS.ACK, + FailToken: TOKENS.FAIL, + FWFullToken: TOKENS.FW_FULL, + FWMinimalToken: TOKENS.FW_MINIMAL, + StartDirectoryListToken: TOKENS.DIR_START, + EndDirectoryListToken: TOKENS.DIR_END, + rmtUSBDrive: DRIVES.usb, + rmtSD: DRIVES.sd, + rmtTeensy: DRIVES.teensy, +}; + +export function toBoard(value, width = 2) { + const bytes = Buffer.alloc(width); + bytes.writeUIntBE(value, 0, width); + return bytes; +} + +export function fromBoard(bytes) { + return bytes.readUInt16LE(0); +} + +export function boardReply(value) { + const bytes = Buffer.alloc(2); + bytes.writeUInt16LE(value, 0); + return bytes; +} + +export function pathField(path) { + return Buffer.from(`${path}\0`, 'latin1'); +} + +export function checksum(data) { + let sum = 0; + for (const byte of data) sum = (sum + byte) & 0xffff; + return sum; +} diff --git a/tools/mcp/lib/screen.mjs b/tools/mcp/lib/screen.mjs new file mode 100644 index 000000000..913234351 --- /dev/null +++ b/tools/mcp/lib/screen.mjs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// +// The VIC-II text screen: where it is in memory, and its screen codes as text. + +export const VIC_MEMPTR = 0xd018; +export const CIA2_PORT_A = 0xdd00; +export const SCREEN_COLUMNS = 40; +export const SCREEN_ROWS = 25; +export const SCREEN_BYTES = SCREEN_COLUMNS * SCREEN_ROWS; + +const VIC_BANK_BYTES = 0x4000; +const SCREEN_STEP = 0x400; +const CHARSET_STEP = 0x800; +// The VIC sees the character ROM, not RAM, at these offsets of banks 0 and 2. +const ROM_UPPER_GRAPHICS = 0x1000; +const ROM_LOWER_UPPER = 0x1800; +const ROM_BANKS = [0, 2]; + +export const CHARSETS = { + lowerUpper: 'lower/uppercase', + upperGraphics: 'uppercase/graphics', + custom: 'custom (RAM)', +}; + +const REVERSE_VIDEO = 0x80; +const CODE_AT = 0; +const CODE_A = 1; +const CODE_Z = 26; +const CODE_SHIFT_A = 65; +const CODE_SHIFT_Z = 90; +const PUNCTUATION_FIRST = 0x20; +const PUNCTUATION_LAST = 0x3f; +const NO_LETTER = '.'; + +export function vicBank(cia2PortA) { + return 3 - (cia2PortA & 3); +} + +export function screenAddress(memptr, cia2PortA) { + return vicBank(cia2PortA) * VIC_BANK_BYTES + ((memptr >> 4) & 0xf) * SCREEN_STEP; +} + +export function charsetIn(memptr, cia2PortA) { + const offset = ((memptr >> 1) & 7) * CHARSET_STEP; + if (!ROM_BANKS.includes(vicBank(cia2PortA))) return CHARSETS.custom; + if (offset === ROM_LOWER_UPPER) return CHARSETS.lowerUpper; + if (offset === ROM_UPPER_GRAPHICS) return CHARSETS.upperGraphics; + return CHARSETS.custom; +} + +// Screen codes 1-26 and 65-90 are different letters in the two ROM sets. +// Anything with no letter in the set (graphics, line drawing) reads as '.'. +function codeAsText(screenCode, charset) { + const code = screenCode & ~REVERSE_VIDEO; + const lowerUpper = charset === CHARSETS.lowerUpper; + if (code === CODE_AT) return '@'; + if (code >= CODE_A && code <= CODE_Z) { + return String.fromCharCode(code - CODE_A + (lowerUpper ? 0x61 : 0x41)); + } + if (lowerUpper && code >= CODE_SHIFT_A && code <= CODE_SHIFT_Z) { + return String.fromCharCode(code - CODE_SHIFT_A + 0x41); + } + if (code >= PUNCTUATION_FIRST && code <= PUNCTUATION_LAST) return String.fromCharCode(code); + return NO_LETTER; +} + +export function screenRows(screen, charset) { + const rows = []; + for (let row = 0; row < SCREEN_ROWS; row += 1) { + const codes = screen.subarray(row * SCREEN_COLUMNS, (row + 1) * SCREEN_COLUMNS); + rows.push(Array.from(codes, (code) => codeAsText(code, charset)).join('')); + } + return rows; +} diff --git a/tools/mcp/lib/tools.mjs b/tools/mcp/lib/tools.mjs new file mode 100644 index 000000000..31da3a123 --- /dev/null +++ b/tools/mcp/lib/tools.mjs @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +// +// The MCP tools. Each call opens its own connection and closes it, so other +// TeensyROM apps can reach the board between calls: it takes one TCP client +// at a time, and a serial port opens in one place only. + +import fs from 'node:fs/promises'; + +import { boardKind, parseBanner } from './banner.mjs'; +import { CHARSETS, CIA2_PORT_A, SCREEN_BYTES, VIC_MEMPTR, charsetIn, screenAddress, screenRows } from './screen.mjs'; +import { driveNumber } from './trlink.mjs'; + +const DRIVE_PROPERTY = { + type: 'string', + enum: ['sd', 'usb', 'teensy'], + default: 'sd', + description: "Where the file is: 'sd' (SD card), 'usb' (USB drive) or 'teensy' (the built-in menu).", +}; +const DEFAULT_TAKE = 200; +const MAX_TAKE = 1000; + +const NOTHING_ANSWERED = 'Nothing answered the firmware check. An extension image runs with USB off and ' + + 'answers nothing; otherwise the board is hung or not connected.'; + +function describeStatus(image, banner) { + const lines = [`image: ${image}`]; + const fields = { + firmware: banner.version, + built: banner.built, + board: boardKind(banner.version), + machine: banner.machine && `${banner.machine} ${banner.video}`, + menu: banner.boot, + }; + for (const [name, value] of Object.entries(fields)) if (value) lines.push(`${name}: ${value}`); + lines.push(`dma: ${image === 'main' && boardKind(banner.version) === 'tr-plus' ? 'available' : 'not available'}`); + return lines.join('\n'); +} + +function describeEntry(entry) { + return entry.type === 'dir' ? `${entry.name}/` : `${entry.name} (${entry.size} bytes)`; +} + +async function status(link) { + const image = await link.fwCheck(); + if (!image) return NOTHING_ANSWERED; + return describeStatus(image, parseBanner(await link.version())); +} + +async function listDir(link, { path = '/', drive = 'sd', skip = 0, take = DEFAULT_TAKE }) { + const count = Math.min(take, MAX_TAKE); + const entries = await link.listDirectory(path, driveNumber(drive), { skip, take: count }); + if (entries.length === 0) return `${path} is empty${skip ? ` past entry ${skip}` : ''}`; + const more = entries.length === count ? `\n(first ${count} from entry ${skip}; ask again with skip=${skip + count})` : ''; + return entries.map(describeEntry).join('\n') + more; +} + +async function readScreen(link) { + const [memptr] = await link.readMemory(VIC_MEMPTR, 1); + const [cia2PortA] = await link.readMemory(CIA2_PORT_A, 1); + const address = screenAddress(memptr, cia2PortA); + const charset = charsetIn(memptr, cia2PortA); + const rows = screenRows(await link.readMemory(address, SCREEN_BYTES), charset); + const note = charset === CHARSETS.custom ? ' (decoded as uppercase/graphics)' : ''; + const header = `screen at $${address.toString(16).toUpperCase()}, charset ${charset}${note}`; + return [header, ...rows.map((row) => row.trimEnd())].join('\n'); +} + +async function pushFile(link, { local_path: localPath, remote_path: remotePath, drive = 'sd' }, readFile) { + const data = await readFile(localPath); + const number = driveNumber(drive); + // The firmware will not overwrite a file, and a missing one fails the delete. + await link.deleteFile(remotePath, number).catch(() => {}); + await link.postFile(data, remotePath, number); + return `pushed ${localPath} to ${drive}:${remotePath} (${data.length} bytes)`; +} + +async function launch(link, { path, drive = 'sd' }) { + await link.launch(path, driveNumber(drive)); + return `launched ${drive}:${path}`; +} + +async function reset(link) { + return (await link.reset()) || 'reset sent'; +} + +const DEFINITIONS = [ + { + name: 'status', + description: 'Which TeensyROM image is running (main, minimal, or none answering), its firmware version, ' + + 'board type (tr or tr-plus), the host machine (C64/C128, NTSC/PAL), whether the menu has finished ' + + "booting, and whether DMA memory access is available. Call this first, and after 'reset' until " + + "'menu: complete'.", + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true, openWorldHint: true }, + run: status, + }, + { + name: 'list_dir', + description: "List a directory on the TeensyROM's SD card, USB drive or built-in menu. Needs the main " + + 'image with the C64 at the TeensyROM menu; otherwise the board answers Busy!.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', default: '/', description: 'Directory to list, starting with /.' }, + drive: DRIVE_PROPERTY, + skip: { type: 'integer', minimum: 0, default: 0, description: 'Entries to skip, for paging.' }, + take: { type: 'integer', minimum: 1, maximum: MAX_TAKE, default: DEFAULT_TAKE }, + }, + }, + annotations: { readOnlyHint: true, openWorldHint: true }, + run: listDir, + }, + { + name: 'read_screen', + description: "The C64's 40-column text screen as 25 lines of text, read by DMA. Needs a TR+ running " + + 'its main image. Graphics characters read as ".". It does not read the C128 80-column screen. ' + + 'Avoid it while a C128 runs at 2 MHz (GEOS 128 in 80 columns): a DMA read can hang the machine.', + inputSchema: { type: 'object', properties: {} }, + annotations: { readOnlyHint: true, openWorldHint: true }, + run: readScreen, + }, + { + name: 'push_file', + description: "Copy a file from this computer to the TeensyROM's SD card or USB drive, replacing any file " + + 'already at that path. Missing directories are created. Needs the main image with the C64 at the menu.', + inputSchema: { + type: 'object', + properties: { + local_path: { type: 'string', description: 'Path of the file on this computer.' }, + remote_path: { type: 'string', description: 'Destination path on the cartridge, starting with /.' }, + drive: { ...DRIVE_PROPERTY, enum: ['sd', 'usb'] }, + }, + required: ['local_path', 'remote_path'], + }, + annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true }, + run: pushFile, + }, + { + name: 'launch', + description: 'Launch a file (PRG, CRT, SID, D64 and the other types the menu runs) on the C64, from the ' + + "SD card, USB drive or built-in menu. Ends whatever the C64 was running. A large CRT hands over to the " + + "minimal image, which answers only status, launch and reset.", + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Path of the file, starting with /.' }, + drive: DRIVE_PROPERTY, + }, + required: ['path'], + }, + annotations: { destructiveHint: false, openWorldHint: true }, + run: launch, + }, + { + name: 'reset', + description: "Reset the C64 to the TeensyROM menu, ending whatever it was running, and bring the board " + + "back to its main image. The menu takes from under a second to tens of seconds to boot: call 'status' " + + "until it reports 'menu: complete' before using the other tools.", + inputSchema: { type: 'object', properties: {} }, + annotations: { destructiveHint: false, openWorldHint: true }, + run: reset, + }, +]; + +export function createTools({ connect, readFile = fs.readFile }) { + return DEFINITIONS.map((definition) => ({ + ...definition, + run: async (args) => { + const link = await connect(); + try { + return await definition.run(link, args, readFile); + } finally { + link.close(); + } + }, + })); +} diff --git a/tools/mcp/lib/transport.mjs b/tools/mcp/lib/transport.mjs new file mode 100644 index 000000000..cd56a1fb0 --- /dev/null +++ b/tools/mcp/lib/transport.mjs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +// +// Byte streams to a TeensyROM: TCP to its listener, or its USB serial port. + +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import tty from 'node:tty'; + +export const TCP_PORT = 2112; +const CONNECT_TIMEOUT_MS = 5000; +// The board drops what arrives while its USB serial is still settling after +// an open; the bench scripts wait the same. +const SERIAL_SETTLE_MS = 400; +const SERIAL_DIR = '/dev'; +const SERIAL_NAMES = [/^cu\.usbmodem/, /^ttyACM/]; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export class ByteStream { + #buffered = Buffer.alloc(0); + #ended = false; + #wake = null; + #output; + #close; + + constructor(input, output, close) { + this.#output = output; + this.#close = close; + input.on('data', (chunk) => this.#arrive(chunk)); + for (const event of ['end', 'close', 'error']) input.on(event, () => this.#end()); + output.on('error', () => this.#end()); + } + + get ended() { + return this.#ended; + } + + write(data) { + if (this.#ended) return Promise.reject(new Error('the connection to the board closed')); + return new Promise((resolve, reject) => { + this.#output.write(data, (error) => (error ? reject(error) : resolve())); + }); + } + + async read(count, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (this.#buffered.length < count && !this.#ended && Date.now() < deadline) { + await this.#arrival(deadline - Date.now()); + } + return this.#take(Math.min(count, this.#buffered.length)); + } + + readUntil(marker, timeoutMs) { + return this.readUntilAny([marker], timeoutMs); + } + + async readUntilAny(markers, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (!this.#endOfFirst(markers) && !this.#ended && Date.now() < deadline) { + await this.#arrival(deadline - Date.now()); + } + const end = this.#endOfFirst(markers); + return end ? this.#take(end) : null; + } + + async readIdle({ firstMs, idleMs, totalMs }) { + const limit = Date.now() + totalMs; + let deadline = Date.now() + firstMs; + let seen = 0; + while (!this.#ended) { + if (this.#buffered.length > seen) { + seen = this.#buffered.length; + deadline = Date.now() + idleMs; + } + const until = Math.min(deadline, limit); + if (Date.now() >= until) break; + await this.#arrival(until - Date.now()); + } + return this.#take(this.#buffered.length); + } + + async drain(quietMs) { + await sleep(quietMs); + this.#take(this.#buffered.length); + } + + close() { + this.#end(); + this.#close(); + } + + #arrive(chunk) { + this.#buffered = Buffer.concat([this.#buffered, chunk]); + this.#wake?.(); + } + + #end() { + this.#ended = true; + this.#wake?.(); + } + + #endOfFirst(markers) { + const ends = markers.map((marker) => { + const at = this.#buffered.indexOf(marker); + return at < 0 ? Infinity : at + marker.length; + }); + const first = Math.min(...ends); + return first === Infinity ? 0 : first; + } + + #take(count) { + const taken = this.#buffered.subarray(0, count); + this.#buffered = this.#buffered.subarray(count); + return Buffer.from(taken); + } + + #arrival(ms) { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + this.#wake = null; + resolve(); + }; + const timer = setTimeout(done, Math.max(ms, 0)); + this.#wake = done; + }); + } +} + +export function openTcp(host, port = TCP_PORT) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host, port }); + const timer = setTimeout(() => fail(new Error(`no answer from ${host}:${port}`)), CONNECT_TIMEOUT_MS); + function fail(error) { + clearTimeout(timer); + socket.destroy(); + reject(error); + } + socket.once('error', fail); + socket.once('connect', () => { + clearTimeout(timer); + socket.off('error', fail); + socket.setNoDelay(true); + resolve(new ByteStream(socket, socket, () => socket.destroy())); + }); + }); +} + +export async function openSerial(device) { + if (process.platform === 'win32') { + throw new Error('USB serial is not supported on Windows yet; set TR_HOST to use the TCP listener'); + } + const readFd = fs.openSync(device, fs.constants.O_RDWR | fs.constants.O_NOCTTY); + if (!tty.isatty(readFd)) { + fs.closeSync(readFd); + throw new Error(`${device} is not a serial port`); + } + const writeFd = fs.openSync(device, fs.constants.O_RDWR | fs.constants.O_NOCTTY); + const input = new tty.ReadStream(readFd); + input.setRawMode(true); + const output = new tty.WriteStream(writeFd); + const stream = new ByteStream(input, output, () => { + input.destroy(); + output.destroy(); + }); + await sleep(SERIAL_SETTLE_MS); + return stream; +} + +export function findSerialPort(directory = SERIAL_DIR) { + const names = fs.readdirSync(directory).filter((name) => SERIAL_NAMES.some((pattern) => pattern.test(name))); + const first = names.sort()[0]; + return first ? path.join(directory, first) : null; +} diff --git a/tools/mcp/lib/trlink.mjs b/tools/mcp/lib/trlink.mjs new file mode 100644 index 000000000..56bf0f065 --- /dev/null +++ b/tools/mcp/lib/trlink.mjs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +// +// The commands a TeensyROM answers, over a ByteStream (see transport.mjs). +// The main image answers all of them. The minimal image answers reset, launch, +// version and the firmware check, and fails the rest with "Busy!". Reading C64 +// memory also needs a TR+ (Fab 0.4). + +import { + DRIVES, IMAGES, TOKENS, boardReply, checksum, fromBoard, pathField, toBoard, +} from './protocol.mjs'; +import { findSerialPort, openSerial, openTcp } from './transport.mjs'; + +const REPLY_BYTES = 2; +const REPLY_MS = 5000; +const DRAIN_MS = 300; +const FW_CHECK_MS = 3000; +const POST_DATA_MS = 180000; +const LISTING_MS = 120000; +const TEXT_REPLY = { firstMs: 5000, idleMs: 300, totalMs: 30000 }; +const FAIL_TEXT = { firstMs: 1000, idleMs: 100, totalMs: 2000 }; + +export class BoardError extends Error {} + +export function driveNumber(name) { + if (name in DRIVES) return DRIVES[name]; + throw new BoardError(`drive '${name}': use ${Object.keys(DRIVES).join(', ')}`); +} + +export class Link { + #stream; + + constructor(stream) { + this.#stream = stream; + } + + close() { + this.#stream.close(); + } + + async fwCheck() { + await this.#send(toBoard(TOKENS.FW_CHECK)); + const replies = Object.keys(IMAGES).map((token) => boardReply(Number(token))); + const seen = await this.#stream.readUntilAny(replies, FW_CHECK_MS); + return seen === null ? null : IMAGES[fromBoard(seen.subarray(-REPLY_BYTES))]; + } + + async version() { + await this.#send(toBoard(TOKENS.VERSION_INFO)); + await this.#ack('version'); + return (await this.#stream.readIdle(TEXT_REPLY)).toString('latin1'); + } + + async reset() { + await this.#send(toBoard(TOKENS.RESET_C64)); + return (await this.#stream.readIdle(TEXT_REPLY)).toString('latin1').trim(); + } + + async launch(path, drive) { + await this.#send(toBoard(TOKENS.LAUNCH_FILE)); + await this.#ack('launch'); + await this.#stream.write(Buffer.concat([Buffer.of(drive), pathField(path)])); + await this.#ack(`launch ${path}`); + } + + async readMemory(address, length) { + await this.#send(Buffer.concat([toBoard(TOKENS.READ_C64_MEM), toBoard(address), toBoard(length)])); + await this.#ack(`read of $${hex(address)}`); + const data = await this.#stream.read(length, REPLY_MS); + if (data.length !== length) { + throw new BoardError(`read of $${hex(address)}: got ${data.length} of ${length} bytes`); + } + return data; + } + + async deleteFile(path, drive) { + await this.#send(toBoard(TOKENS.DELETE_FILE)); + await this.#ack('delete'); + await this.#stream.write(Buffer.concat([Buffer.of(drive), pathField(path)])); + await this.#ack(`delete ${path}`); + } + + async postFile(data, path, drive) { + const header = Buffer.concat([toBoard(data.length, 4), toBoard(checksum(data)), Buffer.of(drive), pathField(path)]); + await this.#send(toBoard(TOKENS.POST_FILE)); + await this.#ack('post'); + await this.#stream.write(header); + await this.#ack(`post ${path}`); + await this.#stream.write(data); + await this.#ack(`post ${path} data`, POST_DATA_MS); + } + + async listDirectory(path, drive, { skip, take }) { + await this.#send(toBoard(TOKENS.GET_DIR_NDJSON)); + await this.#ack('directory'); + await this.#stream.write(Buffer.concat([Buffer.of(drive), toBoard(skip), toBoard(take), pathField(path)])); + await this.#ack(`directory ${path}`); + await this.#expect(TOKENS.DIR_START, `directory ${path}`); + const listing = await this.#stream.readUntil(boardReply(TOKENS.DIR_END), LISTING_MS); + if (listing === null) throw new BoardError(`the listing of ${path} stopped before its end marker`); + return listing.subarray(0, -REPLY_BYTES).toString('latin1').split(/\r?\n/) + .filter((line) => line.trim()).map((line) => JSON.parse(line)); + } + + async #send(data) { + await this.#stream.drain(DRAIN_MS); + await this.#stream.write(data); + } + + async #ack(what, timeoutMs = REPLY_MS) { + await this.#expect(TOKENS.ACK, what, timeoutMs); + } + + async #expect(token, what, timeoutMs = REPLY_MS) { + const reply = await this.#stream.read(REPLY_BYTES, timeoutMs); + if (reply.length < REPLY_BYTES) throw new BoardError(`${what}: no reply from the board`); + const value = fromBoard(reply); + if (value === token) return; + if (value === TOKENS.FAIL) { + const text = (await this.#stream.readIdle(FAIL_TEXT)).toString('latin1').trim(); + throw new BoardError(`${what}: the board refused it${text ? `: ${text}` : ''}`); + } + throw new BoardError(`${what}: unexpected reply 0x${value.toString(16)}`); + } +} + +function hex(value) { + return value.toString(16).toUpperCase().padStart(4, '0'); +} + +export function linkTarget(env) { + if (env.TR_HOST) { + const [host, port] = env.TR_HOST.split(':'); + return { kind: 'tcp', host, port: port ? Number(port) : undefined }; + } + const device = env.TR_PORT || findSerialPort(); + if (!device) { + throw new BoardError('no TeensyROM found: set TR_HOST to its address, or TR_PORT to its serial port'); + } + return { kind: 'serial', device }; +} + +export async function openLink(target) { + const stream = target.kind === 'tcp' ? await openTcp(target.host, target.port) : await openSerial(target.device); + return new Link(stream); +} diff --git a/tools/mcp/server.mjs b/tools/mcp/server.mjs new file mode 100644 index 000000000..e91f06226 --- /dev/null +++ b/tools/mcp/server.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// +// The TeensyROM MCP server. Reaches the board over TCP when TR_HOST is set +// (host or host:port), else over USB serial at TR_PORT or the first TeensyROM +// port found. + +import { serveMcp } from './lib/mcp.mjs'; +import { createTools } from './lib/tools.mjs'; +import { linkTarget, openLink } from './lib/trlink.mjs'; + +const SERVER_INFO = { name: 'teensyrom', version: '0.1.0' }; +const INSTRUCTIONS = 'Tools for a TeensyROM cartridge in a Commodore 64 or 128. Start with status. ' + + 'Most tools need the main image with the C64 at the TeensyROM menu; reset gets it there.'; + +const connect = () => openLink(linkTarget(process.env)); + +await serveMcp({ info: SERVER_INFO, instructions: INSTRUCTIONS, tools: createTools({ connect }) }); diff --git a/tools/mcp/test/fake-board.mjs b/tools/mcp/test/fake-board.mjs new file mode 100644 index 000000000..5ecc85550 --- /dev/null +++ b/tools/mcp/test/fake-board.mjs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +// +// A TeensyROM main image on a local TCP port, answering the commands the +// server sends as docs/ControlComms.md describes them. + +import net from 'node:net'; + +import { TOKENS, boardReply, checksum } from '../lib/protocol.mjs'; +import { ByteStream } from '../lib/transport.mjs'; + +const WAIT_MS = 2000; + +export const BANNER = '\n FW: TeensyROM+ v0.8.0.13\r\n Oct 30 2026, 10:00:00\r\n' + + ' Teensy: 816MHz 55.0C UID: 19277260\r\n C64 NTSC Vid 60 Hz\n Boot: complete\n'; + +async function readPath(stream) { + return (await stream.readUntil(Buffer.of(0), WAIT_MS)).subarray(0, -1).toString('latin1'); +} + +async function readNumber(stream, width) { + return (await stream.read(width, WAIT_MS)).readUIntBE(0, width); +} + +function reply(stream, token) { + return stream.write(boardReply(token)); +} + +async function refuse(stream, text) { + await stream.write(Buffer.concat([boardReply(TOKENS.FAIL), Buffer.from(`${text}\n`)])); +} + +function commandsFor(board) { + return { + [TOKENS.FW_CHECK]: (stream) => reply(stream, board.image === 'main' ? TOKENS.FW_FULL : TOKENS.FW_MINIMAL), + [TOKENS.VERSION_INFO]: async (stream) => { + await reply(stream, TOKENS.ACK); + await stream.write(Buffer.from(board.banner, 'latin1')); + }, + [TOKENS.RESET_C64]: (stream) => stream.write(Buffer.from('Reset cmd received\n')), + [TOKENS.READ_C64_MEM]: async (stream) => { + const address = await readNumber(stream, 2); + const length = await readNumber(stream, 2); + await reply(stream, TOKENS.ACK); + await stream.write(board.memory.subarray(address, address + length)); + }, + [TOKENS.LAUNCH_FILE]: async (stream) => { + await reply(stream, TOKENS.ACK); + const drive = (await stream.read(1, WAIT_MS))[0]; + board.launched.push({ drive, path: await readPath(stream) }); + await reply(stream, TOKENS.ACK); + }, + [TOKENS.DELETE_FILE]: async (stream) => { + await reply(stream, TOKENS.ACK); + await stream.read(1, WAIT_MS); + const path = await readPath(stream); + if (!board.files.delete(path)) return refuse(stream, 'File not found'); + return reply(stream, TOKENS.ACK); + }, + [TOKENS.POST_FILE]: async (stream) => { + await reply(stream, TOKENS.ACK); + const length = await readNumber(stream, 4); + const sum = await readNumber(stream, 2); + await stream.read(1, WAIT_MS); + const path = await readPath(stream); + if (board.files.has(path)) return refuse(stream, 'File exists'); + await reply(stream, TOKENS.ACK); + const data = await stream.read(length, WAIT_MS); + if (checksum(data) !== sum) return refuse(stream, 'Checksum mismatch'); + board.files.set(path, data); + return reply(stream, TOKENS.ACK); + }, + [TOKENS.GET_DIR_NDJSON]: async (stream) => { + await reply(stream, TOKENS.ACK); + await stream.read(1, WAIT_MS); + const skip = await readNumber(stream, 2); + const take = await readNumber(stream, 2); + await readPath(stream); + await reply(stream, TOKENS.ACK); + await reply(stream, TOKENS.DIR_START); + const lines = board.listing.slice(skip, skip + take).map((entry) => `${JSON.stringify(entry)}\r\n`); + await stream.write(Buffer.from(lines.join(''))); + await reply(stream, TOKENS.DIR_END); + }, + }; +} + +async function serve(board, socket) { + const stream = new ByteStream(socket, socket, () => socket.destroy()); + const commands = commandsFor(board); + while (!stream.ended) { + const token = await stream.read(2, 60000); + if (token.length < 2) break; + const command = commands[token.readUInt16BE(0)]; + if (board.busy && token.readUInt16BE(0) !== TOKENS.FW_CHECK) await refuse(stream, 'Busy!'); + else if (command) await command(stream); + } +} + +export async function startFakeBoard(overrides = {}) { + const board = { + image: 'main', + banner: BANNER, + busy: false, + memory: Buffer.alloc(0x10000, 0x20), + files: new Map(), + listing: [], + launched: [], + ...overrides, + }; + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + serve(board, socket); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + board.host = `127.0.0.1:${server.address().port}`; + board.stop = () => new Promise((resolve) => { + server.close(resolve); + for (const socket of sockets) socket.destroy(); + }); + return board; +} diff --git a/tools/mcp/test/protocol.test.mjs b/tools/mcp/test/protocol.test.mjs new file mode 100644 index 000000000..35dd31beb --- /dev/null +++ b/tools/mcp/test/protocol.test.mjs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { DRIVES, FIRMWARE_NAMES, TOKENS } from '../lib/protocol.mjs'; + +const SOURCE = path.resolve(import.meta.dirname, '../../../Source/Teensy'); +const DEFINITIONS = [ + /^\s*#define\s+(\w+)\s+(0x[0-9A-Fa-f]+)\b/gm, + /^\s*const\s+uint\d+_t\s+(\w+)\s*=\s*(0x[0-9A-Fa-f]+)\s*;/gm, + /^\s*(\w+)\s*=\s*(\d+)\s*,/gm, +]; + +function firmwareDefinitions() { + const found = new Map(); + const files = fs.readdirSync(SOURCE, { recursive: true }).filter((name) => /\.(h|c|cpp|ino)$/.test(name)); + for (const file of files) { + const text = fs.readFileSync(path.join(SOURCE, file), 'latin1'); + for (const pattern of DEFINITIONS) { + for (const [, name, value] of text.matchAll(pattern)) { + if (!found.has(name)) found.set(name, new Set()); + found.get(name).add(Number(value)); + } + } + } + return found; +} + +test('every wire value matches the constant the firmware defines for it', () => { + const firmware = firmwareDefinitions(); + const drifted = Object.entries(FIRMWARE_NAMES) + .filter(([name, ours]) => !firmware.has(name) || [...firmware.get(name)].some((theirs) => theirs !== ours)) + .map(([name, ours]) => `${name}: ours 0x${ours.toString(16)}, firmware ${[...(firmware.get(name) ?? [])]}`); + assert.deepEqual(drifted, []); +}); + +test('every token and drive this server sends is one the firmware check covers', () => { + const checked = new Set(Object.values(FIRMWARE_NAMES)); + const unchecked = Object.entries({ ...TOKENS, ...DRIVES }).filter(([, value]) => !checked.has(value)); + assert.deepEqual(unchecked, []); +}); diff --git a/tools/mcp/test/screen.test.mjs b/tools/mcp/test/screen.test.mjs new file mode 100644 index 000000000..3cdaacc0e --- /dev/null +++ b/tools/mcp/test/screen.test.mjs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { CHARSETS, SCREEN_BYTES, charsetIn, screenAddress, screenRows } from '../lib/screen.mjs'; + +const POWER_ON_MEMPTR = 0x15; +const MENU_MEMPTR = 0x17; +const BANK_0 = 0x97; +const BANK_3 = 0x94; + +test('the power-on screen is at $0400 in uppercase/graphics', () => { + assert.equal(screenAddress(POWER_ON_MEMPTR, BANK_0), 0x0400); + assert.equal(charsetIn(POWER_ON_MEMPTR, BANK_0), CHARSETS.upperGraphics); +}); + +test('the TeensyROM menu is at $0400 in lower/uppercase', () => { + assert.equal(screenAddress(MENU_MEMPTR, BANK_0), 0x0400); + assert.equal(charsetIn(MENU_MEMPTR, BANK_0), CHARSETS.lowerUpper); +}); + +test('a screen in VIC bank 3 is found there, and its charset can only be RAM', () => { + assert.equal(screenAddress(0x25, BANK_3), 0xc800); + assert.equal(charsetIn(0x25, BANK_3), CHARSETS.custom); +}); + +test('screen codes read as the letters of the selected set', () => { + const screen = Buffer.alloc(SCREEN_BYTES, 0x20); + screen.set([0x08, 0x09, 0x20, 0x48, 0x49, 0x2e, 0x31, 0x81, 0x66], 0); + assert.equal(screenRows(screen, CHARSETS.lowerUpper)[0].trimEnd(), 'hi HI.1a.'); + assert.equal(screenRows(screen, CHARSETS.upperGraphics)[0].trimEnd(), 'HI ...1A.'); + assert.equal(screenRows(screen, CHARSETS.upperGraphics).length, 25); +}); diff --git a/tools/mcp/test/server.test.mjs b/tools/mcp/test/server.test.mjs new file mode 100644 index 000000000..e2e9076b0 --- /dev/null +++ b/tools/mcp/test/server.test.mjs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import readline from 'node:readline'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { startFakeBoard } from './fake-board.mjs'; + +const SERVER = path.resolve(import.meta.dirname, '../server.mjs'); +const NOBODY_LISTENING = '127.0.0.1:1'; + +function startServer(t, env) { + const child = spawn(process.execPath, [SERVER], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'inherit'] }); + t.after(() => child.kill()); + const replies = readline.createInterface({ input: child.stdout })[Symbol.asyncIterator](); + let id = 0; + return { + async request(method, params) { + id += 1; + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + return JSON.parse((await replies.next()).value); + }, + notify(method) { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method })}\n`); + }, + }; +} + +test('a client can initialize, list the tools and call one over stdio', async (t) => { + const board = await startFakeBoard(); + t.after(board.stop); + const server = startServer(t, { TR_HOST: board.host }); + + const init = await server.request('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } }); + assert.equal(init.result.protocolVersion, '2025-06-18'); + assert.deepEqual(init.result.capabilities, { tools: {} }); + server.notify('notifications/initialized'); + + const listed = await server.request('tools/list'); + assert.deepEqual(listed.result.tools.map((tool) => tool.name), + ['status', 'list_dir', 'read_screen', 'push_file', 'launch', 'reset']); + assert.ok(listed.result.tools.every((tool) => tool.inputSchema.type === 'object' && !('run' in tool))); + + const called = await server.request('tools/call', { name: 'status', arguments: {} }); + assert.equal(called.result.isError, false); + assert.match(called.result.content[0].text, /^image: main/); +}); + +test('a client asking for a protocol version this server lacks is offered the newest it has', async (t) => { + const server = startServer(t, { TR_HOST: NOBODY_LISTENING }); + const init = await server.request('initialize', { protocolVersion: '1999-01-01' }); + assert.equal(init.result.protocolVersion, '2025-11-25'); +}); + +test('a board that cannot be reached is a tool error, not a protocol error', async (t) => { + const server = startServer(t, { TR_HOST: NOBODY_LISTENING }); + const called = await server.request('tools/call', { name: 'status', arguments: {} }); + assert.equal(called.result.isError, true); + assert.match(called.result.content[0].text, /ECONNREFUSED/); +}); + +test('an unknown method is a JSON-RPC error', async (t) => { + const server = startServer(t, { TR_HOST: NOBODY_LISTENING }); + const reply = await server.request('resources/list'); + assert.equal(reply.error.code, -32601); +}); diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs new file mode 100644 index 000000000..a015353d7 --- /dev/null +++ b/tools/mcp/test/tools.test.mjs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createTools } from '../lib/tools.mjs'; +import { linkTarget, openLink } from '../lib/trlink.mjs'; +import { startFakeBoard } from './fake-board.mjs'; + +async function boardWithTools(t, overrides, readFile) { + const board = await startFakeBoard(overrides); + t.after(board.stop); + const connect = () => openLink(linkTarget({ TR_HOST: board.host })); + const tools = Object.fromEntries(createTools({ connect, readFile }).map((tool) => [tool.name, tool.run])); + return { board, tools }; +} + +test('status names the image, the build, the machine and the menu state', async (t) => { + const { tools } = await boardWithTools(t); + assert.equal(await tools.status({}), [ + 'image: main', 'firmware: TeensyROM+ v0.8.0.13', 'built: Oct 30 2026, 10:00:00', 'board: tr-plus', + 'machine: C64 NTSC', 'menu: complete', 'dma: available', + ].join('\n')); +}); + +test('the minimal image has no DMA', async (t) => { + const { tools } = await boardWithTools(t, { image: 'minimal', banner: '\n FW: TeensyROM+ v0.8.0.13(minimal)\r\n' }); + assert.match(await tools.status({}), /^image: minimal\n[\s\S]*dma: not available$/); +}); + +test('read_screen finds the screen through the VIC registers and decodes it', async (t) => { + const { board, tools } = await boardWithTools(t); + board.memory[0xd018] = 0x17; + board.memory[0xdd00] = 0x97; + board.memory.set([0x08, 0x09, 0x20, 0x54, 0x52], 0x0400); + const lines = (await tools.read_screen({})).split('\n'); + assert.equal(lines[0], 'screen at $400, charset lower/uppercase'); + assert.equal(lines[1], 'hi TR'); + assert.equal(lines.length, 26); +}); + +test('push_file replaces a file the board already has', async (t) => { + const { board, tools } = await boardWithTools( + t, { files: new Map([['/a.prg', Buffer.from('old')]]) }, async () => Buffer.from('new data')); + assert.equal(await tools.push_file({ local_path: 'a.prg', remote_path: '/a.prg' }), + 'pushed a.prg to sd:/a.prg (8 bytes)'); + assert.equal(board.files.get('/a.prg').toString(), 'new data'); +}); + +test('push_file writes a file that was not there', async (t) => { + const { board, tools } = await boardWithTools(t, {}, async () => Buffer.from('x')); + await tools.push_file({ local_path: 'b.prg', remote_path: '/b.prg' }); + assert.equal(board.files.get('/b.prg').toString(), 'x'); +}); + +test('list_dir lists entries and says how to get the next page', async (t) => { + const listing = [ + { type: 'dir', name: 'games' }, { type: 'file', name: 'a.prg', size: 10 }, { type: 'file', name: 'b.sid', size: 20 }, + ]; + const { tools } = await boardWithTools(t, { listing }); + assert.equal(await tools.list_dir({ take: 2 }), 'games/\na.prg (10 bytes)\n(first 2 from entry 0; ask again with skip=2)'); + assert.equal(await tools.list_dir({ skip: 2 }), 'b.sid (20 bytes)'); +}); + +test('launch sends the drive and path', async (t) => { + const { board, tools } = await boardWithTools(t); + assert.equal(await tools.launch({ path: '/games/a.crt', drive: 'usb' }), 'launched usb:/games/a.crt'); + assert.deepEqual(board.launched, [{ drive: 0, path: '/games/a.crt' }]); +}); + +test('reset returns what the board said', async (t) => { + const { tools } = await boardWithTools(t); + assert.equal(await tools.reset({}), 'Reset cmd received'); +}); + +test("a refusal carries the board's own words", async (t) => { + const { tools } = await boardWithTools(t, { busy: true }); + await assert.rejects(tools.list_dir({}), /directory: the board refused it: Busy!/); +}); + +test('an unknown drive is refused before anything is sent', async (t) => { + const { tools } = await boardWithTools(t); + await assert.rejects(tools.launch({ path: '/a.prg', drive: 'tape' }), /drive 'tape': use usb, sd, teensy/); +}); diff --git a/tools/mcp/test/transport.test.mjs b/tools/mcp/test/transport.test.mjs new file mode 100644 index 000000000..ded9cf02a --- /dev/null +++ b/tools/mcp/test/transport.test.mjs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +import { PassThrough } from 'node:stream'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { ByteStream } from '../lib/transport.mjs'; + +const FIRST_MS = 2000; +const IDLE_MS = 50; + +function pipe() { + const input = new PassThrough(); + return { input, stream: new ByteStream(input, new PassThrough(), () => {}) }; +} + +test('text that arrived with the reply before it is returned once the line goes quiet', async () => { + const { input, stream } = pipe(); + input.write(Buffer.from('\xcc\x64banner', 'latin1')); + await stream.read(2, FIRST_MS); + const started = Date.now(); + const text = await stream.readIdle({ firstMs: FIRST_MS, idleMs: IDLE_MS, totalMs: FIRST_MS }); + assert.equal(text.toString(), 'banner'); + assert.ok(Date.now() - started < FIRST_MS / 2, `took ${Date.now() - started} ms`); +}); + +test('a read collects chunks until it has the count asked for', async () => { + const { input, stream } = pipe(); + setTimeout(() => input.write('ab'), 10); + setTimeout(() => input.write('cd'), 20); + assert.equal((await stream.read(3, FIRST_MS)).toString(), 'abc'); + assert.equal((await stream.read(1, FIRST_MS)).toString(), 'd'); +}); + +test('readUntilAny stops at whichever marker comes first', async () => { + const { input, stream } = pipe(); + input.write('noise-B-then-A'); + assert.equal((await stream.readUntilAny([Buffer.from('A'), Buffer.from('B')], FIRST_MS)).toString(), 'noise-B'); +}); + +test('a read that times out returns what it has', async () => { + const { input, stream } = pipe(); + input.write('x'); + assert.equal((await stream.read(2, IDLE_MS)).toString(), 'x'); +}); From 258110a92af4e0651de8f2dc58bb32acd1196b7e Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sat, 31 Oct 2026 12:54:27 -0500 Subject: [PATCH 03/12] fix: review findings for 69ff9d2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refusal at the menu is followed by ProcessCommand's two 32-bit stats, which ended up in the tool error text; keep only the refusal's line, and make the fake board send the trailer the firmware does. Screen codes 27-31 are [ £ ] ↑ ← in both ROM sets, not graphics. --- tools/mcp/lib/screen.mjs | 2 ++ tools/mcp/lib/trlink.mjs | 3 ++- tools/mcp/test/fake-board.mjs | 16 ++++++++++++---- tools/mcp/test/screen.test.mjs | 7 +++++++ tools/mcp/test/tools.test.mjs | 7 +++++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/tools/mcp/lib/screen.mjs b/tools/mcp/lib/screen.mjs index 913234351..5f0985669 100644 --- a/tools/mcp/lib/screen.mjs +++ b/tools/mcp/lib/screen.mjs @@ -28,6 +28,7 @@ const CODE_A = 1; const CODE_Z = 26; const CODE_SHIFT_A = 65; const CODE_SHIFT_Z = 90; +const SYMBOLS_AFTER_Z = '[\u00a3]\u2191\u2190'; const PUNCTUATION_FIRST = 0x20; const PUNCTUATION_LAST = 0x3f; const NO_LETTER = '.'; @@ -57,6 +58,7 @@ function codeAsText(screenCode, charset) { if (code >= CODE_A && code <= CODE_Z) { return String.fromCharCode(code - CODE_A + (lowerUpper ? 0x61 : 0x41)); } + if (code > CODE_Z && code < PUNCTUATION_FIRST) return SYMBOLS_AFTER_Z[code - CODE_Z - 1]; if (lowerUpper && code >= CODE_SHIFT_A && code <= CODE_SHIFT_Z) { return String.fromCharCode(code - CODE_SHIFT_A + 0x41); } diff --git a/tools/mcp/lib/trlink.mjs b/tools/mcp/lib/trlink.mjs index 56bf0f065..3be9ce93f 100644 --- a/tools/mcp/lib/trlink.mjs +++ b/tools/mcp/lib/trlink.mjs @@ -116,7 +116,8 @@ export class Link { const value = fromBoard(reply); if (value === token) return; if (value === TOKENS.FAIL) { - const text = (await this.#stream.readIdle(FAIL_TEXT)).toString('latin1').trim(); + // At the menu, two binary 32-bit stats follow the refusal's line. + const [text] = (await this.#stream.readIdle(FAIL_TEXT)).toString('latin1').trim().split(/\r?\n/); throw new BoardError(`${what}: the board refused it${text ? `: ${text}` : ''}`); } throw new BoardError(`${what}: unexpected reply 0x${value.toString(16)}`); diff --git a/tools/mcp/test/fake-board.mjs b/tools/mcp/test/fake-board.mjs index 5ecc85550..30994f62f 100644 --- a/tools/mcp/test/fake-board.mjs +++ b/tools/mcp/test/fake-board.mjs @@ -9,6 +9,10 @@ import { TOKENS, boardReply, checksum } from '../lib/protocol.mjs'; import { ByteStream } from '../lib/transport.mjs'; const WAIT_MS = 2000; +// ProcessCommand() in SerUSBIO.ino follows every command it runs only at the +// menu with two 32-bit stats: Stats.Unlocked, then F_CPU_ACTUAL. +const MENU_ONLY = new Set([TOKENS.POST_FILE, TOKENS.DELETE_FILE, TOKENS.GET_DIR_NDJSON]); +const STATS_TRAILER = Buffer.from([1, 0, 0, 0, 0x00, 0x2c, 0xa3, 0x30]); export const BANNER = '\n FW: TeensyROM+ v0.8.0.13\r\n Oct 30 2026, 10:00:00\r\n' + ' Teensy: 816MHz 55.0C UID: 19277260\r\n C64 NTSC Vid 60 Hz\n Boot: complete\n'; @@ -74,12 +78,13 @@ function commandsFor(board) { await stream.read(1, WAIT_MS); const skip = await readNumber(stream, 2); const take = await readNumber(stream, 2); - await readPath(stream); + const path = await readPath(stream); + if (!board.directories.includes(path)) return refuse(stream, 'Directory not found. (Error 5)'); await reply(stream, TOKENS.ACK); await reply(stream, TOKENS.DIR_START); const lines = board.listing.slice(skip, skip + take).map((entry) => `${JSON.stringify(entry)}\r\n`); await stream.write(Buffer.from(lines.join(''))); - await reply(stream, TOKENS.DIR_END); + return reply(stream, TOKENS.DIR_END); }, }; } @@ -90,9 +95,11 @@ async function serve(board, socket) { while (!stream.ended) { const token = await stream.read(2, 60000); if (token.length < 2) break; - const command = commands[token.readUInt16BE(0)]; - if (board.busy && token.readUInt16BE(0) !== TOKENS.FW_CHECK) await refuse(stream, 'Busy!'); + const value = token.readUInt16BE(0); + const command = commands[value]; + if (board.busy && value !== TOKENS.FW_CHECK) await refuse(stream, 'Busy!'); else if (command) await command(stream); + if (!board.busy && MENU_ONLY.has(value)) await stream.write(STATS_TRAILER); } } @@ -104,6 +111,7 @@ export async function startFakeBoard(overrides = {}) { memory: Buffer.alloc(0x10000, 0x20), files: new Map(), listing: [], + directories: ['/'], launched: [], ...overrides, }; diff --git a/tools/mcp/test/screen.test.mjs b/tools/mcp/test/screen.test.mjs index 3cdaacc0e..999c8fa47 100644 --- a/tools/mcp/test/screen.test.mjs +++ b/tools/mcp/test/screen.test.mjs @@ -31,3 +31,10 @@ test('screen codes read as the letters of the selected set', () => { assert.equal(screenRows(screen, CHARSETS.upperGraphics)[0].trimEnd(), 'HI ...1A.'); assert.equal(screenRows(screen, CHARSETS.upperGraphics).length, 25); }); + +test('the symbols after Z read as themselves in both sets', () => { + const screen = Buffer.alloc(SCREEN_BYTES, 0x20); + screen.set([0x1b, 0x1c, 0x1d, 0x1e, 0x1f], 0); + assert.equal(screenRows(screen, CHARSETS.upperGraphics)[0].trimEnd(), '[\u00a3]\u2191\u2190'); + assert.equal(screenRows(screen, CHARSETS.lowerUpper)[0].trimEnd(), '[\u00a3]\u2191\u2190'); +}); diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index a015353d7..a817ed16f 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -77,6 +77,13 @@ test("a refusal carries the board's own words", async (t) => { await assert.rejects(tools.list_dir({}), /directory: the board refused it: Busy!/); }); +test('a refusal at the menu carries only the line the board printed', async (t) => { + const { tools } = await boardWithTools(t); + await assert.rejects(tools.list_dir({ path: '/nope' }), { + message: 'directory /nope: the board refused it: Directory not found. (Error 5)', + }); +}); + test('an unknown drive is refused before anything is sent', async (t) => { const { tools } = await boardWithTools(t); await assert.rejects(tools.launch({ path: '/a.prg', drive: 'tape' }), /drive 'tape': use usb, sd, teensy/); From 6fd1d1c01adc8652bbf31bfe120839255ea14503 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sat, 31 Oct 2026 12:57:18 -0500 Subject: [PATCH 04/12] tools: the MCP server refuses a path the board would overrun The firmware reads a path into a fixed buffer and runs whatever is left over as commands, so a too-long path or one with a NUL is refused before anything is sent. --- tools/mcp/lib/protocol.mjs | 7 +++++++ tools/mcp/lib/trlink.mjs | 9 ++++++--- tools/mcp/test/tools.test.mjs | 10 ++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tools/mcp/lib/protocol.mjs b/tools/mcp/lib/protocol.mjs index 5dc842f5b..20e32ed1f 100644 --- a/tools/mcp/lib/protocol.mjs +++ b/tools/mcp/lib/protocol.mjs @@ -63,7 +63,14 @@ export function boardReply(value) { return bytes; } +// GetPathParameter() in FileTransfer.ino reads into MaxNamePathLength bytes +// (Common_Defs.h). A longer path, or one with a NUL inside, leaves bytes the +// board then runs as commands of their own. +export const MAX_PATH_BYTES = 256 + 100 + 2 - 1; + export function pathField(path) { + if (path.includes('\0')) throw new Error('a path cannot contain a NUL character'); + if (path.length > MAX_PATH_BYTES) throw new Error(`a path is at most ${MAX_PATH_BYTES} characters`); return Buffer.from(`${path}\0`, 'latin1'); } diff --git a/tools/mcp/lib/trlink.mjs b/tools/mcp/lib/trlink.mjs index 3be9ce93f..f50f922c4 100644 --- a/tools/mcp/lib/trlink.mjs +++ b/tools/mcp/lib/trlink.mjs @@ -56,9 +56,10 @@ export class Link { } async launch(path, drive) { + const target = Buffer.concat([Buffer.of(drive), pathField(path)]); await this.#send(toBoard(TOKENS.LAUNCH_FILE)); await this.#ack('launch'); - await this.#stream.write(Buffer.concat([Buffer.of(drive), pathField(path)])); + await this.#stream.write(target); await this.#ack(`launch ${path}`); } @@ -73,9 +74,10 @@ export class Link { } async deleteFile(path, drive) { + const target = Buffer.concat([Buffer.of(drive), pathField(path)]); await this.#send(toBoard(TOKENS.DELETE_FILE)); await this.#ack('delete'); - await this.#stream.write(Buffer.concat([Buffer.of(drive), pathField(path)])); + await this.#stream.write(target); await this.#ack(`delete ${path}`); } @@ -90,9 +92,10 @@ export class Link { } async listDirectory(path, drive, { skip, take }) { + const request = Buffer.concat([Buffer.of(drive), toBoard(skip), toBoard(take), pathField(path)]); await this.#send(toBoard(TOKENS.GET_DIR_NDJSON)); await this.#ack('directory'); - await this.#stream.write(Buffer.concat([Buffer.of(drive), toBoard(skip), toBoard(take), pathField(path)])); + await this.#stream.write(request); await this.#ack(`directory ${path}`); await this.#expect(TOKENS.DIR_START, `directory ${path}`); const listing = await this.#stream.readUntil(boardReply(TOKENS.DIR_END), LISTING_MS); diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index a817ed16f..bf09a75e1 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -88,3 +88,13 @@ test('an unknown drive is refused before anything is sent', async (t) => { const { tools } = await boardWithTools(t); await assert.rejects(tools.launch({ path: '/a.prg', drive: 'tape' }), /drive 'tape': use usb, sd, teensy/); }); + +test('a path the board cannot hold whole is refused before it is sent', async (t) => { + const { board, tools } = await boardWithTools(t, {}, async () => Buffer.from('x')); + await assert.rejects(tools.launch({ path: `/${'a'.repeat(357)}` }), /at most 357 characters/); + await assert.rejects(tools.launch({ path: '/a.prg\0e' }), /NUL/); + await assert.rejects(tools.list_dir({ path: `/${'a'.repeat(357)}` }), /at most 357 characters/); + await assert.rejects(tools.push_file({ local_path: 'x', remote_path: '/a\0e' }), /NUL/); + await tools.launch({ path: `/${'a'.repeat(356)}` }); + assert.deepEqual(board.launched.map(({ path }) => path.length), [357]); +}); From c44f7055dba725ca4d6710dd69eae49c91207137 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sat, 31 Oct 2026 13:00:22 -0500 Subject: [PATCH 05/12] fix: review findings for 6fd1d1c A path character above U+00FF reached the board as its low byte, so U+0100 ended the path early and the bytes after it ran as commands; refuse anything outside Latin-1. --- tools/mcp/lib/protocol.mjs | 6 ++++-- tools/mcp/test/tools.test.mjs | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/mcp/lib/protocol.mjs b/tools/mcp/lib/protocol.mjs index 20e32ed1f..b02fe9ed5 100644 --- a/tools/mcp/lib/protocol.mjs +++ b/tools/mcp/lib/protocol.mjs @@ -65,11 +65,13 @@ export function boardReply(value) { // GetPathParameter() in FileTransfer.ino reads into MaxNamePathLength bytes // (Common_Defs.h). A longer path, or one with a NUL inside, leaves bytes the -// board then runs as commands of their own. +// board then runs as commands of their own. Latin-1 encoding keeps only the +// low byte of a character above U+00FF, so U+0100 would arrive as a NUL. export const MAX_PATH_BYTES = 256 + 100 + 2 - 1; +const UNSENDABLE = /[^\x01-\xff]/; export function pathField(path) { - if (path.includes('\0')) throw new Error('a path cannot contain a NUL character'); + if (UNSENDABLE.test(path)) throw new Error('a path holds Latin-1 characters only, and no NUL'); if (path.length > MAX_PATH_BYTES) throw new Error(`a path is at most ${MAX_PATH_BYTES} characters`); return Buffer.from(`${path}\0`, 'latin1'); } diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index bf09a75e1..ec11795aa 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -95,6 +95,7 @@ test('a path the board cannot hold whole is refused before it is sent', async (t await assert.rejects(tools.launch({ path: '/a.prg\0e' }), /NUL/); await assert.rejects(tools.list_dir({ path: `/${'a'.repeat(357)}` }), /at most 357 characters/); await assert.rejects(tools.push_file({ local_path: 'x', remote_path: '/a\0e' }), /NUL/); + await assert.rejects(tools.launch({ path: '/aĀŤǮ' }), /Latin-1/); await tools.launch({ path: `/${'a'.repeat(356)}` }); assert.deepEqual(board.launched.map(({ path }) => path.length), [357]); }); From 11a6c6881340c55a96bb74a2c004b471142d8799 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sat, 31 Oct 2026 13:17:43 -0500 Subject: [PATCH 06/12] tools: MCP tools return JSON matching a declared output schema Agents get fixed field names and enums instead of prose to parse, which costs fewer tokens and gives the same shape every call. --- tools/mcp/README.md | 2 + tools/mcp/lib/mcp.mjs | 43 +++++---- tools/mcp/lib/screen.mjs | 6 +- tools/mcp/lib/tools.mjs | 156 +++++++++++++++++++++++---------- tools/mcp/test/fake-board.mjs | 2 + tools/mcp/test/schema.mjs | 55 ++++++++++++ tools/mcp/test/server.test.mjs | 17 +++- tools/mcp/test/tools.test.mjs | 62 +++++++++---- 8 files changed, 255 insertions(+), 88 deletions(-) create mode 100644 tools/mcp/test/schema.mjs diff --git a/tools/mcp/README.md b/tools/mcp/README.md index be3f313d4..4b1f454ac 100644 --- a/tools/mcp/README.md +++ b/tools/mcp/README.md @@ -13,6 +13,8 @@ An [MCP](https://modelcontextprotocol.io) server that lets an AI agent (Claude C | `launch` | Runs a file on the C64 (PRG, CRT, SID, D64, …) | main or minimal image | | `reset` | Resets the C64 to the TeensyROM menu | main or minimal image | +Every tool declares an `outputSchema` and returns a JSON object matching it, as `structuredContent` and as the text of its one content block (for clients older than MCP 2025-06-18, the text alone). Field names and enum values are fixed, so an agent can rely on them; a `null` means the board didn't report that field. + A command the board can't take right now (for example `list_dir` while a game runs) comes back as a tool error carrying the board's own words, usually `Busy!`. `reset` gets the board back to the menu. `read_screen` does not read the C128 80-column screen. Avoid it while a C128 runs at 2 MHz (GEOS 128 in 80 columns), because a DMA read can hang the machine. diff --git a/tools/mcp/lib/mcp.mjs b/tools/mcp/lib/mcp.mjs index f974ca594..dcb18172c 100644 --- a/tools/mcp/lib/mcp.mjs +++ b/tools/mcp/lib/mcp.mjs @@ -14,36 +14,45 @@ function protocolVersionFor(requested) { return SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[0]; } -function listing(tool) { - const { run, ...described } = tool; - return described; +// Versions before 2025-06-18 have no outputSchema or structuredContent; the +// JSON in the text block carries the same result to their clients. +const FIRST_STRUCTURED_VERSION = '2025-06-18'; + +function listing(tool, structured) { + const { run, outputSchema, ...described } = tool; + return structured ? { ...described, outputSchema } : described; +} + +function errorResult(message) { + return { content: [{ type: 'text', text: message }], isError: true }; } -function toolResult(text, isError = false) { - return { content: [{ type: 'text', text }], isError }; +function successResult(value, structured) { + const result = { content: [{ type: 'text', text: JSON.stringify(value) }], isError: false }; + return structured ? { ...result, structuredContent: value } : result; } -async function callTool(tools, params) { +async function callTool(tools, params, structured) { const tool = tools.find((candidate) => candidate.name === params?.name); - if (!tool) return toolResult(`no tool named '${params?.name}'`, true); + if (!tool) return errorResult(`no tool named '${params?.name}'`); try { - return toolResult(await tool.run(params.arguments ?? {})); + return successResult(await tool.run(params.arguments ?? {}), structured); } catch (error) { - return toolResult(error.message, true); + return errorResult(error.message); } } function handlerFor(server) { + let structured = true; return { - initialize: (params) => ({ - protocolVersion: protocolVersionFor(params?.protocolVersion), - capabilities: { tools: {} }, - serverInfo: server.info, - instructions: server.instructions, - }), + initialize: (params) => { + const protocolVersion = protocolVersionFor(params?.protocolVersion); + structured = protocolVersion >= FIRST_STRUCTURED_VERSION; + return { protocolVersion, capabilities: { tools: {} }, serverInfo: server.info, instructions: server.instructions }; + }, ping: () => ({}), - 'tools/list': () => ({ tools: server.tools.map(listing) }), - 'tools/call': (params) => callTool(server.tools, params), + 'tools/list': () => ({ tools: server.tools.map((tool) => listing(tool, structured)) }), + 'tools/call': (params) => callTool(server.tools, params, structured), }; } diff --git a/tools/mcp/lib/screen.mjs b/tools/mcp/lib/screen.mjs index 5f0985669..8459e675c 100644 --- a/tools/mcp/lib/screen.mjs +++ b/tools/mcp/lib/screen.mjs @@ -17,9 +17,9 @@ const ROM_LOWER_UPPER = 0x1800; const ROM_BANKS = [0, 2]; export const CHARSETS = { - lowerUpper: 'lower/uppercase', - upperGraphics: 'uppercase/graphics', - custom: 'custom (RAM)', + lowerUpper: 'lower_upper', + upperGraphics: 'upper_graphics', + custom: 'custom', }; const REVERSE_VIDEO = 0x80; diff --git a/tools/mcp/lib/tools.mjs b/tools/mcp/lib/tools.mjs index 31da3a123..e3ca2b05b 100644 --- a/tools/mcp/lib/tools.mjs +++ b/tools/mcp/lib/tools.mjs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT // -// The MCP tools. Each call opens its own connection and closes it, so other -// TeensyROM apps can reach the board between calls: it takes one TCP client -// at a time, and a serial port opens in one place only. +// The MCP tools. Each returns an object matching its outputSchema. Each call +// opens its own connection and closes it, so other TeensyROM apps can reach +// the board between calls: it takes one TCP client at a time, and a serial +// port opens in one place only. import fs from 'node:fs/promises'; @@ -10,48 +11,103 @@ import { boardKind, parseBanner } from './banner.mjs'; import { CHARSETS, CIA2_PORT_A, SCREEN_BYTES, VIC_MEMPTR, charsetIn, screenAddress, screenRows } from './screen.mjs'; import { driveNumber } from './trlink.mjs'; +const DRIVES = ['sd', 'usb', 'teensy']; +const STORAGE_DRIVES = ['sd', 'usb']; const DRIVE_PROPERTY = { type: 'string', - enum: ['sd', 'usb', 'teensy'], + enum: DRIVES, default: 'sd', description: "Where the file is: 'sd' (SD card), 'usb' (USB drive) or 'teensy' (the built-in menu).", }; const DEFAULT_TAKE = 200; const MAX_TAKE = 1000; -const NOTHING_ANSWERED = 'Nothing answered the firmware check. An extension image runs with USB off and ' - + 'answers nothing; otherwise the board is hung or not connected.'; +const nullable = (schema) => ({ ...schema, type: [schema.type, 'null'] }); +const objectSchema = (properties) => ({ + type: 'object', properties, required: Object.keys(properties), additionalProperties: false, +}); +const NO_INPUT = { type: 'object', properties: {} }; -function describeStatus(image, banner) { - const lines = [`image: ${image}`]; - const fields = { +const STATUS_OUTPUT = objectSchema({ + image: nullable({ type: 'string', enum: ['main', 'minimal', null], + description: 'null: nothing answered (an extension image, which runs with USB off, or no board).' }), + firmware: nullable({ type: 'string', description: "As the board names it, e.g. 'TeensyROM+ v0.9'." }), + built: nullable({ type: 'string', description: "Build date, e.g. 'Oct 30 2026, 03:16:02'." }), + board: nullable({ type: 'string', enum: ['tr', 'tr-plus', null] }), + machine: nullable({ type: 'string', enum: ['C64', 'C128', null] }), + video: nullable({ type: 'string', enum: ['NTSC', 'PAL', null] }), + menu: nullable({ type: 'string', enum: ['complete', 'in progress', null], + description: "Whether the menu has finished booting; null from the minimal image." }), + dma: { type: 'boolean', description: 'Whether read_screen and other DMA tools can run.' }, +}); + +const ENTRY = { + type: 'object', + properties: { + type: { type: 'string', enum: ['dir', 'file'] }, + name: { type: 'string' }, + size: { type: 'integer', description: 'Bytes; files only.' }, + }, + required: ['type', 'name'], +}; +const LIST_DIR_OUTPUT = objectSchema({ + path: { type: 'string' }, + drive: { type: 'string', enum: DRIVES }, + skip: { type: 'integer' }, + entries: { type: 'array', items: ENTRY, description: 'In the order the board lists them.' }, + next_skip: nullable({ type: 'integer', description: 'Pass as skip for the next page; null when this was the last.' }), +}); + +const READ_SCREEN_OUTPUT = objectSchema({ + address: { type: 'integer', description: 'Where the screen is in C64 memory.' }, + charset: { type: 'string', enum: Object.values(CHARSETS), + description: "'custom' (a RAM charset) is decoded as upper_graphics." }, + rows: { type: 'array', items: { type: 'string' }, minItems: 25, maxItems: 25, + description: 'The 25 rows, trailing spaces removed. A character with no text form reads as ".".' }, +}); + +const PUSH_FILE_OUTPUT = objectSchema({ + drive: { type: 'string', enum: STORAGE_DRIVES }, + remote_path: { type: 'string' }, + bytes: { type: 'integer' }, +}); + +const LAUNCH_OUTPUT = objectSchema({ + drive: { type: 'string', enum: DRIVES }, + path: { type: 'string' }, +}); + +const RESET_OUTPUT = objectSchema({}); + +const NOTHING_ANSWERED = { + image: null, firmware: null, built: null, board: null, machine: null, video: null, menu: null, dma: false, +}; + +async function status(link) { + const image = await link.fwCheck(); + if (!image) return NOTHING_ANSWERED; + const banner = parseBanner(await link.version()); + const board = boardKind(banner.version); + return { + image, firmware: banner.version, built: banner.built, - board: boardKind(banner.version), - machine: banner.machine && `${banner.machine} ${banner.video}`, + board, + machine: banner.machine, + video: banner.video, menu: banner.boot, + dma: image === 'main' && board === 'tr-plus', }; - for (const [name, value] of Object.entries(fields)) if (value) lines.push(`${name}: ${value}`); - lines.push(`dma: ${image === 'main' && boardKind(banner.version) === 'tr-plus' ? 'available' : 'not available'}`); - return lines.join('\n'); } -function describeEntry(entry) { - return entry.type === 'dir' ? `${entry.name}/` : `${entry.name} (${entry.size} bytes)`; -} - -async function status(link) { - const image = await link.fwCheck(); - if (!image) return NOTHING_ANSWERED; - return describeStatus(image, parseBanner(await link.version())); +function entryOf({ type, name, size }) { + return type === 'file' ? { type, name, size } : { type, name }; } async function listDir(link, { path = '/', drive = 'sd', skip = 0, take = DEFAULT_TAKE }) { const count = Math.min(take, MAX_TAKE); const entries = await link.listDirectory(path, driveNumber(drive), { skip, take: count }); - if (entries.length === 0) return `${path} is empty${skip ? ` past entry ${skip}` : ''}`; - const more = entries.length === count ? `\n(first ${count} from entry ${skip}; ask again with skip=${skip + count})` : ''; - return entries.map(describeEntry).join('\n') + more; + return { path, drive, skip, entries: entries.map(entryOf), next_skip: entries.length === count ? skip + count : null }; } async function readScreen(link) { @@ -60,9 +116,7 @@ async function readScreen(link) { const address = screenAddress(memptr, cia2PortA); const charset = charsetIn(memptr, cia2PortA); const rows = screenRows(await link.readMemory(address, SCREEN_BYTES), charset); - const note = charset === CHARSETS.custom ? ' (decoded as uppercase/graphics)' : ''; - const header = `screen at $${address.toString(16).toUpperCase()}, charset ${charset}${note}`; - return [header, ...rows.map((row) => row.trimEnd())].join('\n'); + return { address, charset, rows: rows.map((row) => row.trimEnd()) }; } async function pushFile(link, { local_path: localPath, remote_path: remotePath, drive = 'sd' }, readFile) { @@ -71,33 +125,34 @@ async function pushFile(link, { local_path: localPath, remote_path: remotePath, // The firmware will not overwrite a file, and a missing one fails the delete. await link.deleteFile(remotePath, number).catch(() => {}); await link.postFile(data, remotePath, number); - return `pushed ${localPath} to ${drive}:${remotePath} (${data.length} bytes)`; + return { drive, remote_path: remotePath, bytes: data.length }; } async function launch(link, { path, drive = 'sd' }) { await link.launch(path, driveNumber(drive)); - return `launched ${drive}:${path}`; + return { drive, path }; } async function reset(link) { - return (await link.reset()) || 'reset sent'; + await link.reset(); + return {}; } const DEFINITIONS = [ { name: 'status', - description: 'Which TeensyROM image is running (main, minimal, or none answering), its firmware version, ' - + 'board type (tr or tr-plus), the host machine (C64/C128, NTSC/PAL), whether the menu has finished ' - + "booting, and whether DMA memory access is available. Call this first, and after 'reset' until " - + "'menu: complete'.", - inputSchema: { type: 'object', properties: {} }, + description: 'Which TeensyROM image is running, its firmware and board type, the host machine, whether ' + + "the menu has finished booting, and whether DMA tools can run. Call this first, and after 'reset' " + + "until menu is 'complete'.", + inputSchema: NO_INPUT, + outputSchema: STATUS_OUTPUT, annotations: { readOnlyHint: true, openWorldHint: true }, run: status, }, { name: 'list_dir', - description: "List a directory on the TeensyROM's SD card, USB drive or built-in menu. Needs the main " - + 'image with the C64 at the TeensyROM menu; otherwise the board answers Busy!.', + description: "List a directory on the TeensyROM's SD card, USB drive or built-in menu, a page at a time. " + + 'Needs the main image with the C64 at the TeensyROM menu; otherwise the board answers Busy!.', inputSchema: { type: 'object', properties: { @@ -107,15 +162,17 @@ const DEFINITIONS = [ take: { type: 'integer', minimum: 1, maximum: MAX_TAKE, default: DEFAULT_TAKE }, }, }, + outputSchema: LIST_DIR_OUTPUT, annotations: { readOnlyHint: true, openWorldHint: true }, run: listDir, }, { name: 'read_screen', - description: "The C64's 40-column text screen as 25 lines of text, read by DMA. Needs a TR+ running " - + 'its main image. Graphics characters read as ".". It does not read the C128 80-column screen. ' - + 'Avoid it while a C128 runs at 2 MHz (GEOS 128 in 80 columns): a DMA read can hang the machine.', - inputSchema: { type: 'object', properties: {} }, + description: "The C64's 40-column text screen as 25 rows of text, read by DMA. Needs a TR+ running its " + + 'main image. It does not read the C128 80-column screen. Avoid it while a C128 runs at 2 MHz ' + + '(GEOS 128 in 80 columns): a DMA read can hang the machine.', + inputSchema: NO_INPUT, + outputSchema: READ_SCREEN_OUTPUT, annotations: { readOnlyHint: true, openWorldHint: true }, run: readScreen, }, @@ -128,18 +185,19 @@ const DEFINITIONS = [ properties: { local_path: { type: 'string', description: 'Path of the file on this computer.' }, remote_path: { type: 'string', description: 'Destination path on the cartridge, starting with /.' }, - drive: { ...DRIVE_PROPERTY, enum: ['sd', 'usb'] }, + drive: { ...DRIVE_PROPERTY, enum: STORAGE_DRIVES }, }, required: ['local_path', 'remote_path'], }, + outputSchema: PUSH_FILE_OUTPUT, annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true }, run: pushFile, }, { name: 'launch', description: 'Launch a file (PRG, CRT, SID, D64 and the other types the menu runs) on the C64, from the ' - + "SD card, USB drive or built-in menu. Ends whatever the C64 was running. A large CRT hands over to the " - + "minimal image, which answers only status, launch and reset.", + + 'SD card, USB drive or built-in menu. Ends whatever the C64 was running. A large CRT hands over to the ' + + 'minimal image, which answers only status, launch and reset.', inputSchema: { type: 'object', properties: { @@ -148,15 +206,17 @@ const DEFINITIONS = [ }, required: ['path'], }, + outputSchema: LAUNCH_OUTPUT, annotations: { destructiveHint: false, openWorldHint: true }, run: launch, }, { name: 'reset', - description: "Reset the C64 to the TeensyROM menu, ending whatever it was running, and bring the board " + description: 'Reset the C64 to the TeensyROM menu, ending whatever it was running, and bring the board ' + "back to its main image. The menu takes from under a second to tens of seconds to boot: call 'status' " - + "until it reports 'menu: complete' before using the other tools.", - inputSchema: { type: 'object', properties: {} }, + + "until menu is 'complete' before using the other tools.", + inputSchema: NO_INPUT, + outputSchema: RESET_OUTPUT, annotations: { destructiveHint: false, openWorldHint: true }, run: reset, }, diff --git a/tools/mcp/test/fake-board.mjs b/tools/mcp/test/fake-board.mjs index 30994f62f..04fd15b7c 100644 --- a/tools/mcp/test/fake-board.mjs +++ b/tools/mcp/test/fake-board.mjs @@ -95,6 +95,7 @@ async function serve(board, socket) { while (!stream.ended) { const token = await stream.read(2, 60000); if (token.length < 2) break; + if (board.silent) continue; const value = token.readUInt16BE(0); const command = commands[value]; if (board.busy && value !== TOKENS.FW_CHECK) await refuse(stream, 'Busy!'); @@ -108,6 +109,7 @@ export async function startFakeBoard(overrides = {}) { image: 'main', banner: BANNER, busy: false, + silent: false, memory: Buffer.alloc(0x10000, 0x20), files: new Map(), listing: [], diff --git a/tools/mcp/test/schema.mjs b/tools/mcp/test/schema.mjs new file mode 100644 index 000000000..54555b4a0 --- /dev/null +++ b/tools/mcp/test/schema.mjs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// +// Checks a value against the JSON Schema keywords the tools' outputSchemas +// use. Throws on a keyword it does not know, so a schema can't outgrow it. + +const KNOWN = new Set([ + 'type', 'enum', 'properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems', + 'description', 'default', 'minimum', 'maximum', +]); + +function typeOf(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (Number.isInteger(value)) return 'integer'; + return typeof value; +} + +function typeMatches(value, type) { + const types = [type].flat(); + const actual = typeOf(value); + return types.includes(actual) || (actual === 'integer' && types.includes('number')); +} + +function checkObject(value, schema, at, problems) { + for (const name of schema.required ?? []) if (!(name in value)) problems.push(`${at}.${name}: missing`); + for (const [name, item] of Object.entries(value)) { + const property = schema.properties?.[name]; + if (property) check(item, property, `${at}.${name}`, problems); + else if (schema.additionalProperties === false) problems.push(`${at}.${name}: not in the schema`); + } +} + +function checkArray(value, schema, at, problems) { + if (schema.minItems !== undefined && value.length < schema.minItems) problems.push(`${at}: fewer than ${schema.minItems}`); + if (schema.maxItems !== undefined && value.length > schema.maxItems) problems.push(`${at}: more than ${schema.maxItems}`); + if (schema.items) value.forEach((item, index) => check(item, schema.items, `${at}[${index}]`, problems)); +} + +function check(value, schema, at, problems) { + const unknown = Object.keys(schema).filter((keyword) => !KNOWN.has(keyword)); + if (unknown.length) throw new Error(`${at}: schema keyword ${unknown.join(', ')} is not checked`); + if (schema.type && !typeMatches(value, schema.type)) { + problems.push(`${at}: ${typeOf(value)}, want ${[schema.type].flat().join(' or ')}`); + return; + } + if (schema.enum && !schema.enum.includes(value)) problems.push(`${at}: ${JSON.stringify(value)} not in enum`); + if (typeOf(value) === 'object') checkObject(value, schema, at, problems); + if (typeOf(value) === 'array') checkArray(value, schema, at, problems); +} + +export function schemaProblems(value, schema) { + const problems = []; + check(value, schema, '$', problems); + return problems; +} diff --git a/tools/mcp/test/server.test.mjs b/tools/mcp/test/server.test.mjs index e2e9076b0..f4a254219 100644 --- a/tools/mcp/test/server.test.mjs +++ b/tools/mcp/test/server.test.mjs @@ -41,10 +41,25 @@ test('a client can initialize, list the tools and call one over stdio', async (t assert.deepEqual(listed.result.tools.map((tool) => tool.name), ['status', 'list_dir', 'read_screen', 'push_file', 'launch', 'reset']); assert.ok(listed.result.tools.every((tool) => tool.inputSchema.type === 'object' && !('run' in tool))); + assert.ok(listed.result.tools.every((tool) => tool.outputSchema.type === 'object')); const called = await server.request('tools/call', { name: 'status', arguments: {} }); assert.equal(called.result.isError, false); - assert.match(called.result.content[0].text, /^image: main/); + assert.equal(called.result.structuredContent.image, 'main'); + assert.deepEqual(JSON.parse(called.result.content[0].text), called.result.structuredContent); +}); + +test('a client on a protocol version without structured output gets the same JSON as text', async (t) => { + const board = await startFakeBoard(); + t.after(board.stop); + const server = startServer(t, { TR_HOST: board.host }); + await server.request('initialize', { protocolVersion: '2025-03-26' }); + + const listed = await server.request('tools/list'); + assert.ok(listed.result.tools.every((tool) => !('outputSchema' in tool))); + const called = await server.request('tools/call', { name: 'status', arguments: {} }); + assert.ok(!('structuredContent' in called.result)); + assert.equal(JSON.parse(called.result.content[0].text).image, 'main'); }); test('a client asking for a protocol version this server lacks is offered the newest it has', async (t) => { diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index ec11795aa..9e09d46d5 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -5,26 +5,45 @@ import assert from 'node:assert/strict'; import { createTools } from '../lib/tools.mjs'; import { linkTarget, openLink } from '../lib/trlink.mjs'; import { startFakeBoard } from './fake-board.mjs'; +import { schemaProblems } from './schema.mjs'; + +function checkedAgainstSchema(tool) { + return async (args) => { + const result = await tool.run(args); + assert.deepEqual(schemaProblems(result, tool.outputSchema), [], `${tool.name} output`); + return result; + }; +} async function boardWithTools(t, overrides, readFile) { const board = await startFakeBoard(overrides); t.after(board.stop); const connect = () => openLink(linkTarget({ TR_HOST: board.host })); - const tools = Object.fromEntries(createTools({ connect, readFile }).map((tool) => [tool.name, tool.run])); + const tools = Object.fromEntries(createTools({ connect, readFile }).map((tool) => [tool.name, checkedAgainstSchema(tool)])); return { board, tools }; } test('status names the image, the build, the machine and the menu state', async (t) => { const { tools } = await boardWithTools(t); - assert.equal(await tools.status({}), [ - 'image: main', 'firmware: TeensyROM+ v0.8.0.13', 'built: Oct 30 2026, 10:00:00', 'board: tr-plus', - 'machine: C64 NTSC', 'menu: complete', 'dma: available', - ].join('\n')); + assert.deepEqual(await tools.status({}), { + image: 'main', firmware: 'TeensyROM+ v0.8.0.13', built: 'Oct 30 2026, 10:00:00', board: 'tr-plus', + machine: 'C64', video: 'NTSC', menu: 'complete', dma: true, + }); }); -test('the minimal image has no DMA', async (t) => { +test('the minimal image has no DMA and no menu state', async (t) => { const { tools } = await boardWithTools(t, { image: 'minimal', banner: '\n FW: TeensyROM+ v0.8.0.13(minimal)\r\n' }); - assert.match(await tools.status({}), /^image: minimal\n[\s\S]*dma: not available$/); + const result = await tools.status({}); + assert.equal(result.image, 'minimal'); + assert.equal(result.menu, null); + assert.equal(result.dma, false); +}); + +test('a board that answers nothing has every field null and no DMA', async (t) => { + const { tools } = await boardWithTools(t, { silent: true }); + assert.deepEqual(await tools.status({}), { + image: null, firmware: null, built: null, board: null, machine: null, video: null, menu: null, dma: false, + }); }); test('read_screen finds the screen through the VIC registers and decodes it', async (t) => { @@ -32,17 +51,18 @@ test('read_screen finds the screen through the VIC registers and decodes it', as board.memory[0xd018] = 0x17; board.memory[0xdd00] = 0x97; board.memory.set([0x08, 0x09, 0x20, 0x54, 0x52], 0x0400); - const lines = (await tools.read_screen({})).split('\n'); - assert.equal(lines[0], 'screen at $400, charset lower/uppercase'); - assert.equal(lines[1], 'hi TR'); - assert.equal(lines.length, 26); + const { address, charset, rows } = await tools.read_screen({}); + assert.equal(address, 0x0400); + assert.equal(charset, 'lower_upper'); + assert.equal(rows[0], 'hi TR'); + assert.equal(rows[1], ''); }); test('push_file replaces a file the board already has', async (t) => { const { board, tools } = await boardWithTools( t, { files: new Map([['/a.prg', Buffer.from('old')]]) }, async () => Buffer.from('new data')); - assert.equal(await tools.push_file({ local_path: 'a.prg', remote_path: '/a.prg' }), - 'pushed a.prg to sd:/a.prg (8 bytes)'); + assert.deepEqual(await tools.push_file({ local_path: 'a.prg', remote_path: '/a.prg' }), + { drive: 'sd', remote_path: '/a.prg', bytes: 8 }); assert.equal(board.files.get('/a.prg').toString(), 'new data'); }); @@ -52,24 +72,28 @@ test('push_file writes a file that was not there', async (t) => { assert.equal(board.files.get('/b.prg').toString(), 'x'); }); -test('list_dir lists entries and says how to get the next page', async (t) => { +test('list_dir returns entries and the skip for the next page', async (t) => { const listing = [ { type: 'dir', name: 'games' }, { type: 'file', name: 'a.prg', size: 10 }, { type: 'file', name: 'b.sid', size: 20 }, ]; const { tools } = await boardWithTools(t, { listing }); - assert.equal(await tools.list_dir({ take: 2 }), 'games/\na.prg (10 bytes)\n(first 2 from entry 0; ask again with skip=2)'); - assert.equal(await tools.list_dir({ skip: 2 }), 'b.sid (20 bytes)'); + assert.deepEqual(await tools.list_dir({ take: 2 }), { + path: '/', drive: 'sd', skip: 0, entries: listing.slice(0, 2), next_skip: 2, + }); + assert.deepEqual(await tools.list_dir({ skip: 2 }), { + path: '/', drive: 'sd', skip: 2, entries: listing.slice(2), next_skip: null, + }); }); test('launch sends the drive and path', async (t) => { const { board, tools } = await boardWithTools(t); - assert.equal(await tools.launch({ path: '/games/a.crt', drive: 'usb' }), 'launched usb:/games/a.crt'); + assert.deepEqual(await tools.launch({ path: '/games/a.crt', drive: 'usb' }), { drive: 'usb', path: '/games/a.crt' }); assert.deepEqual(board.launched, [{ drive: 0, path: '/games/a.crt' }]); }); -test('reset returns what the board said', async (t) => { +test('reset returns once the board has the command', async (t) => { const { tools } = await boardWithTools(t); - assert.equal(await tools.reset({}), 'Reset cmd received'); + assert.deepEqual(await tools.reset({}), {}); }); test("a refusal carries the board's own words", async (t) => { From 8ea24494d965ec6464006d0d4a8da091b4157404 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sat, 31 Oct 2026 13:20:42 -0500 Subject: [PATCH 07/12] fix: review findings for 11a6c68 --- tools/mcp/test/schema.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/mcp/test/schema.mjs b/tools/mcp/test/schema.mjs index 54555b4a0..1c51ba8e6 100644 --- a/tools/mcp/test/schema.mjs +++ b/tools/mcp/test/schema.mjs @@ -5,7 +5,7 @@ const KNOWN = new Set([ 'type', 'enum', 'properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems', - 'description', 'default', 'minimum', 'maximum', + 'description', ]); function typeOf(value) { From f7d705e31810b08eec49d1d77727863d795b83d2 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sun, 1 Nov 2026 14:25:12 -0600 Subject: [PATCH 08/12] tools: read_screen refuses a plain TR before sending a DMA command A plain TR's firmware has no DMA commands, so it ran the read's address and length bytes as further commands. --- tools/mcp/lib/tools.mjs | 12 +++++++++++- tools/mcp/test/fake-board.mjs | 2 ++ tools/mcp/test/tools.test.mjs | 9 ++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tools/mcp/lib/tools.mjs b/tools/mcp/lib/tools.mjs index e3ca2b05b..461f91e4f 100644 --- a/tools/mcp/lib/tools.mjs +++ b/tools/mcp/lib/tools.mjs @@ -9,7 +9,7 @@ import fs from 'node:fs/promises'; import { boardKind, parseBanner } from './banner.mjs'; import { CHARSETS, CIA2_PORT_A, SCREEN_BYTES, VIC_MEMPTR, charsetIn, screenAddress, screenRows } from './screen.mjs'; -import { driveNumber } from './trlink.mjs'; +import { BoardError, driveNumber } from './trlink.mjs'; const DRIVES = ['sd', 'usb', 'teensy']; const STORAGE_DRIVES = ['sd', 'usb']; @@ -110,7 +110,17 @@ async function listDir(link, { path = '/', drive = 'sd', skip = 0, take = DEFAUL return { path, drive, skip, entries: entries.map(entryOf), next_skip: entries.length === count ? skip + count : null }; } +// A plain TR's firmware has no DMA commands, so it would run their address and +// length bytes as further commands. +async function requireDma(link) { + const { version } = parseBanner(await link.version()); + if (boardKind(version) !== 'tr-plus') { + throw new BoardError(`DMA needs a TeensyROM+; this board is ${version ?? 'not identified'}`); + } +} + async function readScreen(link) { + await requireDma(link); const [memptr] = await link.readMemory(VIC_MEMPTR, 1); const [cia2PortA] = await link.readMemory(CIA2_PORT_A, 1); const address = screenAddress(memptr, cia2PortA); diff --git a/tools/mcp/test/fake-board.mjs b/tools/mcp/test/fake-board.mjs index 04fd15b7c..59608c646 100644 --- a/tools/mcp/test/fake-board.mjs +++ b/tools/mcp/test/fake-board.mjs @@ -97,6 +97,7 @@ async function serve(board, socket) { if (token.length < 2) break; if (board.silent) continue; const value = token.readUInt16BE(0); + board.received.push(value); const command = commands[value]; if (board.busy && value !== TOKENS.FW_CHECK) await refuse(stream, 'Busy!'); else if (command) await command(stream); @@ -115,6 +116,7 @@ export async function startFakeBoard(overrides = {}) { listing: [], directories: ['/'], launched: [], + received: [], ...overrides, }; const sockets = new Set(); diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index 9e09d46d5..0054c0519 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -4,7 +4,8 @@ import assert from 'node:assert/strict'; import { createTools } from '../lib/tools.mjs'; import { linkTarget, openLink } from '../lib/trlink.mjs'; -import { startFakeBoard } from './fake-board.mjs'; +import { TOKENS } from '../lib/protocol.mjs'; +import { BANNER, startFakeBoard } from './fake-board.mjs'; import { schemaProblems } from './schema.mjs'; function checkedAgainstSchema(tool) { @@ -58,6 +59,12 @@ test('read_screen finds the screen through the VIC registers and decodes it', as assert.equal(rows[1], ''); }); +test('read_screen refuses a plain TeensyROM without sending it a DMA command', async (t) => { + const { board, tools } = await boardWithTools(t, { banner: BANNER.replace('TeensyROM+', 'TeensyROM') }); + await assert.rejects(tools.read_screen({}), /DMA needs a TeensyROM\+; this board is TeensyROM v0.8.0.13/); + assert.ok(!board.received.includes(TOKENS.READ_C64_MEM)); +}); + test('push_file replaces a file the board already has', async (t) => { const { board, tools } = await boardWithTools( t, { files: new Map([['/a.prg', Buffer.from('old')]]) }, async () => Buffer.from('new data')); From 6b99a1dac37674e6faf066aceba9abf36a863213 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sun, 1 Nov 2026 14:27:07 -0600 Subject: [PATCH 09/12] tools: launch refuses built-in items that take over the USB serial port The TR BASIC handler reads USB serial itself, so after launching one of its items the board stops answering remote commands. A test pins the list to every IOH_TR_BASIC entry in MainMenuItems.h. --- tools/mcp/lib/tools.mjs | 19 ++++++++++++++++++- tools/mcp/test/tools.test.mjs | 22 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/tools/mcp/lib/tools.mjs b/tools/mcp/lib/tools.mjs index 461f91e4f..d7200a394 100644 --- a/tools/mcp/lib/tools.mjs +++ b/tools/mcp/lib/tools.mjs @@ -19,6 +19,14 @@ const DRIVE_PROPERTY = { default: 'sd', description: "Where the file is: 'sd' (SD card), 'usb' (USB drive) or 'teensy' (the built-in menu).", }; +// The IOH_TR_BASIC items in Source/Teensy/MainMenuItems.h. Their handler reads +// the USB serial port itself, so the board stops answering remote commands. +export const SERIAL_TAKEOVER_ITEMS = new Set([ + 'BASIC TR Command IO access only', + 'BASIC with TeensyROM Commands', + 'DMA Pause Check', + 'Segment+Dot Matrix Digital Clock', +]); const DEFAULT_TAKE = 200; const MAX_TAKE = 1000; @@ -138,7 +146,15 @@ async function pushFile(link, { local_path: localPath, remote_path: remotePath, return { drive, remote_path: remotePath, bytes: data.length }; } +function refuseSerialTakeover(path, drive) { + const name = path.slice(path.lastIndexOf('/') + 1); + if (drive === 'teensy' && SERIAL_TAKEOVER_ITEMS.has(name)) { + throw new BoardError(`'${name}' takes over the USB serial port, and the board stops answering remote commands`); + } +} + async function launch(link, { path, drive = 'sd' }) { + refuseSerialTakeover(path, drive); await link.launch(path, driveNumber(drive)); return { drive, path }; } @@ -207,7 +223,8 @@ const DEFINITIONS = [ name: 'launch', description: 'Launch a file (PRG, CRT, SID, D64 and the other types the menu runs) on the C64, from the ' + 'SD card, USB drive or built-in menu. Ends whatever the C64 was running. A large CRT hands over to the ' - + 'minimal image, which answers only status, launch and reset.', + + 'minimal image, which answers only status, launch and reset. Refuses the built-in items whose handler takes ' + + 'over the USB serial port (TR BASIC, DMA Pause Check, the digital clock).', inputSchema: { type: 'object', properties: { diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index 0054c0519..fff2e99aa 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -1,13 +1,17 @@ // SPDX-License-Identifier: MIT +import fs from 'node:fs'; +import path from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; -import { createTools } from '../lib/tools.mjs'; +import { SERIAL_TAKEOVER_ITEMS, createTools } from '../lib/tools.mjs'; import { linkTarget, openLink } from '../lib/trlink.mjs'; import { TOKENS } from '../lib/protocol.mjs'; import { BANNER, startFakeBoard } from './fake-board.mjs'; import { schemaProblems } from './schema.mjs'; +const MAIN_MENU_ITEMS = path.resolve(import.meta.dirname, '../../../Source/Teensy/MainMenuItems.h'); + function checkedAgainstSchema(tool) { return async (args) => { const result = await tool.run(args); @@ -98,6 +102,22 @@ test('launch sends the drive and path', async (t) => { assert.deepEqual(board.launched, [{ drive: 0, path: '/games/a.crt' }]); }); +test('launch refuses a built-in item whose handler takes over the USB serial port', async (t) => { + const { board, tools } = await boardWithTools(t); + await assert.rejects(tools.launch({ path: '/Utilities/BASIC with TeensyROM Commands', drive: 'teensy' }), + /stops answering remote commands/); + assert.deepEqual(board.launched, []); + await tools.launch({ path: '/BASIC with TeensyROM Commands', drive: 'sd' }); + assert.equal(board.launched.length, 1); +}); + +test('every built-in item with the TR BASIC handler is one launch refuses', () => { + const menu = fs.readFileSync(MAIN_MENU_ITEMS, 'latin1'); + const items = [...menu.matchAll(/IOH_TR_BASIC\s*,\s*\(char\*\)"([^"]+)"/g)].map(([, name]) => name); + assert.ok(items.length > 0); + assert.deepEqual(items.filter((name) => !SERIAL_TAKEOVER_ITEMS.has(name)), []); +}); + test('reset returns once the board has the command', async (t) => { const { tools } = await boardWithTools(t); assert.deepEqual(await tools.reset({}), {}); From 16cedc767c66db356ab8df93b657c68d319a652c Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sun, 1 Nov 2026 14:28:32 -0600 Subject: [PATCH 10/12] tools: reset returns once the menu has finished booting DMA right after a C128's reset can hang its boot, and over TCP the next tool call could land in that window. Each poll reconnects, since a reset from the minimal image restarts the board. --- tools/mcp/README.md | 2 +- tools/mcp/lib/tools.mjs | 49 ++++++++++++++++++++++++++++++----- tools/mcp/test/fake-board.mjs | 11 ++++++-- tools/mcp/test/tools.test.mjs | 14 +++++++--- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/tools/mcp/README.md b/tools/mcp/README.md index 4b1f454ac..85364fff6 100644 --- a/tools/mcp/README.md +++ b/tools/mcp/README.md @@ -11,7 +11,7 @@ An [MCP](https://modelcontextprotocol.io) server that lets an AI agent (Claude C | `read_screen` | The 40-column text screen as 25 lines of text, read by DMA | TR+, main image | | `push_file` | Copies a file from this computer to the SD card or USB drive, replacing one already there | main image, C64 at the menu | | `launch` | Runs a file on the C64 (PRG, CRT, SID, D64, …) | main or minimal image | -| `reset` | Resets the C64 to the TeensyROM menu | main or minimal image | +| `reset` | Resets the C64 to the TeensyROM menu, and returns once the menu has finished booting | main or minimal image | Every tool declares an `outputSchema` and returns a JSON object matching it, as `structuredContent` and as the text of its one content block (for clients older than MCP 2025-06-18, the text alone). Field names and enum values are fixed, so an agent can rely on them; a `null` means the board didn't report that field. diff --git a/tools/mcp/lib/tools.mjs b/tools/mcp/lib/tools.mjs index d7200a394..b4eeff768 100644 --- a/tools/mcp/lib/tools.mjs +++ b/tools/mcp/lib/tools.mjs @@ -28,6 +28,9 @@ export const SERIAL_TAKEOVER_ITEMS = new Set([ 'Segment+Dot Matrix Digital Clock', ]); const DEFAULT_TAKE = 200; +const MENU_BOOT_MS = 60000; +const BOOT_POLL_MS = 500; +const MS_PER_SECOND = 1000; const MAX_TAKE = 1000; const nullable = (schema) => ({ ...schema, type: [schema.type, 'null'] }); @@ -164,12 +167,40 @@ async function reset(link) { return {}; } +const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +async function bootState(connect) { + const link = await connect(); + try { + return parseBanner(await link.version()).boot; + } finally { + link.close(); + } +} + +// DMA in the first moments after a C128's reset can hang its boot, so reset +// returns only once the menu is up. Each poll reconnects: a reset from the +// minimal image restarts the board, which drops a serial connection. +async function waitForMenu(connect, { menuBootMs }) { + const deadline = Date.now() + menuBootMs; + let lastError = null; + while (Date.now() < deadline) { + try { + if (await bootState(connect) === 'complete') return; + } catch (error) { + lastError = error; + } + await sleep(BOOT_POLL_MS); + } + const why = lastError ? ` (${lastError.message})` : ''; + throw new BoardError(`the menu had not finished booting after ${menuBootMs / MS_PER_SECOND} s${why}`); +} + const DEFINITIONS = [ { name: 'status', description: 'Which TeensyROM image is running, its firmware and board type, the host machine, whether ' - + "the menu has finished booting, and whether DMA tools can run. Call this first, and after 'reset' " - + "until menu is 'complete'.", + + 'the menu has finished booting, and whether DMA tools can run. Call this first.', inputSchema: NO_INPUT, outputSchema: STATUS_OUTPUT, annotations: { readOnlyHint: true, openWorldHint: true }, @@ -240,25 +271,29 @@ const DEFINITIONS = [ { name: 'reset', description: 'Reset the C64 to the TeensyROM menu, ending whatever it was running, and bring the board ' - + "back to its main image. The menu takes from under a second to tens of seconds to boot: call 'status' " - + "until menu is 'complete' before using the other tools.", + + 'back to its main image. Returns once the menu has finished booting, which takes from under a second ' + + 'to tens of seconds.', inputSchema: NO_INPUT, outputSchema: RESET_OUTPUT, annotations: { destructiveHint: false, openWorldHint: true }, run: reset, + afterClose: waitForMenu, }, ]; -export function createTools({ connect, readFile = fs.readFile }) { - return DEFINITIONS.map((definition) => ({ +export function createTools({ connect, readFile = fs.readFile, menuBootMs = MENU_BOOT_MS }) { + return DEFINITIONS.map(({ afterClose, ...definition }) => ({ ...definition, run: async (args) => { const link = await connect(); + let result; try { - return await definition.run(link, args, readFile); + result = await definition.run(link, args, readFile); } finally { link.close(); } + await afterClose?.(connect, { menuBootMs }); + return result; }, })); } diff --git a/tools/mcp/test/fake-board.mjs b/tools/mcp/test/fake-board.mjs index 59608c646..13b3fbeb7 100644 --- a/tools/mcp/test/fake-board.mjs +++ b/tools/mcp/test/fake-board.mjs @@ -37,10 +37,15 @@ function commandsFor(board) { return { [TOKENS.FW_CHECK]: (stream) => reply(stream, board.image === 'main' ? TOKENS.FW_FULL : TOKENS.FW_MINIMAL), [TOKENS.VERSION_INFO]: async (stream) => { + const booting = board.bootingFor > 0; + board.bootingFor -= 1; await reply(stream, TOKENS.ACK); - await stream.write(Buffer.from(board.banner, 'latin1')); + await stream.write(Buffer.from(booting ? board.banner.replace('complete', 'in progress') : board.banner, 'latin1')); + }, + [TOKENS.RESET_C64]: (stream) => { + board.bootingFor = board.bootPolls; + return stream.write(Buffer.from('Reset cmd received\n')); }, - [TOKENS.RESET_C64]: (stream) => stream.write(Buffer.from('Reset cmd received\n')), [TOKENS.READ_C64_MEM]: async (stream) => { const address = await readNumber(stream, 2); const length = await readNumber(stream, 2); @@ -117,6 +122,8 @@ export async function startFakeBoard(overrides = {}) { directories: ['/'], launched: [], received: [], + bootPolls: 0, + bootingFor: 0, ...overrides, }; const sockets = new Set(); diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index fff2e99aa..cf500f090 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -20,11 +20,11 @@ function checkedAgainstSchema(tool) { }; } -async function boardWithTools(t, overrides, readFile) { +async function boardWithTools(t, overrides, readFile, options) { const board = await startFakeBoard(overrides); t.after(board.stop); const connect = () => openLink(linkTarget({ TR_HOST: board.host })); - const tools = Object.fromEntries(createTools({ connect, readFile }).map((tool) => [tool.name, checkedAgainstSchema(tool)])); + const tools = Object.fromEntries(createTools({ connect, readFile, ...options }).map((tool) => [tool.name, checkedAgainstSchema(tool)])); return { board, tools }; } @@ -118,9 +118,15 @@ test('every built-in item with the TR BASIC handler is one launch refuses', () = assert.deepEqual(items.filter((name) => !SERIAL_TAKEOVER_ITEMS.has(name)), []); }); -test('reset returns once the board has the command', async (t) => { - const { tools } = await boardWithTools(t); +test('reset returns once the menu has finished booting', async (t) => { + const { board, tools } = await boardWithTools(t, { bootPolls: 2 }); assert.deepEqual(await tools.reset({}), {}); + assert.equal(board.bootingFor, -1); +}); + +test('reset fails when the menu does not finish booting in time', async (t) => { + const { tools } = await boardWithTools(t, { bootPolls: Infinity }, undefined, { menuBootMs: 1500 }); + await assert.rejects(tools.reset({}), /the menu had not finished booting after 1.5 s/); }); test("a refusal carries the board's own words", async (t) => { From 81cabb862c844e7d10a06c2ccd5ec0b11cd3f583 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sun, 1 Nov 2026 14:32:31 -0600 Subject: [PATCH 11/12] fix: review findings for f7d705e --- tools/mcp/lib/tools.mjs | 12 +++++++----- tools/mcp/test/tools.test.mjs | 9 ++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tools/mcp/lib/tools.mjs b/tools/mcp/lib/tools.mjs index b4eeff768..9a00c8ffe 100644 --- a/tools/mcp/lib/tools.mjs +++ b/tools/mcp/lib/tools.mjs @@ -121,12 +121,14 @@ async function listDir(link, { path = '/', drive = 'sd', skip = 0, take = DEFAUL return { path, drive, skip, entries: entries.map(entryOf), next_skip: entries.length === count ? skip + count : null }; } -// A plain TR's firmware has no DMA commands, so it would run their address and -// length bytes as further commands. +// Neither a plain TR nor the minimal image answers DMA commands, so either would +// run their address and length bytes as further commands. async function requireDma(link) { - const { version } = parseBanner(await link.version()); - if (boardKind(version) !== 'tr-plus') { - throw new BoardError(`DMA needs a TeensyROM+; this board is ${version ?? 'not identified'}`); + const { dma, image, firmware } = await status(link); + if (!dma) { + throw new BoardError( + `DMA needs a TeensyROM+ running its main image; this board is ${firmware ?? 'not identified'}` + + (image ? `, ${image} image` : '')); } } diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index cf500f090..432fe6f77 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -65,7 +65,14 @@ test('read_screen finds the screen through the VIC registers and decodes it', as test('read_screen refuses a plain TeensyROM without sending it a DMA command', async (t) => { const { board, tools } = await boardWithTools(t, { banner: BANNER.replace('TeensyROM+', 'TeensyROM') }); - await assert.rejects(tools.read_screen({}), /DMA needs a TeensyROM\+; this board is TeensyROM v0.8.0.13/); + await assert.rejects(tools.read_screen({}), /DMA needs a TeensyROM\+ running its main image; this board is TeensyROM v0.8.0.13, main image/); + assert.ok(!board.received.includes(TOKENS.READ_C64_MEM)); +}); + +test('read_screen refuses the minimal image without sending it a DMA command', async (t) => { + const { board, tools } = await boardWithTools( + t, { image: 'minimal', banner: '\n FW: TeensyROM+ v0.8.0.13(minimal)\r\n' }); + await assert.rejects(tools.read_screen({}), /DMA needs a TeensyROM\+ running its main image/); assert.ok(!board.received.includes(TOKENS.READ_C64_MEM)); }); From 4c83ed5e965666e85411a2a9db19f6b67675dc9d Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Sun, 1 Nov 2026 14:39:53 -0600 Subject: [PATCH 12/12] fix: review findings for 16cedc7 --- tools/mcp/lib/tools.mjs | 3 ++- tools/mcp/test/tools.test.mjs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/tools/mcp/lib/tools.mjs b/tools/mcp/lib/tools.mjs index 9a00c8ffe..9ef0560a5 100644 --- a/tools/mcp/lib/tools.mjs +++ b/tools/mcp/lib/tools.mjs @@ -188,7 +188,8 @@ async function waitForMenu(connect, { menuBootMs }) { let lastError = null; while (Date.now() < deadline) { try { - if (await bootState(connect) === 'complete') return; + // Firmware before v0.9 reports no boot state, so there is nothing to wait for. + if (await bootState(connect) !== 'in progress') return; } catch (error) { lastError = error; } diff --git a/tools/mcp/test/tools.test.mjs b/tools/mcp/test/tools.test.mjs index 432fe6f77..49ae66bd3 100644 --- a/tools/mcp/test/tools.test.mjs +++ b/tools/mcp/test/tools.test.mjs @@ -136,6 +136,39 @@ test('reset fails when the menu does not finish booting in time', async (t) => { await assert.rejects(tools.reset({}), /the menu had not finished booting after 1.5 s/); }); +test('reset keeps polling while the restarting board refuses connections', async (t) => { + const board = await startFakeBoard({ bootPolls: 1 }); + t.after(board.stop); + let connections = 0; + const connect = () => { + connections += 1; + if (connections === 2) return Promise.reject(new Error('connection refused')); + return openLink(linkTarget({ TR_HOST: board.host })); + }; + const reset = createTools({ connect }).find((tool) => tool.name === 'reset'); + assert.deepEqual(await reset.run({}), {}); + assert.equal(board.bootingFor, -1); +}); + +test('a reset that times out names the last connection error', async (t) => { + const board = await startFakeBoard(); + t.after(board.stop); + let connections = 0; + const connect = () => { + connections += 1; + if (connections > 1) return Promise.reject(new Error('connection refused')); + return openLink(linkTarget({ TR_HOST: board.host })); + }; + const reset = createTools({ connect, menuBootMs: 1500 }).find((tool) => tool.name === 'reset'); + await assert.rejects(reset.run({}), /after 1.5 s \(connection refused\)/); +}); + +test('reset returns at once from firmware that reports no boot state', async (t) => { + const { tools } = await boardWithTools( + t, { banner: BANNER.replace(/\s*Boot: complete/, '') }, undefined, { menuBootMs: 1500 }); + assert.deepEqual(await tools.reset({}), {}); +}); + test("a refusal carries the board's own words", async (t) => { const { tools } = await boardWithTools(t, { busy: true }); await assert.rejects(tools.list_dir({}), /directory: the board refused it: Busy!/);