pidp10-mcp
by masto
README.md
# pidp10-mcp
An MCP server for driving an **ITS** session on a **PiDP-10** (simh KA10)
emulator over its raw TCP terminal line.
It exists because generic telnet MCP servers do not work against this target:
they cannot transmit raw control bytes, they tie the TCP connection's lifetime
to tool-call cadence, and they reconnect dead sessions in the background —
which, on a port that maps to a *single* terminal line, produces zombie
connections fighting each other for it.
## What it does differently
- **Raw control bytes get through.** A `~`-escape syntax in `send` puts exact
bytes on the wire: `~z` for the Ctrl-Z that calls ITS, `~e` for the ESC that
DDT prints as `$`, `~xNN` for anything else.
- **The connection belongs to the server process, not to tool calls.** A
background reader drains the socket continuously into a 256 KB scrollback.
Nothing cares how long the client spends thinking between calls; a
three-minute gap is invisible to the session.
- **It never reconnects on its own.** If the socket dies, the session is marked
dead and the next tool response says so. Reopening is an explicit decision.
- **Closes hard.** `close()` sets `SO_LINGER` to zero so the socket is reset
rather than left in a half-closed state that keeps the line marked busy, and
the same teardown runs from `atexit` plus SIGTERM/SIGHUP handlers.
- **Terse responses.** Only output produced since the last call, VT52 noise
stripped, followed by one trailer line. No banners, no echoed inputs, no
re-dumping the session log.
## Install
Requires Python 3.11+ and the official `mcp` SDK 2.x.
```sh
uv sync # or: pip install -e .
uv run pidp10-mcp # stdio transport (default)
```
Register it with an MCP client — for Claude Code:
```sh
claude mcp add pidp10 -- uv --directory /path/to/pidp10/mcp run pidp10-mcp
```
or by hand, in an `mcpServers` config block:
```json
{
"mcpServers": {
"pidp10": {
"command": "uv",
"args": ["--directory", "/path/to/pidp10/mcp", "run", "pidp10-mcp"],
"env": { "PIDP10_HOST": "pidp10.local", "PIDP10_PORT": "10018" }
}
}
}
```
### Streamable HTTP
```sh
uv run pidp10-mcp --http --http-host 127.0.0.1 --http-port 8010
```
## Configuration
| Env var | CLI flag | Default | Meaning |
| ----------------- | -------------- | -------------- | -------------------------------- |
| `PIDP10_HOST` | `--host` | `pidp10.local` | Emulator host |
| `PIDP10_PORT` | `--port` | `10018` | Emulator TCP port (one line) |
| `PIDP10_MCP_HOST` | `--http-host` | `127.0.0.1` | Bind address for `--http` |
| `PIDP10_MCP_PORT` | `--http-port` | `8010` | Bind port for `--http` |
`open(host, port)` can override the host and port per call.
## Escape syntax
Escapes are expanded in `send`'s `input`. An unknown escape is an error rather
than being passed through as text — silently sending `~q` to DDT is worse than
a rejection.
| Escape | Byte | Meaning |
| ------- | ------ | ---------------------------------------------------------- |
| `~z` | `0x1A` | Ctrl-Z — **calls ITS**; a fresh line ignores all other input |
| `~e` | `0x1B` | ESC / altmode — DDT's `$` |
| `~c` | `0x03` | Ctrl-C |
| `~g` | `0x07` | Ctrl-G |
| `~d` | `0x7F` | Rubout |
| `~s` | `0x13` | Ctrl-S |
| `~o` | `0x0F` | Ctrl-O |
| `~r` | `0x0D` | CR, when an explicit one is wanted mid-line |
| `~n` | `0x0A` | LF — a DDT command (examine next location), *not* a newline |
| `~t` | `0x09` | Tab |
| `~xNN` | `0xNN` | Any byte, two hex digits |
| `~~` | `~` | Literal tilde |
| `~-` | — | At the very end: do **not** append the automatic CR |
### Line endings
`send` appends a CR (`0x0D`) automatically, because that is what the line
editor wants, and normalises any literal LF or CRLF in the input to CR. A bare
LF is not a newline on this system — it is a DDT command. If you genuinely
want to send one, `~n` is exempt from normalisation.
`raw=true` sends the expanded bytes verbatim: no CR appended, no normalisation.
## Tools
| Tool | Purpose |
| -------- | ------- |
| `open(host?, port?)` | Connect. Idempotent — reports status if already open. Returns any greeting bytes. |
| `send(input, expect?, timeout_ms=10000, quiet_ms=700, auto_more=true, raw=false)` | Send input, return the output it produced. |
| `read(timeout_ms=2000, expect?, quiet_ms=700, auto_more=true)` | Collect more output without sending anything. |
| `peek(last_n_chars=2000)` | Re-show recent scrollback without moving the read cursor. |
| `status()` | Connected?, host:port, uptime, bytes, pending output, death reason. |
| `close()` | Hard-close the socket so the emulator frees the line. |
### How `send` and `read` decide to return
They return on whichever comes first:
- `expect` (a regex) matches the new output → reason `matched`
- the line has been silent for `quiet_ms` → reason `quiet`
- `timeout_ms` elapses → reason `timeout`
The quiet timer only starts **after the first byte arrives**, so a program that
takes five seconds to say anything is not cut off at 700 ms. A call that sees
no output at all runs to `timeout_ms` and returns `timeout`.
With `auto_more` (default on), a trailing `--More-- (Space=yes, Rubout=no)`
prompt is answered with a space and collection continues, up to 20 pages; the
answered prompts are removed from the returned text. Hitting the cap returns
reason `more_limit`, and `read` continues from there. To flush a pager instead
of paging through it, send `~d` (rubout).
`send` and `read` return **only** output produced since the last call. `peek`
does not move that cursor.
## Output filtering
Raw output carries VT52 escape sequences, NUL padding and occasional telnet
IAC bytes. The filter drops NULs, `ESC`+letter sequences, `ESC Y <row> <col>`
cursor addressing, IAC negotiation (without implementing any telnet stack), and
other nonprinting bytes; it keeps text, tabs and newlines. CR, LF and CRLF all
become a single `\n`. Trailing whitespace and runs of blank lines are collapsed
in returned text to save tokens; the scrollback keeps the unabridged text.
## Typical session
```
open()
send("~z", expect="Happy hacking|ITS") # ^Z calls ITS -> DDT banner
send(":login rms")
send(":listf") # pages collected automatically
send("foo~ej") # f-o-o ESC j CR (DDT's foo$j)
close()
```
A detached but logged-in ITS session gets auto-logged-out after about five
minutes; ITS itself never times out an idle line, so a held-open session is
stable indefinitely.
## Tests
```sh
uv run pytest # offline: filter, escapes, session, tools
PIDP10_LIVE=1 uv run pytest -m live # acceptance tests on a real emulator
PIDP10_LIVE=1 PIDP10_LIVE_SLOW=1 uv run pytest -m live # ...including the 3-minute idle test
```
The offline tests run the whole stack — including the MCP tool layer, via an
in-process client — against a fake TCP line, so no emulator is needed.
Live tests are skipped unless `PIDP10_LIVE=1`; they take the single terminal
line for their duration. They honour `PIDP10_HOST` / `PIDP10_PORT`, plus
`PIDP10_USER` (default `guest`) and `PIDP10_LISTF_DIR` (default `sys;`, which
needs to be a directory big enough to make the pager appear).
The `test_three_minute_gap_does_not_drop_the_session` case sits idle for 190
seconds on purpose — it is the regression test for the failure that motivated
this server — so it needs `PIDP10_LIVE_SLOW=1` as well.
TDQS
A4.2/5.0
Scored across 6 tools
Disambiguation5/5
Each tool has a clearly distinct role: open manages connection, send transmits input, read collects output, peek previews without consuming, status reports state, and close terminates. No overlapping purposes.
Naming Consistency5/5
All tool names are concise single-word verbs (open, send, read, peek, status, close), following a uniform imperative style with no mixed conventions.
Tool Count5/5
Six tools is ideal for a terminal connection server, covering the full lifecycle without redundancy or bloat.
Completeness5/5
The tool surface covers connection setup, sending, reading, non-destructive inspection, status checking, and teardown. No obvious missing operations for the stated purpose of interacting with a PDP-10 terminal line.
Maintenance
ActivitySlowing
ResponsivenessNo issues