diff --git a/tools/lib/trserial.mjs b/tools/lib/trserial.mjs index 9e83f1a2..c259f6e2 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 e8031bd5..1eb1afe1 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/.claude-plugin/plugin.json b/tools/mcp/.claude-plugin/plugin.json new file mode 100644 index 00000000..9144ed77 --- /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 00000000..f26a38c1 --- /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 00000000..85364fff --- /dev/null +++ b/tools/mcp/README.md @@ -0,0 +1,55 @@ +# 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, 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. + +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/banner.mjs b/tools/mcp/lib/banner.mjs new file mode 100644 index 00000000..a4eaaba4 --- /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/lib/mcp.mjs b/tools/mcp/lib/mcp.mjs new file mode 100644 index 00000000..dcb18172 --- /dev/null +++ b/tools/mcp/lib/mcp.mjs @@ -0,0 +1,94 @@ +// 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]; +} + +// 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 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, structured) { + const tool = tools.find((candidate) => candidate.name === params?.name); + if (!tool) return errorResult(`no tool named '${params?.name}'`); + try { + return successResult(await tool.run(params.arguments ?? {}), structured); + } catch (error) { + return errorResult(error.message); + } +} + +function handlerFor(server) { + let structured = true; + return { + 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((tool) => listing(tool, structured)) }), + 'tools/call': (params) => callTool(server.tools, params, structured), + }; +} + +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 00000000..b02fe9ed --- /dev/null +++ b/tools/mcp/lib/protocol.mjs @@ -0,0 +1,83 @@ +// 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; +} + +// 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. 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 (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'); +} + +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 00000000..8459e675 --- /dev/null +++ b/tools/mcp/lib/screen.mjs @@ -0,0 +1,76 @@ +// 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_upper', + upperGraphics: 'upper_graphics', + custom: 'custom', +}; + +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 SYMBOLS_AFTER_Z = '[\u00a3]\u2191\u2190'; +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 (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); + } + 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 00000000..9ef0560a --- /dev/null +++ b/tools/mcp/lib/tools.mjs @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: MIT +// +// 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'; + +import { boardKind, parseBanner } from './banner.mjs'; +import { CHARSETS, CIA2_PORT_A, SCREEN_BYTES, VIC_MEMPTR, charsetIn, screenAddress, screenRows } from './screen.mjs'; +import { BoardError, driveNumber } from './trlink.mjs'; + +const DRIVES = ['sd', 'usb', 'teensy']; +const STORAGE_DRIVES = ['sd', 'usb']; +const DRIVE_PROPERTY = { + type: 'string', + enum: DRIVES, + 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 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'] }); +const objectSchema = (properties) => ({ + type: 'object', properties, required: Object.keys(properties), additionalProperties: false, +}); +const NO_INPUT = { type: 'object', properties: {} }; + +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, + machine: banner.machine, + video: banner.video, + menu: banner.boot, + dma: image === 'main' && board === 'tr-plus', + }; +} + +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 }); + return { path, drive, skip, entries: entries.map(entryOf), next_skip: entries.length === count ? skip + count : null }; +} + +// 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 { 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` : '')); + } +} + +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); + const charset = charsetIn(memptr, cia2PortA); + const rows = screenRows(await link.readMemory(address, SCREEN_BYTES), charset); + return { address, charset, rows: rows.map((row) => row.trimEnd()) }; +} + +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 { 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 }; +} + +async function reset(link) { + await link.reset(); + 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 { + // 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; + } + 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.', + 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, 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: { + 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 }, + }, + }, + outputSchema: LIST_DIR_OUTPUT, + annotations: { readOnlyHint: true, openWorldHint: true }, + run: listDir, + }, + { + name: 'read_screen', + 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, + }, + { + 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: 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. 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: { + path: { type: 'string', description: 'Path of the file, starting with /.' }, + drive: DRIVE_PROPERTY, + }, + 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 ' + + '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, menuBootMs = MENU_BOOT_MS }) { + return DEFINITIONS.map(({ afterClose, ...definition }) => ({ + ...definition, + run: async (args) => { + const link = await connect(); + let result; + try { + result = await definition.run(link, args, readFile); + } finally { + link.close(); + } + await afterClose?.(connect, { menuBootMs }); + return result; + }, + })); +} diff --git a/tools/mcp/lib/transport.mjs b/tools/mcp/lib/transport.mjs new file mode 100644 index 00000000..cd56a1fb --- /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 00000000..f50f922c --- /dev/null +++ b/tools/mcp/lib/trlink.mjs @@ -0,0 +1,149 @@ +// 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) { + const target = Buffer.concat([Buffer.of(drive), pathField(path)]); + await this.#send(toBoard(TOKENS.LAUNCH_FILE)); + await this.#ack('launch'); + await this.#stream.write(target); + 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) { + const target = Buffer.concat([Buffer.of(drive), pathField(path)]); + await this.#send(toBoard(TOKENS.DELETE_FILE)); + await this.#ack('delete'); + await this.#stream.write(target); + 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 }) { + 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(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); + 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) { + // 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)}`); + } +} + +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 00000000..e91f0622 --- /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/banner.test.mjs b/tools/mcp/test/banner.test.mjs new file mode 100644 index 00000000..5ad7f4d3 --- /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); +}); diff --git a/tools/mcp/test/fake-board.mjs b/tools/mcp/test/fake-board.mjs new file mode 100644 index 00000000..13b3fbeb --- /dev/null +++ b/tools/mcp/test/fake-board.mjs @@ -0,0 +1,142 @@ +// 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; +// 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'; + +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) => { + const booting = board.bootingFor > 0; + board.bootingFor -= 1; + await reply(stream, TOKENS.ACK); + 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.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); + 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(''))); + return 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; + 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); + if (!board.busy && MENU_ONLY.has(value)) await stream.write(STATS_TRAILER); + } +} + +export async function startFakeBoard(overrides = {}) { + const board = { + image: 'main', + banner: BANNER, + busy: false, + silent: false, + memory: Buffer.alloc(0x10000, 0x20), + files: new Map(), + listing: [], + directories: ['/'], + launched: [], + received: [], + bootPolls: 0, + bootingFor: 0, + ...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 00000000..35dd31be --- /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/schema.mjs b/tools/mcp/test/schema.mjs new file mode 100644 index 00000000..1c51ba8e --- /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', +]); + +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/screen.test.mjs b/tools/mcp/test/screen.test.mjs new file mode 100644 index 00000000..999c8fa4 --- /dev/null +++ b/tools/mcp/test/screen.test.mjs @@ -0,0 +1,40 @@ +// 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); +}); + +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/server.test.mjs b/tools/mcp/test/server.test.mjs new file mode 100644 index 00000000..f4a25421 --- /dev/null +++ b/tools/mcp/test/server.test.mjs @@ -0,0 +1,82 @@ +// 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))); + 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.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) => { + 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 00000000..49ae66bd --- /dev/null +++ b/tools/mcp/test/tools.test.mjs @@ -0,0 +1,198 @@ +// 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 { 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); + assert.deepEqual(schemaProblems(result, tool.outputSchema), [], `${tool.name} output`); + return result; + }; +} + +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, ...options }).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.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 and no menu state', async (t) => { + const { tools } = await boardWithTools(t, { image: 'minimal', banner: '\n FW: TeensyROM+ v0.8.0.13(minimal)\r\n' }); + 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) => { + const { board, tools } = await boardWithTools(t); + board.memory[0xd018] = 0x17; + board.memory[0xdd00] = 0x97; + board.memory.set([0x08, 0x09, 0x20, 0x54, 0x52], 0x0400); + 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('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\+ 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)); +}); + +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.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'); +}); + +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 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.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.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('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 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('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!/); +}); + +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/); +}); + +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 assert.rejects(tools.launch({ path: '/aĀŤǮ' }), /Latin-1/); + await tools.launch({ path: `/${'a'.repeat(356)}` }); + assert.deepEqual(board.launched.map(({ path }) => path.length), [357]); +}); diff --git a/tools/mcp/test/transport.test.mjs b/tools/mcp/test/transport.test.mjs new file mode 100644 index 00000000..ded9cf02 --- /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'); +});