Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 5 additions & 18 deletions tools/lib/trserial.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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';
}
29 changes: 1 addition & 28 deletions tools/lib/trserial.test.mjs
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
});
24 changes: 24 additions & 0 deletions tools/mcp/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
}
}
12 changes: 12 additions & 0 deletions tools/mcp/.mcp.json
Original file line number Diff line number Diff line change
@@ -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}"
}
}
}
}
55 changes: 55 additions & 0 deletions tools/mcp/README.md
Original file line number Diff line number Diff line change
@@ -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).
36 changes: 36 additions & 0 deletions tools/mcp/lib/banner.mjs
Original file line number Diff line number Diff line change
@@ -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';
}
94 changes: 94 additions & 0 deletions tools/mcp/lib/mcp.mjs
Original file line number Diff line number Diff line change
@@ -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)));
}
83 changes: 83 additions & 0 deletions tools/mcp/lib/protocol.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading