Prototype: cmux Cloud as a remote MCP server

Merged
TE
1mo ago
+1144-0

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:

ToolHintsOn the machine
list_machinesread-onlynone (same query as GET /api/vm)
list_terminalsnot read-only (see below)cmux-tui --session cloud terminal list
run_agentwriteworkspace 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_terminalnot read-only (see below)terminal <id> screen read, or output read --max-bytes 65536
send_inputwrite, destructiveone terminal <id> write --text <text>, with a trailing CR if submit

Scoping

  • No new authority. The tools call the existing listUserVms and execVm Effect programs through runVmRoute. Ownership (requireAccessibleUserVm), team scope, plan gates, resume and usage events are therefore exactly what /api/vm enforces, and an unowned machine is vm_not_found before the provider runs anything.
  • Exact guest arguments. Every argument is a single-quoted word of the cmux-tui argv, run as the daemon user via cmuxTuiRunCommand. 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/exec enforces (execVm itself 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. initialize and tools/list work for a multi-team account without X-Cmux-Team-Id, and list_machines uses the same rule as GET /api/vm.
  • Errors stay in JSON-RPC. An unexpected failure becomes a -32603 reply 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 carries WWW-Authenticate: Bearer.
  • Not done: OAuth. ChatGPT and Claude connectors need OAuth 2.1, which this PR does not add. /api/vm has 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, and initialize/tools/list never resolving a scope.
    • Scoping runs the real execVm/listUserVms programs against a fake repository that scopes the way accountScopeWhere does. A stranger gets vm_not_found with 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 ', ;, $HOME and backticks.
  • Static checks. bun run typecheck, eslint on the new files and bun run lint:complexity are clean.
  • Live demo against a real cmux-tui daemon (built from ad7906a). The route needs Stack and Postgres, so the demo uses web/scripts/cloud-mcp-local.ts: the same handler, with one fake machine local whose guest commands run cmux-tui locally. claude there 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.
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.