Skip to main content
Glama
masto

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