MCP server for driving an ITS session on a PiDP-10 (simh KA10) emulator over a raw TCP terminal line
Python
0
1 commits
updated Aug 17, 2026
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.
~-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.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.Requires Python 3.11+ and the official mcp SDK 2.x.
uv sync # or: pip install -e .
uv run pidp10-mcp # stdio transport (default)
Register it with an MCP client — for Claude Code:
claude mcp add pidp10 -- uv --directory /path/to/pidp10/mcp run pidp10-mcp
or by hand, in an mcpServers config block:
{
"mcpServers": {
"pidp10": {
"command": "uv",
"args": ["--directory", "/path/to/pidp10/mcp", "run", "pidp10-mcp"],
"env": { "PIDP10_HOST": "pidp10.local", "PIDP10_PORT": "10018" }
}
}
}
uv run pidp10-mcp --http --http-host 127.0.0.1 --http-port 8010
| 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.
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 |
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.
| 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. |
send and read decide to returnThey return on whichever comes first:
expect (a regex) matches the new output → reason matchedquiet_ms → reason quiettimeout_ms elapses → reason timeoutThe 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.
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.
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.
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.
Python
100.0%
MCP server for driving an ITS session on a PiDP-10 (simh KA10) emulator over a raw TCP terminal line
Python
0
1 commits
updated Aug 17, 2026
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.
~-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.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.Requires Python 3.11+ and the official mcp SDK 2.x.
uv sync # or: pip install -e .
uv run pidp10-mcp # stdio transport (default)
Register it with an MCP client — for Claude Code:
claude mcp add pidp10 -- uv --directory /path/to/pidp10/mcp run pidp10-mcp
or by hand, in an mcpServers config block:
{
"mcpServers": {
"pidp10": {
"command": "uv",
"args": ["--directory", "/path/to/pidp10/mcp", "run", "pidp10-mcp"],
"env": { "PIDP10_HOST": "pidp10.local", "PIDP10_PORT": "10018" }
}
}
}
uv run pidp10-mcp --http --http-host 127.0.0.1 --http-port 8010
| 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.
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 |
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.
| 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. |
send and read decide to returnThey return on whichever comes first:
expect (a regex) matches the new output → reason matchedquiet_ms → reason quiettimeout_ms elapses → reason timeoutThe 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.
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.
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.
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.
Python
100.0%