Prototype: cmux Cloud as a remote MCP server
Merged
TE
1mo ago+1144+1144+0123456789012345678901234567890123456789-0-0-0123456789
Prototype, for review. Not deployed and not exposed to ChatGPT yet. Part of #16567.
ChatGPT, Claude and other MCP hosts have no way to drive cmux Cloud. This adds POST /api/mcp, a remote MCP server (streamable HTTP, stateless JSON replies) with five tools that act only on the caller's own machines:
| Tool | Hints | On the machine |
|---|---|---|
list_machines | read-only | none (same query as GET /api/vm) |
list_terminals | not read-only (see below) | cmux-tui --session cloud terminal list |
run_agent | write | workspace create --empty, then workspace <ws> run --on-exit keep -- bash -lc 'cd "$HOME" && exec "$@"' bash claude -p -- <prompt> (codex/opencode use the cmux vm agent one-shot forms with --; pi refuses a prompt starting with -). If the run fails, the workspace is closed. |
read_terminal | not read-only (see below) | terminal <id> screen read, or output read --max-bytes 65536 |
send_input | write, destructive | one terminal <id> write --text <text>, with a trailing CR if submit |
Scoping
- No new authority. The tools call the existing
listUserVmsandexecVmEffect programs throughrunVmRoute. Ownership (requireAccessibleUserVm), team scope, plan gates, resume and usage events are therefore exactly what/api/vmenforces, and an unowned machine isvm_not_foundbefore the provider runs anything. - Exact guest arguments. Every argument is a single-quoted word of the
cmux-tuiargv, run as the daemon user viacmuxTuiRunCommand. Prompts and input text never become shell text. - Strict argument checks. Terminal ids must match
term_…. Unknown tool arguments are refused, and text and prompts are capped at 16 KiB. The full guest command stays under the 64 KiB cap/api/vm/:id/execenforces (execVmitself does not). The request body is capped at 256 KiB, and a batch at 8 messages. - Terminal reads are not marked read-only. They go through
execVm, which resumes a paused machine and records usage, so a host should not auto-approve them as free reads. A path that refuses to resume would let them become read-only. - Lazy billing scope. The scope resolves on the first tool that needs it.
initializeandtools/listwork for a multi-team account withoutX-Cmux-Team-Id, andlist_machinesuses the same rule asGET /api/vm. - Errors stay in JSON-RPC. An unexpected failure becomes a
-32603reply for that request id, not a REST 500. Batches are accepted, since 2025-03-26 is one of the negotiated versions.
Auth
- Today: the same as
/api/vm. Requests carry the Stack bearer pair the app sends, or a same-origin cookie (cross-origin cookie POSTs get 403 from the existing mutation guard). A 401 carriesWWW-Authenticate: Bearer. - Not done: OAuth. ChatGPT and Claude connectors need OAuth 2.1, which this PR does not add.
/api/vmhas no API keys or team tokens to reuse. #16567 proposes the OAuth and scope design, so ChatGPT cannot connect to this route yet.
Validation
scripts/run-tests.sh tests/cloud-mcp.test.ts tests/cloud-mcp-route.test.ts tests/vm-route-auth.test.ts: 124 pass (23 new).- The route test covers an unauthenticated POST getting a 401 with the bearer challenge before any tool runs, and GET getting 405.
- Also covered: batches, a defect becoming
-32603, a flag-shaped prompt staying a prompt, cleanup after a failed start, the command-size cap, an unresolvable team scope becoming a tool error, andinitialize/tools/listnever resolving a scope. - Scoping runs the real
execVm/listUserVmsprograms against a fake repository that scopes the wayaccountScopeWheredoes. A stranger getsvm_not_foundwith zero provider execs on every tool. Naming another team's id without membership is refused. A member reaches the team machine but not the owner's personal one. Listing returns only the caller's scope. - Argument quoting is checked by round-tripping the generated words through
sh, using a prompt containing',;,$HOMEand backticks.
- Static checks.
bun run typecheck,eslinton the new files andbun run lint:complexityare clean. - Live demo against a real cmux-tui daemon (built from
ad7906a). The route needs Stack and Postgres, so the demo usesweb/scripts/cloud-mcp-local.ts: the same handler, with one fake machinelocalwhose guest commands runcmux-tuilocally.claudethere is a stub script that prints its argv, so no model was called.- The transcript below is from the MCP Inspector CLI (
@modelcontextprotocol/inspector --cli), lightly formatted (structuredContent only), with the home path and host name replaced. - This establishes the protocol, the tool flow and the guest command shapes. It does not establish Stack auth or exec on a real Cloud machine.
- The transcript below is from the MCP Inspector CLI (
MCP Inspector session
# cmux Cloud MCP prototype: MCP Inspector CLI (@modelcontextprotocol/inspector --cli) against web/scripts/cloud-mcp-local.ts, 2026-10-02T00:31:29Z
# URL=http://127.0.0.1:8765/mcp. Machine 'local' runs cmux-tui on this computer; 'claude' is a stub script that prints its argv.
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/list
list_machines {"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}
list_terminals {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}
run_agent {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": false, "openWorldHint": true}
read_terminal {"readOnlyHint": false, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false}
send_input {"readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": true}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name list_machines
{
"machines": [
{
"id": "local",
"name": "this computer",
"status": "running"
}
]
}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name run_agent --tool-arg machine_id=local --tool-arg agent=claude --tool-arg 'prompt=--help count the files in project/'
{
"machine_id": "local",
"agent": "claude",
"workspace_id": "ws_07e4be7eb51c753412b21e592bd667cf",
"terminal_id": "term_63df1828566e5be9aa6aaf67d1a1211e"
}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name read_terminal --tool-arg machine_id=local --tool-arg terminal_id=term_63df1828566e5be9aa6aaf67d1a1211e
content: "claude (demo stub) argv: [-p] [--] [--help count the files in project/]\nDone: listed 3 files in /home/demo"
structuredContent: {"machine_id": "local", "terminal_id": "term_63df1828566e5be9aa6aaf67d1a1211e", "source": "screen"}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name list_terminals --tool-arg machine_id=local
{
"machine_id": "local",
"terminals": [
{
"id": "term_cf0a8ca0cbb3f7ca07ffcd5c421e1e8d",
"title": "",
"running": true,
"cwd": "/home/demo"
},
{
"id": "term_63df1828566e5be9aa6aaf67d1a1211e",
"title": "",
"running": false,
"cwd": "/home/demo"
},
{
"id": "term_6ff6e91b9cfda521bb66a2c2807ecf6b",
"title": "",
"running": true,
"cwd": "/home/demo"
},
{
"id": "term_59cd84e822de8d62b73744ab23e4471f",
"title": "",
"running": false,
"cwd": null
}
]
}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name send_input --tool-arg machine_id=local --tool-arg terminal_id=term_6ff6e91b9cfda521bb66a2c2807ecf6b --tool-arg 'text=echo hello from an MCP client' --tool-arg submit=true
{
"machine_id": "local",
"terminal_id": "term_6ff6e91b9cfda521bb66a2c2807ecf6b",
"sent_bytes": 29,
"submitted": true
}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name read_terminal --tool-arg machine_id=local --tool-arg terminal_id=term_6ff6e91b9cfda521bb66a2c2807ecf6b
content: "demo@host:~$ echo hello from an MCP client\nhello from an MCP client\ndemo@host:~$"
structuredContent: {"machine_id": "local", "terminal_id": "term_6ff6e91b9cfda521bb66a2c2807ecf6b", "source": "screen"}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name list_terminals --tool-arg machine_id=someone-elses-vm
{
"isError": true,
"error": "vm_not_found",
"message": "Cloud VM someone-elses-vm was not found."
}
$ npx @modelcontextprotocol/inspector --cli $URL --transport http --method tools/call --tool-name read_terminal --tool-arg machine_id=local --tool-arg 'terminal_id=term_x; rm -rf ~'
{
"isError": true,
"error": "invalid_arguments",
"message": "`terminal_id` must be a term_… id from list_terminals or run_agent."
}
Not in this PR
- OAuth, scopes, audit log and per-token rate limits (#16567 step 2).
- An MCP Apps terminal widget.
- Mac terminals through the relay.
Plugin-declared MCP servers in the agent pane are described in #16567 rather than prototyped.
— Lanternfish g2 🛠️
🤖 Generated with Claude Code
Summary by CodeRabbit
- New Features
- Added an authenticated Cloud MCP endpoint for connecting compatible clients to your machines.
- Cloud MCP tools can list machines and terminals, start an agent, read terminal output, and send terminal input.
- Added a local demo server for trying Cloud MCP with a local machine.
- Bug Fixes
- Improved error responses for invalid requests and tool calls, including clear handling of unsupported methods and unauthenticated access.