Content
# terminal-mcp
**English** | [简体中文](README.zh-CN.md)
[](https://pkg.go.dev/github.com/fzxbl/terminal-mcp)
[](https://go.dev/)
[](https://modelcontextprotocol.io)
[](LICENSE)
[](https://github.com/fzxbl/terminal-mcp/stargazers)
**A real PTY terminal for AI agents — with full observability and seamless human takeover.**
`terminal-mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) 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 five 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.
- **5. A fence around the agent — transparent resource limits, 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.
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.
- **LLM-friendly output.** ANSI escapes stripped, partial escape sequences held back — the model never gets corrupted bytes. The session log is an append-only file on disk (the source of truth) with a bounded in-memory tail cache, so a runaway `cat`/`yes` can't blow up memory; oversized results are returned as a log-range reference and paged 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+.
```bash
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>`.
## Use it from your AI coding tool
terminal-mcp speaks MCP over **Streamable HTTP**. Point your client at `http://127.0.0.1:8900/mcp`.
Generic MCP client config (`mcp.json`):
```jsonc
{
"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
1. Agent calls `terminal_open` → gets a `terminal_url`.
2. You open it in a browser, click **take over**.
3. The session reports `held=true`; the agent's writes pause.
4. You type (enter a password, run a risky command, poke around). The agent sees each of your commands reconstructed as `[rc=n] $ cmd`.
5. You click **release**. The agent picks up right where you left off.
## Tools
| 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?, from?, to?)` | Observe output. `tail` (peek), `since_last` (full increment; also the record of human-takeover commands), or `range` (page an absolute log byte window `[from,to)`; use the `range` returned in a truncated result). |
| `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. |
## Configure
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`, `max_buffer_bytes` (in-memory tail-cache cap; the full log lives on disk), `exec_output_max_bytes` (per-call return cap; larger results come back as a paging range), `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:
```toml
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):
```toml
[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:
```go
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_* tools
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](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.