← Back to Portfolio

Introducing PTY-MCP — The MCP Server That Makes Terminals Interactive

An open-source MCP server that solves the fundamental limitation of AI agents: they can only run non-interactive commands. PTY-MCP gives AI agents a real PTY session — local shell, SSH, serial port, and persistent sessions that survive disconnects.

Go MCP PTY SSH Serial Port Open Source MIT License

What is PTY-MCP?

PTY-MCP is an MCP (Model Context Protocol) server that gives AI agents interactive terminal sessions. Unlike standard shell tools that run a single command and exit, PTY-MCP maintains a live PTY (pseudo-terminal) — the same kind of terminal emulation that makes SSH, Python REPL, and router CLIs work.

When an AI agent needs to do something that requires interaction — typing a password, navigating a menu, watching a long-running process — PTY-MCP is the answer.

MCP Tools

ToolDescription
create_local_sessionStart a local interactive shell (bash, python3, node, etc.). Supports log_file, log_max_size, log_max_files for log rotation.
create_ssh_sessionSSH to a remote host (supports SSH config aliases). Same log options as local session.
create_serial_sessionConnect to a serial port device (IoT, embedded systems, network gear). Same log options.
send_inputSend input to the session. raw=true skips newline (for single-char menus). wait_for/wait_for_timeout combines send+wait in one call (v0.7.0). Returns cursor_start/cursor_end and timed_out.
read_outputRead session output. wait_for blocks until a regex pattern appears. since_cursor for incremental reads. max_bytes for chunked reads with has_more.
send_secretPrompts the human operator with a native password dialog. The password is sent directly to the PTY — the AI never sees it. Returns {"success": true, "length": N}.
prepare_secretPre-stages a password before it's needed. Shows the dialog immediately and buffers the credential in the session. auto_send: true fires when password_prompt is detected. line_ending controls \r/\r\n/\n per device. Never logged. (v0.9.0)
get_session_stateReturns session state without reading raw output: state (at_prompt / password_prompt / confirmation / pager / running / unknown), awaiting_secret, last_prompt.
resize_sessionResize the terminal window (rows / cols) for any session type. Validated to ≤ 500 rows / ≤ 1000 cols. (v0.10.0)
get_credential_bundleGenerate a signed HPKE ConsumerBundle (Ed25519 identity key + ephemeral X25519 session key). Pass this to cred-mcp; it seals the credential and returns a SealedBox. Plaintext never reaches the AI. (v0.11.0)
inject_secretReceive a SealedBox from cred-mcp, HPKE-decrypt locally, write directly to the PTY via WriteRaw, zero memory. Returns {"success":true} — the AI never sees the plaintext. Session keys are single-use. (v0.11.0)
send_controlSend control keys: ctrl+c, ctrl+d, arrow keys, tab, escape.
list_sessionsList all active sessions.
close_sessionClose a session and terminate the PTY.
detach_sessionDisconnect from a session while keeping the remote PTY running.
list_remote_sessionsList persistent sessions on a remote host. status param for filtering.

We Need an Interactive Terminal for AI Agents

All AI coding agents (Claude Code, Cursor, Copilot) run inside a non-interactive shell. This means they cannot:

PTY-MCP solves all of these by giving the AI agent a real PTY session.

The Conversation That Needs PTY-MCP

User: Run certbot to renew the SSL certificate.
AI: I cannot complete this — certbot requires interactive input
(domain confirmation, agreement prompts). My shell is non-interactive.
User: Connect to the router and check the firewall rules.
AI: I connected via SSH, but this router uses an interactive menu system.
I cannot navigate menus in a non-interactive shell.
User: Deploy to production and tail the logs until it's healthy.
AI: I can start the deployment, but I cannot maintain a live session
or reconnect if the connection drops. I cannot complete this task.

Tools, and Why They Exist

send_secret — The AI should not see your password

The problem: the AI needs to type a password (sudo, SSH, enable mode), but you don't want the password in the AI's context or logs. send_secret solves this by popping a native OS password dialog on the operator's screen. The password goes directly into the PTY. The AI only receives {"success": true, "length": 12}.

# AI encounters a sudo prompt
get_session_state(session_id)
→ { state: "password_prompt", last_prompt: "[sudo] password:" }
send_secret(session_id)
→ native dialog appears on your screen
→ AI receives: { success: true, length: 12 }
→ password never touches AI context

get_session_state — Ask what the terminal is waiting for

Before sending input, the AI needs to know what the terminal is doing. Parsing raw output is fragile. get_session_state runs a classifier on the last 2KB of output and returns a structured answer: at_prompt, password_prompt, confirmation, pager, running, or unknown.

# Is the terminal ready for input?
get_session_state(session_id)
→ { state: "at_prompt", last_prompt: "admin@router#" }
# Is it waiting for yes/no?
→ { state: "confirmation", last_prompt: "Do you want to continue? [Y/n]" }

wait_for — Stop polling, start waiting

Without wait_for, AI agents use sleep 30 && check_status loops — burning CPU cycles and API tokens waiting for things to happen. wait_for blocks server-side until a regex pattern appears in the output. Less polling, less energy, better for polar bears. 🐻‍❄️

# Wait for server reboot — one tool call, no polling
create_local_session("ping myserver")
read_output(session_id, wait_for: "bytes from", timeout: 300)
→ blocks until server responds (~80s), returns immediately on match
# v0.7.0: combine send + wait in one call
send_input(session_id, "systemctl restart nginx",
wait_for: "Active:", wait_for_timeout: 30)
→ { output: "...", timed_out: false }

raw=true — For menus that don't want Enter

Some CLIs (Sophos XG, BIOS menus, router selection screens) expect a single character — pressing Enter breaks the flow. send_input with raw=true sends input without appending a newline.

# Sophos XG firewall menu: select option 3
send_input(session_id, "3", raw: true)
read_output(session_id, wait_for: "Select Menu Number", timeout: 10)
→ navigates into submenu without pressing Enter

log_file + log rotation — Full record of long-running tasks

For deployments, builds, and audit sessions, every line of PTY output is written to a log file in real time. Add log_max_size and log_max_files to rotate automatically — no unbounded log growth.

# Full audit log for network device access
create_ssh_session(host: "switch-01", user: "admin",
log_file: "/tmp/audit.log",
log_max_size: 10485760, log_max_files: 5)
→ every keystroke and response is logged
→ auto-rotates at 10MB, keeps last 5 files

audit log — know what your agent did

When an AI agent runs commands on your server, you may need a full record of what was sent and what came back — for compliance, debugging, or simply understanding what happened. v0.8.0 adds a built-in audit log system.

pty-mcp audit serve starts an HTTP log collector on your server. Every send_input call generates two log entries: one before execution (the command), one after (the output). send_secret is never logged — passwords stay out of the audit trail by design.

# One-time setup on your log server
pty-mcp audit init
→ generates ~/.config/pty-mcp/config with random token (chmod 600)
→ prints setup instructions
pty-mcp audit serve --port 9090
→ starts JSONL log collector at http://localhost:9090
# Enable audit on the pty-mcp side
pty-mcp audit enable
→ uncomments audit-url in config, activates logging
# Disable without losing config
pty-mcp audit disable
→ comments out audit-url, logging stops, token preserved for re-enable
Two modes: strict (stop if audit fails — for compliance environments) or best-effort (log when possible, continue either way). Set via --audit-mode flag or config file.

audit log credential redaction — sensitive data stays out of logs (v0.10.0)

Audit logs are only useful if they're safe to store. v0.10.0 adds automatic scrubbing before any command or output is written to the audit log. Patterns like password=, token=, api_key=, Authorization: Bearer/Basic/Token headers, and PEM private key blocks are replaced with [REDACTED] or [PRIVATE KEY REDACTED]. You get a full operation record without credential exposure.

resize_session — make full-screen tools work correctly (v0.10.0)

Tools like vim, htop, tmux, and router CLIs render based on terminal dimensions. If the AI agent opens a session with default dimensions, these tools look broken. resize_session lets the agent set rows and cols to match the actual display, for all session types (local, SSH, serial, persistent).

# Set terminal size before launching a full-screen tool
resize_session(session_id, rows: 40, cols: 220)
send_input(session_id, "htop")
→ htop renders correctly at 40×220

get_credential_bundle + inject_secret — credentials sealed end-to-end, plaintext never in context (v0.11.0)

The problem: the AI needs to authenticate to a system (SSH password, sudo, enable mode), but send_secret requires a human at the keyboard. What if you want fully automated credential injection — no human in the loop — without the password ever appearing in the LLM context?

v0.11.0 adds a first-class HPKE credential delivery protocol that integrates with cred-mcp. The AI generates a cryptographic identity and a one-time session key, passes the public bundle to cred-mcp, receives back an encrypted SealedBox, and hands that to pty-mcp for local decryption and direct PTY injection. The LLM context only ever contains ciphertext.

# Authenticate over SSH — no plaintext in the AI context
bundle = get_credential_bundle(session_id) → ConsumerBundle (public keys only)
sealed = cred_mcp.vault_copy(item_id, bundle) → SealedBox from cred-mcp
inject_secret(session_id, sealed_box=sealed) → decrypt locally, write to PTY
→ {"success": true} — AI never saw the password

Session keys are single-use: a second inject_secret call with the same session_id always errors, preventing replay. Audit events (bundle_generated, secret_injected, inject_failed) are sent to the audit collector — no plaintext, ciphertext, or key material is logged.

persistent + detach — Start a task, come back later

SSH connections drop. Long builds take hours. With persistent: true, PTY-MCP routes through ai-tmux — a daemon on the remote server that keeps the PTY alive after disconnect. Detach, close Claude Code, come back tomorrow.

# Start a deployment, detach immediately
create_ssh_session(host: "prod", user: "deploy", persistent: true)
send_input(session_id, "./deploy.sh")
detach_session(session_id)
→ deployment continues on server, connection closed
# Hours later: reconnect and check result
list_remote_sessions(host: "prod", user: "deploy")
create_ssh_session(host: "prod", user: "deploy", session_id: "abc123")
read_output(session_id, wait_for: "SUCCESS|FAILED", context_lines: 20)

Installation

# Claude Code Plugin (recommended)
claude plugin marketplace add raychao-oao/pty-mcp
claude plugin install pty-mcp@pty-mcp
→ Restart Claude Code twice — binary downloads automatically
# Update
claude plugin marketplace update pty-mcp
claude plugin update pty-mcp@pty-mcp
→ Restart Claude Code twice — new binary downloads automatically
# Manual install
curl -fsSL https://raw.githubusercontent.com/raychao-oao/pty-mcp/main/install.sh | sh
claude mcp add pty-mcp -- /usr/local/bin/pty-mcp

Tested on: macOS, WSL, Linux with KDE Plasma. Binaries are published for other platforms, but those are untested on real hardware — the GUI password dialog in particular varies by desktop environment (kdialog on KDE, zenity on GNOME).

Current version: v0.11.6  ·  License: MIT  ·  Built with Go  ·  github.com/raychao-oao/pty-mcp
GitHub →
← Back to Portfolio