The Host Harness and MCP Tools

The host harness, tools/vios_harness.py, connects your Mac to the board. It is the only program that opens the board's USB serial port. Everything else on the Mac reaches the board through it: the human CLI (tools/vios_cli.py), MCP clients such as Claude, and the on-board acceptance suite. VIOS runs fine without it. You need the harness only when you want to drive the board from the Mac.

How it fits together

  MCP client (Claude)   tools/vios_cli.py   acceptance scripts
        |  stdio              |  TCP               |  TCP
        |  (via nc)           |                    |
        +-------------+-------+--------------------+
                      |  newline-delimited JSON-RPC on 127.0.0.1:8765
              tools/vios_harness.py   (one process, one lock)
                      |  COBS/CRC frames, 115200 8N1
              /dev/cu.usbserial-XXX  (CH340 on the board)
                      |
              ESP32 USB multiplexer -> eZ80 Forth REPL, supervisor, events
  • One serial owner. The harness opens the port once and keeps it open. All tool calls go through one lock, so requests from the CLI and from Claude never interleave on the wire. Do not open the port with anything else while the harness runs. That includes a serial terminal, esptool, tools/vios_firmware_ab.py and tools/vios_sd_refresh.py. The port is opened exclusively, so a second opener fails anyway.
  • Opening the port resets the board. On this board, opening the USB serial device resets it. The first connect can therefore take a full boot (allow up to 120 s), and every harness restart is also a board restart. Unsaved RAM work is lost when that happens.
  • Two front ends. With --tcp 127.0.0.1:8765 the harness serves newline-delimited JSON-RPC (one JSON object per line) on loopback TCP. Several clients can connect at once and share the board. Without --tcp it speaks MCP over its own stdin/stdout, and only that one client can use it.
  • Loopback only, no authentication. The TCP server refuses any bind address other than 127.0.0.1. Anyone who can open a socket on your Mac can drive the board, so do not forward this port.
  • The protocol. It is JSON-RPC 2.0 with MCP lifecycle version 2025-11-25. On TCP the transport is a plain line protocol, not MCP Streamable HTTP. Frames are limited to 1 MiB.
  • JSON stays on the Mac. The board never parses JSON. Over USB the harness and the ESP32 exchange compact binary frames: console, control, events, host-file requests and link requests each have their own channel. See Architecture.

Starting the harness

Find the board's port first, then start the harness against it:

ls /dev/cu.usbserial-*
lsof /dev/cu.usbserial-210                 # must print nothing: nobody owns the port
lsof -nP -iTCP:8765 -sTCP:LISTEN           # must print nothing: no harness running

.venv/bin/python tools/vios_harness.py --backend resident \
    --port /dev/cu.usbserial-210 --tcp 127.0.0.1:8765

The harness prints VIOS /host/ serves /Users/you/VIOS-host and VIOS TCP listening on 127.0.0.1:8765 on stderr. It does not touch the board until a client calls connect.

Always pass --port. The default (/dev/cu.usbserial-224130) is a suffix from an earlier USB setup. The suffix changes with the USB socket or hub you use, so check ls /dev/cu.usbserial-* each time.

Flag Default Meaning
--backend resident ram Use the installed VIOS firmware. Always pass it. The ram default is the historical bring-up bridge that uploads a temporary RAM image.
--port DEV /dev/cu.usbserial-224130 Serial device of the board.
--tcp 127.0.0.1:PORT stdio Serve loopback TCP instead of MCP stdio. Only 127.0.0.1 is accepted.
--host-root DIR ~/VIOS-host Mac directory the board sees as /host/. Pass --host-root "" to switch the service off.
--image FILE build/vios-resident.bin CPU image used only by the explicit deploy tool.
--log FILE docs/bring-up/vios-harness.jsonl Raw serial evidence log (every byte, timestamped). Git ignores this file.
--fast-console off Historical RAM-bridge option. Resident source always uses atomic framed lines.

Stop the harness with Ctrl+C. When TCP clients disconnect, the harness and the board session keep running.

Connecting Claude (MCP)

The simplest arrangement is one TCP harness shared by you and Claude. Claude's MCP stdio is bridged to the running harness with nc. The bridge opens no serial port of its own:

{
  "mcpServers": {
    "vios": {
      "command": "/usr/bin/nc",
      "args": ["127.0.0.1", "8765"]
    }
  }
}

If no human CLI is needed, the harness can be the MCP server itself. Stop any TCP harness first, because two owners cannot share the port. Use absolute paths:

{
  "mcpServers": {
    "vios": {
      "command": "/path/to/agon-light-2/.venv/bin/python",
      "args": ["/path/to/agon-light-2/tools/vios_harness.py",
               "--backend", "resident", "--port", "/dev/cu.usbserial-210"]
    }
  }
}

The client's first board call must be connect. Listing tools does not open the port.

A raw TCP session looks like this, one JSON object per line:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"me","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"connect","arguments":{}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"evaluate","arguments":{"source":": SQUARE DUP * ;\n7 SQUARE . CR"}}}

Each tool result is a JSON document carried in one text content item. isError is true when the tool failed or the Forth code raised an error. Malformed requests or arguments get JSON-RPC errors. The schemas returned by tools/list are the authority for argument limits.

The CLI

tools/vios_cli.py is a plain terminal console that attaches to the TCP harness:

.venv/bin/python tools/vios_cli.py                 # harness on 127.0.0.1:8765
.venv/bin/python tools/vios_cli.py --port 8765 --timeout 30

Its --port is the TCP port of the harness (default 8765), not the serial device. The CLI has no serial code at all. The other flags are --timeout and --rpc-timeout. --timeout (default 15 s, at most 300) is how long one Forth line may run before the CLI reports it as pending. --rpc-timeout (default 130 s) is how long the CLI waits for any harness reply, and it covers a full board boot.

On start the CLI calls connect and prints VIOS console — :help for commands. Then you type Forth lines. Each line is at most 235 printable ASCII bytes, and tabs become spaces. The prompt shows the session state:

Prompt Meaning
vios> Idle. Type Forth.
compile> A colon definition is unfinished. Finish it with ; or cancel with :abort.
pending> The last line is still running (for example waiting in KEY). Use :input and :await.
uncertain> A request failed in a way that leaves the result unknown. Use :await or :recover.

Tab completes words from the live dictionary. Lines that start with : are CLI commands:

Command What it does
:help [NAME] Without a name, list these commands. With a name, explain a word or command from the live system (kind, help text or SEE source).
:complete PFX List dictionary words starting with PFX. Tab does the same at the prompt.
:graph Module activation order, dependency edges with their CRCs, and dependents.
:events Console text you did not request: lines typed on the Agon keyboard with their results, and console-task output.
:ls [/PATH] List an SD directory (8.3 names, sizes, / for directories). Defaults to /.
:put LOCAL /PATH Upload a local ASCII text file to an SD path (8.3 names).
:get /PATH [LOCAL] Print an SD file, or save it to a new local file (an existing file is never overwritten).
:describe KIND [ID] Typed descriptor: word NAME, module NAME, task N, handle N or error.
:why Explain the last runtime error: message, failing word and offset, callers, and the data stack as it was before unwinding.
:trace WORD [N] Single-step WORD for up to N instructions (default 200) and print each step.
:status Show dictionary and VM resource counters.
:words List all words.
:wb ACTION Workbench open, close, status, view or redraw.
:snapshot [FILE] Summarize live, dirty, staged and persisted state. With FILE, also save the full JSON record (the file must not exist).
:await Keep waiting for a pending line without sending new source.
:resync Recover a lost reply frame at an idle prompt. It never resends your request, so the outcome of that request stays unknown.
:input HEX Send 1 to 16 bytes to a pending line, for example :input 41 for a KEY that waits for A. Hex 1D is reserved. Follow it with :await.
:abort Cancel an unfinished definition at the idle prompt.
:reboot Restart the firmware from a synchronized idle prompt.
:recover Force a supervisor restart even when the session is pending or uncertain. Unsaved RAM work is lost (see below).
:quit Disconnect this client. The harness and the board keep running.

Two rules keep the CLI honest:

  • It never retries. If a request fails halfway, the CLI says so and leaves the result marked uncertain. It never sends the request again.
  • Pending execution blocks new source. KEY gets only the bytes you send with :input.

:recover differs with and without the ZDI wire. With the wire (dev boot), the supervisor halts the eZ80 and reloads the image embedded in the ESP firmware, so all RAM state is gone. Without the wire (untethered boot), the ESP cannot reset the eZ80. :recover then restarts only the ESP32: the resident keeps running with its RAM state, reports itself healthy again when the ESP comes back, and the harness reattaches. If the eZ80 itself is wedged, stop and restart the harness (opening the port resets the whole board) or press the board's reset button. See Troubleshooting.

MCP tool reference

The harness registers 72 tools. Tools marked wire need the ZDI recovery links (a dev boot). On an untethered board the ESP cannot reach the eZ80's debug port.

Session and recovery

Tool Description
connect Attach to the running resident firmware. Nothing is uploaded. Waits up to 120 s for BOOT READY. Safe to call twice.
reboot Restart the firmware and reattach on the same port. force=true abandons pending or unknown execution and loses unsaved RAM state. Optional timeout.
deploy wire. Replace CPU RAM with the --image file and verify every byte. A development and recovery operation; normal attachment never needs it.
resync Recover a lost reply frame at a quiet idle prompt without rebooting. Never replays the request; its outcome is reported as unknown.
abort Cancel input or an unfinished definition at the idle REPL. A program stuck in KEY needs console_input or reboot(force=true).
debug_registers wire. Briefly pause the CPU to read its registers, then restore the previous run state.
memory_read wire. Read 1 to 65536 bytes of RAM in 0x040000..0x0BFFFF, CRC-checked.
memory_write wire. Write 1 to 256 bytes below 0x0AF000 and verify by readback. A trusted systems operation: it can break running code.
resume wire. Resume the existing CPU PC after a debug pause. Does not reboot.

Running Forth

Tool Description
evaluate Evaluate ASCII Forth lines (up to 65536 characters per call, 235 bytes per line). Definitions, stacks, BASE and the engine persist across calls; multi-line definitions work. Optional timeout (default 15 s).
await_result Wait for a timed-out evaluation to finish, without sending any source.
console_input Send 1 to 16 bytes (hex) to a pending evaluation, for example the key a KEY waits for.
events Return and clear console text the host did not request: lines typed on the Agon keyboard with their results, and console-task output.
benchmark Run the built-in validated hardware benchmarks (up to 120 s).

Inspection

Tool Description
words List the resident dictionary.
inspect_word SEE source and word metadata (kind, address) for one word.
stacks The data stack, as .S shows it.
resources Dictionary capacity and observed VM resource counters.
complete Complete a word, outer command or compiler word from the live dictionary (prefix, optional limit).
help Explain an outer command or compiler word, or show a live word's kind, metadata and source.
describe Typed descriptor (kind, identity, generation) for one word, module, task, handle, or the last error.
last_error The last foreground runtime failure captured before unwinding: message, failing word and offset, caller frames, top stack cells, depths, engine, BASE. Includes stale=true if the dictionary changed since.
trace Single-step one word (one instruction per dispatch, any engine) and return each step: word, offset, depths, top of stack. The word's side effects happen as usual.
tasks The four background task slots: state, word, slices, executed steps, stack depths and peaks, output bytes and drops, error.
task_output Drain one background task's output ring (id 1 to 4).
handles Open handles (SD files, pipes, /dev/null, /dev/cons, /dev/tasks) with owner, direction, byte counts and the last I/O error.
snapshot Read-only structured record of live state: modules with exact sources, draft, stacks, BASE, engine and boot slot, plus a live/dirty/staged/persisted summary. Fails closed on a torn read.

Modules and persistence

Tool Description
modules List module versions, CRC32 values (decimal) and pinned dependencies.
module_source Exact retained module source plus a host-computed SHA-256 (previous=true for the retained older generation).
module_stage Validate and stage exact module source with pinned dependencies and an optional test word. Does not activate.
module_activate Activate the staged module after validating it again.
module_discard Discard the staged module.
module_rollback Restore a module's retained previous generation.
module_save Save a module package to SD, by default at its content-addressed path under /VIOS.
module_load Load a saved package from SD, then stage and activate it.
module_graph Dependency edges with CRC satisfaction, reverse dependents and activation order.
develop One explicit cycle: pin named live dependencies, stage (validate and test), activate, optionally save. Reports each step and stops at the first failure without retrying.
load Evaluate a raw Forth source file from SD (or from /host/).
boot_status Confirmed, candidate and current boot slots and the trial state.
boot_manifest Read-only: what a power cycle would load. Lists module name, version, source CRC32 and package health in the confirmed and candidate boot bundles on SD.
boot_trial Write all active modules into the inactive boot bundle and mark it pending. Does not reboot.
boot_confirm Confirm the currently running trial after validation.
boot_revert Select the confirmed or previous boot slot for the next boot. Does not reboot.

SD card

Tool Description
sd_list List an SD directory: 8.3 names, sizes, directory flags.
sd_put Upload text or hex bytes to an SD file through a handle (create or replace; not power-fail atomic).
sd_get Download an SD file of up to 64 KiB as text and hex, with its SHA-256.

Workbench (text editor)

These are the headless equivalents of the workbench keys. Edits are checked against the draft revision so that a stale client cannot overwrite newer work.

Tool Description
wb_status Draft revision and workbench state.
wb_open Open the workbench, keeping any existing draft.
wb_close Close the workbench and keep its draft.
wb_browse Browse MODULES or WORDS, optionally filtering words by module.
wb_select Select a module by name or a word by catalog index.
wb_edit Load a module's exact active source into the draft. A dirty draft is replaced only with discard=true.
wb_new Start a new module draft (name and version). A dirty draft is replaced only with discard=true.
wb_patch Apply one atomic, revision-checked patch of at most 80 ASCII bytes (offset, remove, source).
wb_undo Restore the one source and cursor undo snapshot.
wb_metadata Set the draft's version and optional test word, keeping pinned dependencies.
wb_stage Validate and test the exact draft without activating it.
wb_activate Activate the staged draft after revision and catalog checks.
wb_save Save the active draft to SD.
wb_source Read the exact draft text in bounded chunks with revision checks.
wb_view Read rows of the 80-column workbench screen model with text and packed attributes.
wb_key Inject a semantic workbench key (ASCII, or 256 to 267 for special keys) with modifier bits.
wb_redraw Invalidate and recompose the workbench screen.
wb_flush Send changed screen spans in bounded batches and return the flush counters.
wb_render_stats Cumulative renderer counters and 288 kHz timer ticks (reset=true zeroes them).

These read the ESP32 directly over the control channel. The eZ80 keeps running and is not involved.

Tool Description
esp_stats ESP counters: glyphs drawn, USB console bytes and writes, time the VDP parser was blocked, parser time, frame and vblank timing, and the USB link counters link_requests, link_resent and link_gone. reset=true zeroes them after reading.
esp_console The exact text of the console as the ESP renders it (rows 0 to 57 plus status row 58), read without disturbing the screen.
esp_gui Display server status line (windows, frames, errors, style). With vk, ascii and mods it presses and releases one key at the window manager. With pixels it returns framebuffer colour indices at up to 64 points.
esp_inject Type 1 to 32 characters at the PS/2 boundary, as if on the Agon keyboard (printable, BS, TAB, CR, ESC). The resulting lines appear in events.
esp_audio Audio engine state, read without the CPU: busy voices, master volume, notes started, last tune error, per-voice frequency, wave and position, sample rate and free heap.
esp_probe Key-to-glyph latency probe. With char, arm it for one printable character; without, report n/min/median/p95/max in microseconds.
esp_g0 GUI memory and timing probe (gate G0): heap after a mode change, blitter benchmark, scanline map and tear test. A diagnostic that draws on screen and then restores the console.

Sharing files with the board: /host/

While the harness runs with the resident backend, any path that starts with /host/ on the board refers to a file under --host-root on your Mac (default ~/VIOS-host, created if missing). You can edit on the Mac and run on the Agon:

LOAD /host/app.fth

OPEN, READ, WRITE and CLOSE work on /host/ paths too, like SD paths (see Handles, Pipes and Devices). The service has these limits:

  • Paths are relative to the host root. Absolute paths, .. and symlinks that leave the root are refused.
  • At most four host files are open at a time.
  • Each board request carries at most 240 bytes and waits about one second for the Mac's reply.
  • /host/ exists only while the harness is running and idle enough to answer. Without the harness the board reports an error for /host/ paths. Everything else keeps working.

Snapshots and resync

  • snapshot (CLI :snapshot FILE) records what is live, dirty, staged and persisted: every module with its exact source, the editor draft, the stacks, BASE, the engine and the boot slot. Take one before anything risky, such as a reboot, a firmware install or an experiment. The persisted flag means the module's exact name and CRC32 are in the confirmed boot bundle on SD, so a power cycle brings it back. RAM-only definitions are not persisted.
  • resync (CLI :resync) re-establishes framing when a reply was lost. One example is a byte from the physical keyboard merging into a request. It works only at a quiet idle prompt and never resends the request, because that request may or may not have run.

The USB link itself already recovers lost frames: the ESP numbers every frame it sends and resends frames on request (see Architecture). The harness also repeats one read-only tool once after a resync: snapshot, tasks, handles, last_error, sd_list, sd_get, boot_status, boot_manifest, esp_stats or esp_console. Such results carry wire_loss_recovered: true. Mutating tools are never repeated.

The on-board acceptance suite

tools/vios_board_suite.py runs the acceptance checks on the real board through the TCP harness on 127.0.0.1:8765. Start the harness first (as above), with the board booted into the GUI. The suite has no serial-port option of its own.

.venv/bin/python tools/vios_board_suite.py build/suite-$(date +%Y%m%d)
.venv/bin/python tools/vios_board_suite.py build/suite-gui --only gui,gui_tools
.venv/bin/python tools/vios_board_suite.py build/suite-soak --soak-minutes 30

Each check writes its own JSON report into the output directory. The runner writes suite.json with one status per check and exits non-zero if any check failed. Checks that need an idle task table pause and later restart a running AUTORUN task.

Check Script What it covers
gui vios_gui_accept.py Display server: a decorated window is opened, moved, raised and closed (pixels checked); a Ctrl+S accelerator becomes a COMMAND event; an input and a button widget; no protocol errors; frame work and vblank start within budget. Uses window and surface 3 only.
gui_tools vios_gui_tools_accept.py The GUI tools via injected keys: browser (Ctrl+Shift+B), workspace (Ctrl+Shift+W), inspector (Ctrl+Shift+I), manager (Ctrl+Shift+M, an action is cancelled with Esc); text read-back speed; frame budgets.
console vios_console_accept.py The full-screen text console, read back from the framebuffer with CHAR@; editor and console round trips. Switches with Ctrl+Alt+F1 and back.
audio vios_audio_accept.py TONE, stored tunes, tune errors with slot and offset, looping tunes and STOP, BEEP acknowledgement, VOLUME, ESP heap. Ends with a short melody for you to listen to.
core_words vios_core_words_accept.py The DIRECT, TOKEN and C reference engines agree on a set of core-word cases.
tasks vios_tasks_accept.py Background tasks, preemption and console latency.
pipeline vios_pipeline_accept.py A producer's output feeding a consumer's KEY through a pipe.
handles vios_handles_accept.py Handles, pipes and devices; writes and removes /VIOS/IOTEST.TXT.
tools vios_tools_accept.py Source maps, the SD file service and native TYPE; writes and removes /VIOS/SVCTEST.TXT.
incremental vios_incremental_accept.py Workbench compositor regressions. Replaces the current draft, so it runs only with --with-incremental. Save your draft first.

None of the default checks changes the boot slots, saved modules or the workbench draft. They define temporary RAM words, so take a snapshot first if your RAM session matters. --soak-minutes N adds tools/vios_soak.py afterwards. The soak repeats REPL arithmetic, a snapshot, a 1000-byte pipeline, an editor round trip and, every tenth iteration, an SD round trip, and it stops at the first failure. On firmware main-51 all nine default checks pass.

A few older drivers open the serial port themselves, so stop the harness before you run them. Each takes --port:

  • tools/vios_resident_acceptance.py. Its boot phase changes the SD boot slots and refuses a card with a confirmed user bundle.
  • tools/vios_resident_recovery.py --exercise-resets. This deliberately discards RAM state.
  • tools/vios_coldboot_capture.py. This records cold boots without sending anything.