Skip to main content
Glama
pidp10-mcp

pidp10-mcp

by masto

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.

Related MCP server: mcp-ssh-interactive

Install

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" }
    }
  }
}

Streamable HTTP

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

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.

Available Tools

6 tools
closeA

Hard-close the line so the emulator frees it for the next connection.

Inputs: no named parameters.

openA

Open the single TCP terminal line to the PDP-10.

Idempotent: if the line is already open this reports status instead of reconnecting. The connection is owned by the server and survives any gap between tool calls. Nothing reconnects automatically -- if the session dies you will be told, and reopening is your explicit decision.

ITS ignores all input on a fresh line until it receives a literal ^Z, so the usual next step is send("~z").

Inputs: host, port.

peekA

Re-show recent scrollback without consuming it.

Does not move the read cursor, so send and read still return the same output afterwards. Use this to recover lost context.

Inputs: last_n_chars.

readA

Collect further output without sending anything.

For slow programs, or to continue after a more_limit. Same cursor semantics as send: only output not already returned.

Inputs: expect, quiet_ms, auto_more, timeout_ms.

sendA

Send input to ITS and return the output it produced.

A CR (0x0D) is appended automatically -- that is what the line editor wants -- unless input ends with the ~- marker or raw is true. Any literal LF/CRLF in input is normalised to CR, because a bare LF is a DDT command (examine next location) rather than a newline.

Returns only output produced since the previous call, plus a one-word end reason: matched, quiet, timeout, more_limit or dead.

Examples: send("z") calls ITS; send("fooej") sends f-o-o ESC j CR, the DDT altmode form usually written foo$j; send(":login rms", expect="@").

Inputs: input (required), raw, expect, quiet_ms, +2 more.

statusB

Connection state, target, uptime, pending output and any death reason.

Inputs: no named parameters.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    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

    ActivityInactive
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      F
      maintenance
      An MCP server that allows AI models to execute system commands on local machines or remote hosts via SSH, supporting persistent sessions and environment variables.
      1
      45 npm
      28
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      MCP server that enables AI agents to run fully interactive SSH sessions (via tmux) and execute commands like a human operator, with persistent sessions and multiple concurrent connections.
      6
      MIT
    • F
      license
      Not graded
      quality
      D
      maintenance
      MCP server for controlling iTerm2 terminal sessions, enabling screen reading, command execution, keystroke sending, and session management through natural language.
      1
      -
    • A
      license
      Not graded
      quality
      D
      maintenance
      MCP server for interactive TCP connection management, enabling opening listeners, managing sessions, port forwarding, proxy with logging, and local command execution from any MCP host.
      MIT