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.
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, 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 → observe ...) → shell_close
After every shell_send_line, you must call shell_output (or shell_wait_for) to confirm the state before deciding the next step. Never batch commands speculatively. For control characters (Ctrl+C, Ctrl+D), use shell_send_control instead of embedding raw bytes in shell_send.
Features
- One-shot execution —
execwith configurable shell interpreter (bash/sh/zsh/python/node...), timeout, and non-blocking output capture - Stateful interactive sessions — Full lifecycle management across 16 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 support —
shell_wait_forwith pattern matching and timeout for uncertain-duration commands (gdb continue, SSH handshake, large downloads); poll-based observation as fallback - Built-in prompt templates — Guided step-by-step workflows for GDB/pwndbg debugging, SSH connections, sudo password handling, and CTF reverse shell listener setup
- Resource documentation — Inline guides via
guide://shell/*URIs (security policy, lifecycle basics, per-scenario recipes) - PTY (Pseudo-Terminal) mode — Spawn sessions with
pty=truefor programs that require a real terminal (sudo, colored output, tty-sensitive tools); observe rendered screen viashell_snapshotor drive full-screen TUI programs (vim/nano/htop/less/whiptail/menuconfig) viashell_send_keys+ cursor control tools - 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 (pipe / pty) │ │
│ │ DashMap<tag, Arc<Mutex<Shell>> │ │
│ └────────────────────────────────┘ │
│ ┌────────────────────────────────┐ │
│ │ audit::with_audit() │ │
│ │ → JSON logs per command │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
Quick Start
Install
The binary terminal-mcp will be placed in ~/.cargo/bin/. Ensure this directory is in your PATH.
Build from Source
Binary at target/release/terminal-mcp.
Configure MCP Client
Add to your MCP client configuration (e.g., opencode.json):
Environment Variables
| Variable | Default | Description |
|---|---|---|
RUST_LOG |
warn,audit=info |
Log level filter |
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
-
shell_spawn(shell, tag, pty?, cols?, rows?)— Create a session with the specified interpreter (bash/sh/zsh/python/node...). Setpty=trueto spawn in PTY mode (default window size 100x40). Interactive programs like gdb/ssh must NOT be passed directly asshell; spawn bash first, then send the program as a command. -
shell_send_line(input, tag)— Send a command with a trailing newline (equivalent to pressing Enter). Returns"sent"immediately without output — always follow withshell_outputorshell_wait_for. -
shell_send(input, tag)— Send raw bytes without a trailing newline. Not recommended for control characters — useshell_send_controlinstead. -
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. -
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 withshell_snapshot. Unknown bracket tags return an explicit error rather than being silently sent as text. Seeguide://shell/tui. -
shell_output(tag, idle_ms?)— Read buffered stdout/stderr. Waits until output is silent foridle_ms(default 200ms) before returning incremental output. Must be called after every send_line to confirm state (or useshell_wait_for). -
shell_wait_for(tag, pattern, timeout_ms?)— Block untilpatternappears in stdout/stderr or timeout elapses (default 5000ms). Returnsstdout,stderrand amatchedboolean. Prefer this over repeatedshell_outputcalls for uncertain-duration commands. -
shell_snapshot(tag, idle_ms?)— Get a rendered virtual terminal screen snapshot plus the current cursor position (pty sessions only). Returns{ "screen": "...", "cursor": {"row":.., "col":..} }(cursor 0-based, null if unavailable). Use instead ofshell_outputin PTY mode to see the actual rendered screen after ANSI escape interpretation. -
shell_cursor_position(tag)— Get just the current cursor (row, col; 0-based) without a full screen payload (pty sessions only). Cheaper thanshell_snapshotwhen you only need the caret/selection position. -
shell_move_cursor(tag, row, col)— Move the cursor to an absolute 1-based (row, col) position via a standard ANSI CUP sequence (pty sessions only). Only affects where subsequently sent characters land; does not by itself trigger program behavior. -
shell_resize(tag, cols, rows)— Dynamically resize an already-running pty session's terminal window without losing session state (pty sessions only). Use when a column/row-sensitive program needs a different size mid-session. -
shell_reset(tag)— Force-restart a session when stuck in an infinite loop or hung state. -
shell_close(tag)— Terminate and remove a session. Always close sessions when done to prevent zombie processes. -
shell_close_all— Cleanup all active sessions at once. -
shell_list— List all active tags with shell paths, PTY status, truncation info, and busy state. -
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_for—shell_wait_for(tag, pattern, timeout_ms)blocks until the expected keyword appears or the timeout elapses, reducing the number of interaction turns. The response includes amatchedfield indicating whether the pattern was actually seen. - Fallback to polling — if no specific keyword is known in advance, call
shell_outputwith a largeridle_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
- Pipe mode (pty=false): Full-screen TUI/GUI programs (vim/nano/htop/less/whiptail, etc.) are prohibited — use cat/head/grep/ps instead, since there is no way to observe the actual screen layout without a real terminal.
- PTY mode (pty=true): Full-screen TUI interaction IS supported via
shell_snapshot+shell_send_keys+shell_cursor_position+shell_move_cursor+shell_resize. Seeguide://shell/tuifor 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.
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", pattern="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/runhave uncertain execution time — useshell_wait_for(tag, pattern, 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_outputrepeatedly or useshell_wait_fortargeting the(gdb)/pwndbg>prompt
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.
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), sudo may be refusing to run without a real terminal. Re-spawn with shell_spawn(shell="bash", tag="b1", pty=true) and prefer shell_snapshot over shell_output to check for the prompt. See guide://shell/pty.
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:
After connection established — the listener session becomes the target's shell:
shell_wait_for(tag="listener", pattern="$", 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.
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 (pipe or PTY mode) | shell, tag, pty?, cols?, rows? |
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 | tag, idle_ms? |
shell_wait_for |
Wait until pattern appears in output (with timeout) | tag, pattern, timeout_ms? |
shell_snapshot |
Get rendered terminal screen + cursor (PTY only) | tag, idle_ms? |
shell_cursor_position |
Get current cursor position (PTY only) | tag |
shell_move_cursor |
Move cursor to absolute position via ANSI CUP (PTY only) | tag, row, col |
shell_resize |
Dynamically resize PTY window (PTY only) | tag, cols, rows |
shell_list |
List all sessions with PTY status 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: when to enable pty, preferring shell_snapshot |
guide://shell/tui |
Driving full-screen TUI programs (vim/htop/less/whiptail) in pty mode |
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_resizecall 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
- Read-only by default: Commands like ls, cat, grep, ps, df that do not modify state can execute directly
- Remote operations: SSH sessions and reverse shells inherently operate on remote targets and are exempt from local consent (unless they write to local disk or tunnel back)
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 invocationaction— tool nameshell,tag,input— command contextbegin/endevents with duration and success/failure status