[BUG] Cowork agent-mode spawns duplicate MCP server processes, breaking MCP Apps that share state between iframe and agent transports
Summary
When a Claude Desktop session uses both an MCP App (iframe served via ui://...mcp-app.html) and Cowork's local-agent-mode at the same time, Cowork spawns two independent OS processes of the same configured MCP server. The iframe's MCP transport is wired to one process; the agent's tool calls are wired to the other. Any state the server keeps in module-level memory — request queues, pending callbacks, session/view registries, auth tokens, in-flight bridges — is split-brained between the two processes.
The MCP App appears to work for stateless calls (tools/list, simple request/response tools) and silently fails for any flow that requires state to be shared between an agent-issued tool call and an iframe-issued follow-up. This is the common case for any MCP App that presents an interactive UI and lets the model drive it.
Related to but distinct from #50422 (cold-launch duplicate spawn, marked invalid). That issue is about regular MCP servers spawning twice on app launch with a ~2 minute gap. This issue is specifically about Cowork's agent-mode-on-top-of-iframe-host scenario, where both processes are spawned concurrently, one per logical transport.
Reproduction
The transport and packaging format don't matter — verified with both an .mcpb install and a direct claude_desktop_config.json entry using {"command":"node","args":["dist/index.js","--stdio"]}.
- Configure any MCP server that:
- Registers a
ui://...mcp-app.htmlresource (so it's an MCP App). - Holds state in module-level memory keyed off something the iframe later references — e.g. a session id returned by tool A, read back by tool B that the iframe calls.
- Open a Cowork project, enable local-agent-mode, ensure the MCP App's iframe is mounted in the same conversation.
- Have the agent call the state-mutating tool. Have the iframe (or the agent) issue a follow-up that depends on that state.
- The follow-up returns empty / "no such session" / times out — the state was written into a different process's memory than the one the follow-up call lands on.
Evidence the host is double-spawning
ps during a reproduction shows two concurrent processes for the same configured server, both children of the Claude helper:
PID PPID COMMAND
4109 4106 node /path/to/dist/index.js --stdio ...
4162 4161 node /path/to/dist/index.js --stdio ...
Each runs its own copy of the server module. Claude Desktop's mcp.log shows only one transport (the iframe's, clientInfo: "claude-ai"); the agent's transport is wired to the second process.
When the server logs its own pid alongside in-memory state mutations, the asymmetry is plain: an enqueue event logs pid A, the matching poll/dequeue logs pid B, both for the same logical session id.
Cross-referenced concretely:
Agent process (audit.jsonl)
02:36:00 CALL display_document → OK viewUUID=1806fccd-...
02:37:07 CALL interact {viewUUID:1806fccd-...}
02:37:32 ERR "Viewer never connected for viewUUID 1806fccd-... (no poll within 8s).
The iframe likely failed to mount."
Iframe-host process (mcp.log, [server-name], clientInfo: claude-ai)
02:36:27 initialize / tools/list / resources/list
02:36:29 resources/read mcp-app.html
02:36:32 poll_commands {viewUUID:1806fccd-...} ← iframe IS polling for this view
02:37:02 → "0 command(s)" ← long-poll returns empty after 30s
Same viewUUID, two different processes' queues, both empty from the other's perspective. The iframe is polling correctly; the agent enqueued correctly; they are talking past each other because the host bound them to different processes.
Why this is a problem for MCP Apps in general
The MCP Apps pattern requires shared state across the iframe and agent transports for any non-trivial UX:
- An agent tool returns a session/view id; the iframe reads it from
_metaand starts polling or subscribing. - The agent enqueues commands for the iframe to render or execute.
- The iframe reports user actions / form values back via an internal tool the agent then reads.
- The server tracks an authentication token that the iframe must echo back.
Every one of these patterns assumes the iframe and the agent are talking to the same server instance — the natural reading of MCP. The current behavior silently violates that assumption only when local-agent-mode is active alongside the iframe, so it's invisible during single-process testing and ships undetected.
Expected behavior (any of)
- Single instance per configured server entry. Cowork reuses the same MCP server connection and process for both the iframe and the agent. State works as written. (Strongly preferred.)
- Documented contract that MCP Apps must externalize state. If multi-process is intentional, the spec/docs need to say so clearly, and an SDK-supplied shared-state primitive (UNIX socket / SQLite-backed) should ship so server authors don't reinvent it.
- Routing semantics for
app-only tools that match server expectation. If "the iframe calls back into the same server" is part of the MCP Apps mental model — and it is, per existingapp-visibility scoping — then the host should ensure that route lands on the same process the agent is using.
Workaround
Server authors can move all shared state into an external store (UNIX socket leader/follower, SQLite, etc.) and treat the in-process MCP server as a thin frontend. This is significant extra complexity, isn't documented anywhere as a requirement, and adds latency to every iframe ↔ server round-trip.
Severity
Medium-High for any MCP App author. Failure mode is silent in dev and only triggers in agent-mode sessions, so it tends to ship undetected. Stateful MCP Apps are a primary use case for the MCP Apps pattern, so this affects the core value proposition.
Environment
- Claude Desktop (April 2026 build), macOS
- MCP App built with
@modelcontextprotocol/ext-apps - Cowork local-agent-mode active in the same conversation as the iframe
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗