terminal-mcp
README.md
# terminal-mcp
A sandboxed terminal, filesystem, git, network and system MCP server for **Claude** and **ChatGPT**.
67 tools, one workspace root, two transports (`stdio` and streamable HTTP), plus an optional Cloudflare
Worker relay so ChatGPT can reach your Mac without a tunnel process. Every file path and every shell
command is confined to a workspace folder — the model gets a real terminal without getting your whole Mac.
Live status page: **https://terminal-mcp.pages.dev**
```
┌──────────────┐ MCP ┌────────────────┐ sandbox-exec ┌─────────────────────┐
│ Claude / GPT │ ───────► │ terminal-mcp │ ────────────────► │ sand1, sand2, … │
└──────────────┘ stdio/http└────────────────┘ (kernel profile) └─────────────────────┘
```
---
## Quick start
```bash
cd terminal-mcp
npm install
npm run build
npm link # puts `terminal-mcp` on your PATH
terminal-mcp # start everything: sandboxed server + Cloudflare relay
```
Now from any directory:
```bash
terminal-mcp # server + relay, Ctrl+C stops both
terminal-mcp http --port 9000
terminal-mcp stdio # for Claude Desktop / Code / Cursor
terminal-mcp relay --url https://relay.example.workers.dev
```
Verify the sandbox works end-to-end:
```bash
npm run smoke # 26 checks
SMOKE_NPM=1 npm run smoke # also proves npm install works inside the sandbox
```
Everything the server creates stays inside this folder:
```
terminal-mcp/
├── src/ dist/ worker/ bin/ scripts/ code
├── sand1/ sand2/ sand3/ … one sandbox per session
├── workspace/ only used with --shared
└── state/ audit.log, relay-url, relay-token, sessions.jsonl (gitignored)
```
All of these are created on first run and are gitignored. Move them with `TERMINAL_MCP_HOME`
(everything), `--root` / `TERMINAL_MCP_ROOT` (pin a single shared workspace) or `TERMINAL_MCP_STATE`.
---
## One sandbox per session
Every time a client opens a new MCP session — a new chat, a new app launch, a reconnect — the server
creates the next free directory next to the code. Nothing is created just for connecting — a health
check or `tools/list` leaves no folder behind, so `sandN` really means "this session did real work":
```
sand1/ ← first session
sand2/ ← second session
sand3/ ← third session
```
A session cannot see, read or write another session's files; the path jail treats `../sand2` as an
escape and refuses it. Each `sandN` is a full independent workspace, so parallel chats never fight over
the same files or the same git repo.
```bash
ls -d sand* # see every session's folder
rm -rf sand3 # delete one you are done with
terminal-mcp --shared # opt out: one shared ./workspace for everything
terminal-mcp --root ~/code # pin any folder instead
```
`state/sessions.jsonl` records which `sandN` belonged to which MCP session id and when it was created,
and `sandbox_info` reports the current session's folder plus the full list.
---
## Connecting clients
### Claude Code
```bash
claude mcp add terminal-mcp -- node "$(pwd)/dist/index.js"
claude mcp list
```
### Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
```json
{
"mcpServers": {
"terminal": {
"command": "node",
"args": [
"/Users/you/terminal-mcp/dist/index.js"
]
}
}
}
```
Restart Claude Desktop. The 67 tools appear under the tools menu.
### Cursor / other stdio clients
`~/.cursor/mcp.json` uses the same `mcpServers` shape as above.
### ChatGPT (custom connector)
ChatGPT needs a public HTTPS URL. There are two ways to give it one — a permanent Worker relay (no domain,
no tunnel process) or a quick tunnel for testing.
#### Option A — Cloudflare Worker relay (recommended, permanent)
A Worker holds one outbound WebSocket to your Mac and forwards each MCP request down it. No inbound ports,
no `cloudflared`, no domain. URL stays the same across restarts.
```
ChatGPT ──https──► Worker (/mcp) ◄──wss── relay-agent (Mac) ──► 127.0.0.1:8787
```
Deploy once:
```bash
cd worker
npm install
npx wrangler login # opens the browser
npx wrangler secret put RELAY_TOKEN # paste a long random secret
npx wrangler deploy
```
Wrangler prints `https://terminal-mcp.<your-subdomain>.workers.dev`. Save it once, then start both halves:
```bash
echo "https://terminal-mcp.<your-subdomain>.workers.dev" > state/relay-url
echo "<the same secret you gave wrangler>" > state/relay-token && chmod 600 state/relay-token
terminal-mcp
```
Nothing relay-specific is hardcoded in the repo: `terminal-mcp` reads `state/relay-url` (or `RELAY_URL`)
and `state/relay-token` (or `RELAY_TOKEN`). With no URL configured it runs the local server only.
Override anything with the environment:
```bash
RELAY_URL=https://other.workers.dev PORT=9000 TERMINAL_MCP_ROOT=~/scratch terminal-mcp
```
Equivalent to doing it by hand in two terminals:
```bash
npm run serve:json
RELAY_TOKEN="$(cat ./state/relay-token)" npm run relay -- --url "$(cat ./state/relay-url)"
```
Check it: `curl https://<worker>/health` → `{"online":true,...}`, and open `https://<worker>/` for a status page.
Run one instance per machine. A second `terminal-mcp` detects the running supervisor and exits with
`terminal-mcp is already running (pid …)` — two agents would otherwise keep replacing each other's
upstream socket and every request would hang. The agent also pings every 25 s; the Worker routes to the
newest socket that has spoken within 90 s, so a dead connection can never swallow a request.
In ChatGPT: **Settings → Connectors → Advanced → Developer mode → Add custom connector**
| Field | Value |
|---|---|
| Name | `terminal` |
| URL | `https://terminal-mcp.<subdomain>.workers.dev/mcp?token=<secret>` |
| Authentication | None (token is in the URL) |
If the connector drops sessions, add `--stateless` to the local server.
#### Option B — Quick tunnel (temporary)
```bash
node dist/index.js --http --port 8787 --token super-secret-token
cloudflared tunnel --url http://127.0.0.1:8787 # random trycloudflare.com URL, dies with the process
```
Use the printed `https://<random>.trycloudflare.com/mcp?token=super-secret-token` as the connector URL.
For a stable hostname, create a **named** tunnel instead (`cloudflared tunnel create` + DNS route + service).
> The URL is a credential. Anyone with it can run commands in the workspace.
---
## The sandbox
Three independent layers, all on by default.
**1. Path jail (always on).** Every path argument is resolved against the workspace root, symlinks are
resolved to their real target, and anything landing outside the root is rejected:
```
Path escapes the sandbox: ../../etc/passwd
Sandbox root: /Users/you/terminal-mcp/workspace
Resolved to: /Users/etc/passwd
```
**2. Kernel sandbox (`strict` mode).** Commands are executed through `sandbox-exec` on macOS (or `bwrap`
on Linux) with a generated profile:
| | allowed |
|---|---|
| **write** | workspace root, `.tmp/`, OS temp dir, `/dev/null`, `/dev/stdout`, `/dev/stderr` |
| **read** | system paths, toolchains, workspace — **except** `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.docker/config.json`, `~/.config/gh`, `~/.netrc`, `~/.npmrc`, `~/.pypirc` |
| **network** | allowed unless you pass `--no-network` |
| **process** | fork/exec allowed (needed for compilers, npm, git) |
Verified behaviours from the smoke test:
```
PASS kernel sandbox blocks writes outside workspace
PASS kernel sandbox blocks reading ~/.ssh
PASS npm install works inside sandbox
```
Environment inside the sandbox is redirected so tools stay contained: `TMPDIR`, `XDG_CACHE_HOME` and
`npm_config_cache` point into the workspace.
**3. Environment scrubbing (always on).** Anything that looks like a credential is dropped before the
child process starts, so `env`, `printenv` and friends cannot see it:
```
CLOUDFLARE_API_TOKEN, AWS_*, OPENAI_*, ANTHROPIC_*, GITHUB_TOKEN, GH_*, NPM_TOKEN,
SSH_AUTH_SOCK, anything matching *_TOKEN, *_KEY, *_SECRET, *_PASSWORD, *AUTH*, *COOKIE*
```
This closes the gap the kernel profile leaves open: it protects credential *files*, not credentials you
exported in your shell. If a tool genuinely needs one, allow it by name:
```bash
TERMINAL_MCP_PASS_ENV=SSH_AUTH_SOCK,NPM_TOKEN terminal-mcp
```
Combine with `--no-network` when the model is working on untrusted input, and note that the relay token
is passed to the agent through the environment (never argv), so it does not appear in `ps`.
### Modes
| mode | path jail | kernel sandbox | use when |
|---|---|---|---|
| `strict` *(default)* | ✅ | ✅ | normal use — untrusted or exploratory work |
| `workspace` | ✅ | ❌ | the toolchain fights the kernel profile (Docker, some IDEs) |
| `off` | ❌ | ❌ | throwaway VM only |
```bash
node dist/index.js --root ./workspace --sandbox workspace
```
`sandbox_selftest` actively probes the confinement and reports pass/fail at runtime.
---
## Tools (67)
### Shell — 9
| tool | purpose |
|---|---|
| `run_command` | run a shell command (cwd, timeout, env, stdin, background) |
| `run_script` | run inline bash / python / node source |
| `start_process` | background long-running process, returns an id |
| `read_process` | incremental stdout/stderr by offset |
| `write_process` | send stdin (answer prompts, REPLs) |
| `wait_process` | block until exit |
| `kill_process` | signal a managed process or raw pid |
| `list_processes` | managed processes + optional `ps` snapshot |
| `kill_all_processes` | clean shutdown of everything it started |
### Files — 12
`read_file` `write_file` `append_file` `edit_file` `list_directory` `tree` `file_info`
`make_directory` `delete_path` `move_path` `copy_path` `read_many_files`
`edit_file` is an exact-string replace that refuses ambiguous matches, so the model has to read before it writes.
### Search — 4
`find_files` (globs with `**`, `?`, `{a,b}`) `search_text` (regex, context lines) `replace_text`
(bulk regex replace, dry-run by default) `count_lines`
### Git — 14
`git_status` `git_diff` `git_log` `git_show` `git_blame` `git_branches` `git_checkout` `git_commit`
`git_clone` `git_push` `git_pull` `git_stash` `git_remotes` `git_init`
### Network — 6
`http_request` `download_file` `check_port` `dns_lookup` `ping` `public_ip`
### System — 11
`system_info` `disk_usage` `env_vars` (secrets masked) `clipboard_read` `clipboard_write` `screenshot`
`notify` `open_url` `listening_ports` `which_command` `current_time`
### Data — 6
`diff_files` `hash` `base64` `archive` (zip / tar / tar.gz) `json_query` `json_set`
### Meta — 5
`sandbox_info` `sandbox_selftest` `command_history` `clear_history` `list_capabilities`
`command_history` reads an append-only JSONL audit log at `./state/audit.log` — deliberately
**outside** the workspace so the agent cannot erase its own tracks.
---
## CLI reference
```
terminal-mcp [--stdio] [--http] [options]
--root <dir> pin one shared workspace (default: sand1, sand2, … per session)
--shared one shared ./workspace instead of per-session sandboxes
--sandbox <mode> strict | workspace | off (default strict)
--no-network deny network to sandboxed commands
--timeout <ms> default command timeout (default 120000)
--host <addr> HTTP bind address (default 127.0.0.1)
--port <port> HTTP port (default 8787)
--token <secret> require Bearer auth
--stateless no session tracking (hosted connectors)
--json-response answer with JSON instead of SSE (needed behind the CF relay)
--help, --version
```
Environment equivalents: `TERMINAL_MCP_HOME`, `TERMINAL_MCP_STATE`, `TERMINAL_MCP_ROOT`, `TERMINAL_MCP_SHARED=1`,
`TERMINAL_MCP_SANDBOX`, `TERMINAL_MCP_NETWORK=0`, `TERMINAL_MCP_TIMEOUT_MS`, `TERMINAL_MCP_MAX_OUTPUT`,
`TERMINAL_MCP_HOST`, `TERMINAL_MCP_PORT`, `TERMINAL_MCP_TOKEN`, `TERMINAL_MCP_READ_PATHS`,
`TERMINAL_MCP_WRITE_PATHS`, `TERMINAL_MCP_PASS_ENV`.
Endpoints: `POST/GET/DELETE /mcp` (MCP), `GET /health`.
The server refuses to bind a non-loopback address without a token unless
`TERMINAL_MCP_ALLOW_INSECURE=1` is set.
---
## Safety model
This is a real shell. The sandbox bounds *where* it can act, not *what* it can do inside that bound.
Inside the workspace the model can create, modify and delete anything, install packages and reach the network.
What it cannot do (in `strict` mode): write outside the workspace, read your SSH/AWS/GPG/gh credentials, or
persist changes anywhere else on disk. The host-integration tools (`screenshot`, `notify`, `clipboard_*`,
`open_url`, `listening_ports`) run outside the sandbox by design, but each executes a fixed binary with
validated arguments and no user-controlled shell string.
Recommended posture: keep `strict`, keep the workspace a folder you don't mind losing, and review
`command_history` after a long session.
**Exposing it to ChatGPT.** The Worker relay authenticates with a shared `RELAY_TOKEN` and only forwards
`/mcp`; the local server should stay bound to `127.0.0.1` so the agent is the only client. Treat the
connector URL (`…/mcp?token=…`) as the password it is: rotate the secret with
`wrangler secret put RELAY_TOKEN` and restart the agent. If you want a second layer, put Cloudflare Access
in front of the Worker and add a service token bypass for the `/mcp` path.
---
## Layout
```
src/
index.ts CLI entry, stdio + HTTP transports
server.ts McpServer factory, tool registration
config.ts flags / env / defaults
relay-agent.ts Mac-side outbound agent for the Cloudflare Worker relay
bin/terminal-mcp global launcher (`terminal-mcp`, `stdio`, `http`, `relay`)
lib/
sandbox.ts path jail + workspace-relative resolution
osSandbox.ts sandbox-exec / bwrap profile generation
exec.ts spawn with timeout, output capping, tree kill
processes.ts managed background process registry
fsutil.ts confined walker, glob→regex
audit.ts JSONL audit log
tools/
shell.ts files.ts search.ts git.ts net.ts system.ts data.ts meta.ts
relay-agent.ts Mac-side outbound agent for the Cloudflare Worker relay
scripts/smoke.ts end-to-end test over stdio
site/ landing site (Astro, deployed to terminal-mcp.pages.dev)
src/pages/ home · tools · sandbox · relay · 404
src/components/ nav, footer, typing terminal, live status pill
src/lib/art.ts ASCII art rendered at build time (figlet)
wrangler.jsonc Pages project config (build output: site/dist)
worker/
src/index.ts Worker entry: auth, landing page, routing
src/relay.ts Durable Object holding the WebSocket to the Mac
src/protocol.ts wire protocol between Worker and agent
```
## Troubleshooting
| symptom | fix |
|---|---|
| `OS sandbox unavailable` on macOS | `sandbox-exec` was removed on your build — the server falls back to path-jail only; rebuild with `--sandbox workspace` to silence the warning |
| `npm install` fails with EPERM | run with `--sandbox workspace`, or add the path via `TERMINAL_MCP_WRITE_PATHS` |
| Docker / kubectl fail | they need the host socket and full writes — use `workspace` mode for those sessions |
| ChatGPT connector errors on session | add `--stateless` |
| Connector hangs, `/health` shows `agents` > 1 | two agents are fighting over the socket — `pkill -f dist/relay-agent.js`, then start `terminal-mcp` once |
| Server won't start on `0.0.0.0` | pass `--token`, or set `TERMINAL_MCP_ALLOW_INSECURE=1` |
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues