Skip to content

tools: an MCP server for driving a TeensyROM from an AI agent - #86

Draft
kfox wants to merge 12 commits into
SensoriumEmbedded:mainfrom
kfox:mcp-server
Draft

kfox wants to merge 12 commits into
SensoriumEmbedded:mainfrom
kfox:mcp-server

Conversation

@kfox

@kfox kfox commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Adds tools/mcp, an MCP server that lets an AI agent (Claude Code, Claude Desktop, or any MCP client) drive a TeensyROM over the remote command protocol in docs/ControlComms.md. It is zero-dependency Node 24, like the rest of tools/, and the folder is also a Claude Code plugin.

Tools

Tool Protocol behind it
status: image (main, minimal, none), firmware, board type, machine, menu boot state, DMA available FW Check, Version Info
list_dir, with paging Get Dir NDJSON
read_screen: the 40-column text screen as text Read C64 Mem
push_file, replacing any file at that path Delete File, Post File
launch Launch File
reset Reset C64

Every tool declares an outputSchema and returns JSON matching it (structuredContent, plus the same JSON as text for clients before MCP 2025-06-18), so agents get fixed field names and enums instead of prose. The tests check each result against its schema.

Each call opens its own connection, over USB serial (macOS/Linux) or the TCP listener (TR_HOST), and closes it, so other TeensyROM apps can reach the board between calls. A refusal comes back as a tool error with the board's own words (Busy! and so on). Paths the firmware's path reader would overrun, or that would encode to a NUL, are refused before anything is sent.

Some calls would leave the board in a bad state, so the server refuses or waits:

  • read_screen sends no DMA command unless the board is a TR+ running its main image. A plain TR's firmware has no DMA commands and would run the address and length bytes as further commands.
  • launch refuses the built-in items run by the TR BASIC handler (both TR BASIC items, DMA Pause Check, the digital clock). That handler reads USB serial itself, so the board stops answering remote commands. A test checks the list against every IOH_TR_BASIC entry in MainMenuItems.h.
  • reset returns once the menu reports Boot: complete, reconnecting for each poll. DMA right after a C128's reset can hang its boot. Firmware before v0.9 reports no boot state, and there reset returns at once.

parseBanner moves from tools/lib/trserial.mjs to tools/mcp/lib/banner.mjs, so the flash tool and the server share it. It now also reads the machine, video standard and Boot: state; trserial.mjs re-exports it.

Verification

  • npm test passes on macOS, and in node:24 on linux/amd64 in Docker. New tests run against a fake board on a local TCP port, drive server.mjs over stdio, and pin every token the server sends to its constant in Source/Teensy.
  • On a TR+ (v0.9) in an NTSC C64, over USB serial, through server.mjs:
    • status reported the main image, the machine and menu: complete.
    • read_screen returned the TR menu as text.
    • list_dir paged the SD root.
    • push_file wrote a BASIC PRG into a new directory, then again over the same file.
    • launch ran it, and read_screen showed its output.
    • list_dir away from the menu returned Busy!.
    • reset brought the menu back.
  • The read_screen, launch and reset refusals and waits above were tested against the fake board only.
  • TCP was exercised only against the fake board.
  • claude plugin validate tools/mcp passes.

Review

  • The server commits got one review at medium. It fixed binary trailer bytes leaking into refusal messages, and the symbols after Z decoding as ..

  • The path guard got one review at low; it found nothing.

  • A final branch-wide pass at medium closed a way around the guard: non-Latin-1 characters encoded to NUL or command bytes.

  • That fix got one low review.

  • The structured output got one review at medium. It made the test schema checker refuse keywords it doesn't enforce, and that fix got one low review.

  • The three board rails got a review each (low, low, medium) and one branch pass over all three at medium. The reviews made read_screen refuse the minimal image as well, made reset return at once on firmware without a boot state, and added tests for a poll whose connection drops.

Every fix landed with a test that failed first.

kfox added 12 commits October 31, 2026 12:50
The MCP server needs it too, so it moves where both can share it.
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.
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.
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.
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.
Agents get fixed field names and enums instead of prose to parse, which
costs fewer tokens and gives the same shape every call.
A plain TR's firmware has no DMA commands, so it ran the read's address
and length bytes as further commands.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant