terminal-mcp
English | 简体中文

A real PTY terminal for AI agents — with full observability and seamless human takeover.
terminal-mcp is a Model Context Protocol server that gives an LLM agent a real PTY terminal (not a one-shot exec pipe). A human can watch every step the agent takes in real time, take over the exact same session the instant the agent goes down the wrong path, and the agent then resumes with the full context of everything the human just did. Local shells, ssh, containers, gdb, python, top, vim — the agent drives them like a person, and you're never locked out.
One terminal, shared by human and agent. The agent does the driving, you watch live, grab the wheel when it matters, and hand it back — without losing a beat.
Built in Go, single binary, official MCP SDK, Streamable HTTP. Drop it into Cursor, Claude, Comate, or any MCP client.
Why terminal-mcp
Most "run a command" tools hand the model a stateless pipe: one command in, one blob out. It breaks on anything interactive (a login, a REPL, a long-running session), and — worse — it gives the human no way to see what's happening or step in when the agent goes wrong.
terminal-mcp is built around four things:
- 1. A real PTY, not a pipe. A genuine pseudo-terminal with a persistent session. Interactive and full-screen programs (
gdb, python, mysql, top, vim), line editing, colors, prompts, control keys — all work exactly as they do for a human. Not exec one-shots.
- 2. Full observability — watch the agent live. Every session has a live web terminal URL. Open it and see, in real time, every keystroke and byte the agent produces — the same screen the agent sees. No guessing what your agent did.
- 3. Instant human takeover — correct course the moment it's wrong. See the agent heading the wrong way? Click take over and type directly into the running PTY. The agent's writes are paused immediately; you fix things by hand — enter a password, run the right command, back it out of a hole.
- 4. Seamless resume — the agent picks up with full context. When you release control, everything you typed is fed back to the agent as
[rc=n] $ command with output, so it continues with complete knowledge of what you just did — no re-explaining, no lost state.
Plus the machinery that makes the above reliable:
- Precise command boundaries. A sentinel prompt tells the server exactly when a command finished and its exit code — the agent never mistakes "still running" for "done".
- Survives shell switches.
ssh, su, docker exec, entering a container — the sentinel is re-armed automatically so tracking keeps working across hops.
- Transparent resource guardrails, inescapable across shell switches. Configure a model-invisible
ulimit that's injected at session start and re-injected every time the agent hops into a new shell (ssh remote / su / container). The hard limit is inherited by every child process, so unprivileged commands can't raise it back — the agent can't escape the cap by switching shells or running other commands. A safety net that keeps an autonomous agent from running away with resources, with zero awareness on the model side.
- LLM-friendly output. ANSI escapes stripped, partial escape sequences held back — the model never gets corrupted bytes; oversized output is spilled and fetched on demand.
- Local & remote.
mode=local runs a shell/command on the host; mode=ssh connects out.
- Audit & replay. Every tool call logged as JSON (caller IP, user, tool, args, result); every session's raw byte stream saved for replay, with configurable retention.
Quick start
Requires Go 1.25+.
go install github.com/fzxbl/terminal-mcp/cmd/terminal-mcp@latest
terminal-mcp --listen 127.0.0.1:8900
The MCP endpoint is at /mcp; a session's web terminal is at /debug/terminal/<session_id>.
terminal-mcp speaks MCP over Streamable HTTP. Point your client at http://127.0.0.1:8900/mcp.
Generic MCP client config (mcp.json):
{
"mcpServers": {
"terminal": { "url": "http://127.0.0.1:8900/mcp" }
}
}
Then just ask your agent:
"Open a terminal, ssh into staging, tail the service log, and tell me why it's 500ing."
The agent opens a session, runs commands, and streams back results. If it needs a password or you want to intervene, open the returned terminal_url and take over — the agent waits, watches, and resumes.
Human takeover, in 20 seconds
- Agent calls
terminal_open → gets a terminal_url.
- You open it in a browser, click take over.
- The session reports
held=true; the agent's writes pause.
- You type (enter a password, run a risky command, poke around). The agent sees each of your commands reconstructed as
[rc=n] $ cmd.
- You click release. The agent picks up right where you left off.
| Tool |
What it does |
terminal_open(mode, command?, host?) |
Start a persistent PTY session. mode=local or mode=ssh. Returns session_id + terminal_url. |
terminal_send(session_id, input, wait_ms?) |
Type a command, wait for it to settle. Returns output, state, exit_code. |
terminal_read(session_id, wait_ms?, mode?) |
Observe output. tail (peek) or since_last (full increment; also the record of human-takeover commands). |
terminal_control(session_id, key) |
Send control keys (ctrl-c, ctrl-d, ctrl-z, …) or recovery actions (flush, hard, rearm). |
terminal_status(session_id) |
Lightweight state / prompt / exit_code / held query. |
terminal_close(session_id) |
Close the session, reclaim the process group. |
terminal_list() |
List active sessions. |
terminal_spill_explore(spill_id) |
Fetch full output that was spilled to disk when a result was too large. |
Copy config.example.toml. Highlights: listen_addr, data_dir, default_shell, ssh_user, ssh_opts, shell_switch_commands (commands that trigger auto re-arm — add your own, e.g. container-enter commands), auto_rearm, transcript_retention_days, log_dir / log_rotate / log_max_age_days.
Transparent resource guardrails (resource_limit_cmd): a model-invisible ulimit injected alongside the sentinel at session start, and re-injected on every shell switch (ssh/su/docker exec/matrix_jail …) and on hard reset. A ulimit without -S/-H sets both soft and hard limits; the hard limit is inherited by child processes and can't be raised by unprivileged commands, so switching shells or running other commands can't escape the cap:
resource_limit_cmd = "ulimit -v 4194304; ulimit -t 600; ulimit -u 4096"
Boundary: ulimit can't constrain a process that escalates to root and raises its own hard limit, nor a process tree started by a daemon that doesn't inherit this session. For a privilege-independent cap on the local process tree, use cgroup v2 instead.
Customize tool descriptions: use [tool_descriptions] to override the model-facing description per tool (handy when embedding into an external MCP and you want your own wording; missing/empty entries keep the built-in default):
[tool_descriptions]
terminal_open = "Open a persistent terminal session; returns session_id and a read-only web terminal URL."
terminal_send = "Type a command into the session and wait for it to finish; returns new output and status."
Embed it as a library (share one MCP server)
Already run an MCP server and want terminal tools on the same /mcp? go get github.com/fzxbl/terminal-mcp and register onto your own official-SDK server:
mcpserver.Init("config.toml")
mcpserver.SetAdvertiseAddr("10.0.0.5:8080") // host:port used to build terminal_url
mcpserver.SetToolDescriptions(map[string]string{ // optional: reword tool descriptions
"terminal_open": "Open a persistent terminal session; returns session_id and a web terminal URL.",
})
mcpserver.StartIdleGC(ctx)
mcpserver.RegisterTools(server, auditWriter) // terminal_* + terminal_spill_explore
mux.Handle("/debug/terminal/", mcpserver.TerminalHandler()) // web terminal (human takeover)
/mcp path is yours to choose; the web terminal prefix /debug/terminal/ is fixed. Tool descriptions can also be overridden via the [tool_descriptions] config table (precedence: SetToolDescriptions > config > built-in default). See docs/EMBEDDING.md for the full public API reference, the shared-/mcp integration pattern, path conventions, authorization notes, and the config reference.
Security
The HTTP service (MCP endpoint + web terminal) is unauthenticated by default. Calling these tools is equivalent to shell access on the host (or over ssh). Bind it to loopback, or put it behind an authenticating reverse proxy, before exposing it beyond a trusted network.
License
MIT. Contributions and stars welcome.