cmux mcp serve: start other MCP servers (cmux-cua) on first call, not to list them

Merged
TE
7d ago
+399-7

Leo: "Yes, fix it" (the goal he approved: Nightly starts agent hosts only when a session is opened or has queued work, and computer-use/REPL helpers only on first use). Coordinator, with measurements from Leo's Mac: "Fix the helpers so they're started on first use and reaped when idle or when their session stops: node_repl per Codex session, the agent-memory MCP (could one shared server per host work?), and cmux-cua."

Root cause (Leo's Mac, 2026-10-29)

  • Load 113 to 369, swap 14.8 of 15.4 GB, 116 live agent hosts, 85 codex app-server, 75 cmux mcp serve each with its own cmux-cua mcp child.
  • The burst: at 19:31Z one agent message, sent from another machine through the peer link, reached about 64 idle sessions on Leo's Mac within two minutes. Each woke its harness for the turn: the daemon's live count went 58 to 122 (session/new admitted past the lane cap ... 122 live sessions). So it was queued work, not restore-on-launch or a respawn loop: every running host had a host record, only 2 of 116 had none.
  • Why they never went away: the idle pass refused to end almost every harness (idle harness stays up: a process that is not one of its MCP servers runs under it, 1,600+ times that day). The servers' paths have spaces (/Applications/cmux NIGHTLY.app/...), which #27925 fixes (it supersedes #27558), and npm's Codex launcher, which #27681 fixed. So every harness ever woken stayed up with all its MCP servers.
  • What each harness carries: cmux mcp serve started cmux-cua the moment the harness listed its tools (same second, every chat), whether or not the chat ever used computer use. Codex starts its full MCP set, node_repl among them, once per thread.

This PR

cmux mcp serve stores the list each upstream (cmux-cua, secrets, offers) gives at its start in ~/.cache/cmux/mcp-upstreams, and answers tools/list from it without starting the server. The first tools/call starts the server, which lists again and stores again. A chat that never uses computer use never runs a cmux-cua.

  • Key: command, args, env names (never values: tokens, session ids), the real path, size and mtime of the executable the server's own PATH resolves the command to (an app update is a new key) and of every arg that names a file (python3 server.py: editing the script is a new key), cmux's version.
  • Folder 0700, files 0600, each entry written whole (temp file, then rename). A missing, torn or unreadable entry means the server starts to be listed, exactly as before.
  • Reaping: cmux-cua runs under cmux mcp serve, which runs under the harness, so it ends with the harness when the idle pass hibernates it (#27925, #27681) or the session stops. No new timer.

The other pieces of the same fix land on their own PRs:

  • #27925 (supersedes #27558): idle census matches MCP servers whose path has a space, by exact argv and executable, so nightly harnesses hibernate and take node_repl, agent-memory and cmux-cua with them.
  • #27681 (merged): idle Codex behind npm's launcher hibernates.
  • teamleaderleo/leo-workspace#1543 (merged): one shared agent-memory backend per host; each session's agent-memory mcp is a thin front (26.4 to 16.9 MB on macOS).
  • #27594: a daemon whose home is gone stops (the two leaked test daemons), and ACPMUX_SOCKET only pairs with ACPMUX_HOME.
  • #27731: CLI resumes and new sessions from an interactive Mac are placed on a fleet host; session stop --id works without --mesh.

Tests

  • a_stored_list_answers_without_starting_the_server_until_a_call (upstream_tests.rs): with a stored list, tools/list starts nothing; the first call starts the server and runs.
  • a_stored_tool_the_server_dropped_is_not_sent: a call to a tool the restarted server no longer lists is refused, not forwarded.
  • upstream_listing::tests: a list is shared across chats whose env values differ, dropped when the binary or a script arg (absolute or bare relative) changes; the key follows the server's own PATH and skips files nobody may run; a torn entry reads as none; a 0644 entry or a symlink is never read; the folder is 0700.

Performance

metricbeforeafterdeltaharness or run link
upstream servers running, 20 chats that list tools and never call one200-20bench below, run 41043089021
RSS of the 20 cmux mcp serve trees (debug build, stand-in upstream)1,411.7 MB1,166.7 MB-245.0 MB (-17%)same run
upstream RSS alone216.1 MB0 MB-216.1 MBsame run
time for 20 chats to get their tool list0.06 s0.04 s-0.02 ssame run
cmux-cua on the Mac in the census, 75 cmux mcp serve750 until a computer-use call-75ps census on the beachballed Mac, 2026-10-29 (coordinator's later census: 93 cmux-cua)

The bench (fleet Linux CI, 32 vCPU) starts 20 cmux mcp serve processes at once, each with one upstream, and sends initialize then tools/list as a harness does at thread start, then counts the upstream processes under them and sums VmRSS two seconds later. "Before" is the same binary with no stored list possible (~/.cache is a file), which is base's code path: every list starts its upstream. "After" first lets one chat list once (that chat starts its upstream and stores the list), then starts the 20. The upstream is a small Python MCP server, so its 10.8 MB per process is a floor: the real cmux-cua is a Mac binary and could not run there. The upstream count is the platform-independent number; the Mac census row is the measured one for cmux-cua.

No UI change, no capture.

Agent session: agent session

šŸ¤– Generated with Claude Code