Skip to content

MCP server

Status: preview. This works and is used daily, but it is still being developed and sits outside termapy's compatibility promise: its commands, files and behavior may change in any release.

Termapy can run as an MCP (Model Context Protocol) server, letting an LLM client like Claude drive your serial device through a typed JSON tool surface.

Bare form -- auto-discovers a single .cfg under ./termapy_cfg/ (or the OS config dir):

termapy --mcp

Explicit form -- point at a specific cfg file (recommended for MCP client configs, where cwd is non-deterministic):

termapy --mcp termapy_cfg/mydevice/mydevice.cfg

Either way, the server starts a stdio loop bound to one device. No interactive UI; it speaks MCP frames on stdin/stdout and exposes one tool, run_command, for the client to call.

Architecture: one server per device

Each cfg is its own server process. Two devices on the bench means two termapy --mcp processes -- different ports, different profiles, never conflict. No hot-swap; if you want a different device, point a different MCP client entry at a different cfg.

Requires the [mcp] install extra (or [all]). See Installation.


Part 1: Setting up termapy for a device

A "device setup" is a config folder that holds everything termapy needs to talk to one specific piece of hardware. For MCP use, two files matter:

File Purpose Required?
<name>.cfg Port, baud rate, line ending, etc. Yes
<name>.profile.json Command schema: how requests are sent and how responses are parsed into typed JSON Recommended

Both files live at the top of the cfg folder, alongside <name>.log and <name>.history.

The cfg file

Standard termapy config. The MCP-relevant slice (partial example -- see the field reference for the rest):

{
    "serial": {
        "port": "COM4",
        "baud_rate": 115200
    },
    "eol": "\r\n",
    "auto_connect": true,
    "profile_path": "",
    "mcp_on_connect_cmd": ""
}

profile_path (default empty) lets you point at a profile in a non-conventional location. Empty means "look for <cfg_dir>/<cfg_name>.profile.json" -- the convention. mcp_on_connect_cmd runs a command automatically when the MCP server connects (the MCP counterpart of tui_on_connect_cmd / cli_on_connect_cmd).

The profile file

A device profile declares what commands the device understands and how its responses should be parsed. Every run_command reply uses one fixed envelope -- cmd, success, error, value, data, elapsed_s, output_lines, captured_artifacts, async_events (the --json / /term.request envelope in a terminal carries the first seven; the last two are collected by the MCP host). Without a profile, the raw response text arrives in value and data is null (legacy fallthrough). With a profile, data carries the typed structure, like {"celsius": 23}, with the raw wire text still in value.

The authoritative, machine-readable schema ships inside the package as profile/schema.json -- point an LLM at it (or feed it into your MCP client's context) to draft, audit, or validate profiles automatically.

Minimal example -- one parameter-less command and one with an enum-typed parameter:

{
    "profile_version": 2,
    "profile_revision": "1.0.0",
    "profile_date": "2026-07-03",
    "device": {"name": "MyDevice", "vendor": "ACME"},
    "types": {
        "on_off": {
            "kind": "enum",
            "values": ["on", "off"],
            "help": "LED state: on or off."
        }
    },
    "commands": {
        "temp": {
            "help": "Read board temperature in Celsius.",
            "safety": "readonly",
            "response": {
                "format": "regex",
                "pattern": "Temperature:\\s+(?P<celsius>-?\\d+)\\s*C",
                "types": {"celsius": "int"},
                "timeout_ms": 300
            }
        },
        "set_led": {
            "help": "Turn the status LED on or off.",
            "safety": "mutable",
            "send_template": "led {state}",
            "typed_args": [
                {"name": "state", "type": "on_off", "required": true}
            ],
            "response": {
                "format": "literal",
                "pattern": "OK",
                "timeout_ms": 200
            }
        }
    }
}

temp is parameter-less: the LLM calls run_command("temp"), termapy sends temp\r\n and waits for a reply like Temperature: 23 C. The regex skips the prefix and unit, extracts the digits, the types map coerces them to int, and the call's response carries data={"celsius": 23} (raw wire text in value).

set_led takes one argument: the LLM calls run_command("set_led", state="on"), termapy formats the wire bytes via send_template (led on\r\n), waits for the literal OK reply, and returns success. The types block declares the reusable on_off enum; typed_args references it, so the LLM sees state: "on" | "off" in the tool definition. safety: mutable means it changes device state but doesn't trigger the destructive-confirmation gate.

Full schema, response formats (none, literal, lines, regex, json), the four-tier safety taxonomy (safe / readonly / mutable / destructive), and the request/response executor are documented in Authoring profiles.

Lifecycle on connect / disconnect

When the MCP server connects to its port, it auto-loads the profile from profile_path or the convention path. On disconnect, device-specific state (active profile, catalog, event buffers) is wiped, so reconnecting cleanly re-loads the profile.

Output hygiene: color and echo

An MCP client is an automated peer, not a terminal, so the session defaults to color off: device ANSI escape codes (SGR color, cursor moves) are stripped from async_events and request_mode values, and command echo is off. This keeps escape codes out of the JSON the model parses. For the same reason a markup entry in output_lines carries the text a terminal shows, with termapy's own Rich color tags removed: $(TAG) = [ok], not [cyan]$(TAG)[/] = [green]\[ok][/].

To pass ANSI through anyway -- for a faithful capture, or a client that renders it -- send /term.color on. To silence color (or a half-duplex device's echo) at the source, put the device's own command in mcp_on_connect_cmd (e.g. color off, echo off); it runs automatically on connect. Full-screen / VT100 output (cursor-addressed dashboards and menus) can't be represented as MCP text -- view those in the TUI.

Resources

Besides the run_command tool, the server publishes three resources:

  • termapy://commands.json -- the command catalog (same content as /mcp.catalog).
  • termapy://device_state.json -- port, profile, last command, and the capture files, each as {name, bytes, mtime, age_s, uri}, newest first.
  • termapy://capture/<name> -- the body of one capture file.

Every run_command reply's captured_artifacts lists the capture files a command created or changed, in the same {uri, name, bytes, mtime, age_s} shape, so an agent can order captures by recency without re-listing.

What an MCP client can and can't reach

The MCP server runs with no interactive session, so termapy's capability gate blocks a whole class of host access that only makes sense for a human at a terminal:

  • Shell escape (/os) is unreachable under MCP (needs an interactive session) and separately gated by TERMAPY_OS_CMD_ENABLED.
  • File viewers / editors / explorers (/show <path>, /edit, /cfg.explore, the folder .show/.explore verbs) are unreachable -- they need a local desktop.
  • File access is meant to stay inside the config directory, and mostly does. Captures (/cap.text|bin|struct|hex, /cap.poll file=), reads (termapy://capture/<name>, the .dump commands), /profile.load|save, /cfg.load, /log.dump and generated CRC source (/proto.crc.<lang> file=) refuse an absolute path or ../, and a path-valued cfg key (log_file) can't be pointed outside it via /cfg.auto. Known gaps remain while the server is a preview: some commands resolve a relative name from the folder termapy was started in, some Windows path forms (\x, D:x) pass the check, a few file readers (/profile.validate, /proto.run <path>) are not checked, and /cfg.load accepts any existing file. Load configs and profiles by name, and don't rely on the confinement to contain an agent. To allow host-wide paths, set TERMAPY_MCP_FS_UNCONFINED=1 in the server's shell.
  • Network ports are refused at /port.connect. pyserial "URL ports" (/port.connect socket://host:port, rfc2217://...) would open an outbound TCP connection to an arbitrary host; that command refuses them unless you set TERMAPY_MCP_NET_EGRESS=1. Other routes to a URL port -- /port <url>, /cfg port <url>, a config whose port is a URL -- are not checked yet, so treat this as partial. Physical device ports are unaffected.
  • Environment commands are off under MCP by default. /env (which would dump every variable) and the $(env.NAME) expansion in a command are refused unless you opt in with TERMAPY_MCP_ENV_ENABLED=1 in the server's shell -- env vars routinely hold secrets, and an MCP peer is remote/automated. A config's own $(env.NAME) values still expand when it loads.
  • Destructive commands -- device commands a profile marks safety: destructive, and termapy's own destructive commands (/mem.write, /cfg.delete, the folder .delete commands such as /cap.delete) -- refuse to run without an explicit confirm=true, which a well-behaved client elicits from the user. The check covers the command you send; a destructive command run inside another (/repeat, /run, a capture's cmd=) is not checked yet. The catalog carries the safety tier of every command that declares one.
  • Peripheral read gates need a device file. /mem.dump refuses a sweep over registers and /mem.read refuses a write-only one only for registers a loaded device file describes (/dev.use); with none loaded, peripheral space is ungated memory. Load the part before pointing an agent at /mem.* (see Memory).

Wire-level device control (send bytes, read responses, run profiled commands) is of course available -- that's the point. The gates above are about host access, not device access. The three TERMAPY_MCP_* opt-in flags live in the server's shell, not the cfg, so a shared or device-fetched config cannot turn them on (the same rationale as /os's TERMAPY_OS_CMD_ENABLED). It can still reach the gaps above, so until they close, the MCP client's own permissions are the real boundary: run termapy under a client that asks before it acts, and don't point an agent at configs you didn't write. Each takes any boolean token (1, on, true, yes, ...); a value outside that vocabulary refuses to start, naming the variable, rather than silently meaning off.


Part 2: Hooking termapy into an MCP client

The MCP client is the program the user talks to (Claude Code, Claude Desktop, VS Code, etc.). It launches termapy --mcp as a child process and routes the LLM's tool calls to it.

Claude Code

claude mcp add --scope user termapy-mydevice -- termapy --mcp C:/path/to/termapy_cfg/mydevice/mydevice.cfg

Use --scope user (or --scope project for a team-shared .mcp.json). The default local scope registers the server for the current directory only and it silently stops loading after some restarts -- if the tool vanishes, re-add with an explicit scope.

Claude Desktop

Edit %APPDATA%\Claude\claude_desktop_config.json (Windows) or ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):

{
    "mcpServers": {
        "termapy-mydevice": {
            "command": "termapy",
            "args": ["--mcp", "C:/path/to/termapy_cfg/mydevice/mydevice.cfg"]
        }
    }
}

Restart Claude Desktop. The tool mcp__termapy-mydevice__run_command shows up; the LLM can now drive the device.

For multiple devices, add more entries -- one per cfg. The key (termapy-mydevice) is the tool name prefix the LLM will see.

VS Code

Native MCP support uses .vscode/mcp.json (workspace-scoped) or user settings (global):

{
    "servers": {
        "termapy-mydevice": {
            "command": "termapy",
            "args": ["--mcp", "termapy_cfg/mydevice/mydevice.cfg"]
        }
    }
}

Workspace-scoped is nice for team projects -- commit .vscode/mcp.json and everyone who clones gets the same MCP wiring.

Running from a dev clone

If you're developing termapy itself (or want to test changes without releasing), point the client at uv run and set cwd:

{
    "mcpServers": {
        "termapy-mydevice": {
            "command": "uv",
            "args": ["run", "termapy", "--mcp", "termapy_cfg/mydevice/mydevice.cfg"],
            "cwd": "/path/to/termapy/repo"
        }
    }
}

Edit-restart-test cycle: edit termapy source, restart the MCP client, the new code runs.


Troubleshooting

Symptom Likely cause
Client shows no termapy tool MCP client config not reloaded -- restart the client. In Claude Code, also check the registration scope (see above)
--mcp requires the 'mcp' optional dependency Missing extra: uv tool install "termapy[all]"
run_command responses have data: null, raw text in value only No profile loaded; legacy fallthrough is sending bytes but not shaping the response. Check /mcp.info for the profile revision; if blank, the profile didn't auto-load
Profile doesn't auto-load on connect Check the convention path (<cfg_dir>/<cfg_name>.profile.json) or set profile_path explicitly. The session log will tell you
Need to see what the server is doing Add --mcp-verbose to args; tees the session log to stderr
Several servers running, or uv sync can't replace termapy.exe Every server has the same command line (termapy.exe --mcp). Ask each one /mcp.info for its pid, find it in tasklist / ps, and end a stale one by that PID (taskkill /F /PID <pid>, kill <pid>); its launchers exit with it and release termapy.exe

The session log lives at <cfg_dir>/mcp/session.log. Every dispatch, every TX/RX, every error is recorded there. Open it with /mcp.log (system viewer), /mcp.log.dump (inline), or /mcp.log.path (just the path). Over MCP, /log.dump and $(CFG.LOG_FILE) mean this file too: the session log is the frontend's own, never the TUI's <name>.log. An agent cannot run /log.delete.

See also

  • Authoring profiles -- response formats, safety tiers, the request/response executor
  • Installation -- the [mcp] and [all] extras
  • Configuration -- profile_path, mcp_on_connect_cmd, and the rest of the cfg field reference
  • REPL commands -- /mcp.* and /profile.* work in the TUI/CLI too, useful for inspection and validation