MCP: stdio servers are re-spawned per subagent and never closed while the supervisor stays live — duplicate fleets accumulate
Summary
One live codex.exe supervisor owns the MCP fleet roots for a project. When a subagent lazily uses a tool from a stdio MCP server, a new server process is spawned as a direct child of that same supervisor instead of reusing the fleet already attached to it. That child then outlives the subagent: the subagent finishes, but nothing closes the connection, so the supervisor/client lifecycle stays live and the process stays resident holding memory.
Over a session with several subagents, duplicate stdio server processes accumulate under a single supervisor with no signal anywhere that they are redundant.
Environment
- Codex CLI 0.149.0, standalone install, platform
windows-x86_64 - Windows 11 Professional 10.0.26200, Windows Terminal
- Auth: ChatGPT (subscription), model
gpt-5.6-sol, provideropenai - MCP config: 4 configured servers, 4 stdio, 1 disabled
- Sandbox: filesystem
restricted, networkrestricted, approval policyOnRequest
<details>
<summary><code>codex doctor --json</code> (relevant checks)</summary>
{
"codexVersion": "0.149.0",
"checks": {
"auth.credentials": {"status": "ok", "details": {"stored auth mode": "chatgpt", "stored API key": "false"}},
"config.load": {"status": "ok", "details": {"mcp servers": "4", "model": "gpt-5.6-sol", "model provider": "openai"}},
"mcp.config": {"status": "ok", "details": {"configured servers": "4", "disabled servers": "1", "stdio servers": "4"}},
"runtime.provenance": {"status": "ok", "details": {"install method": "standalone (windows)", "platform": "windows-x86_64", "version": "0.149.0"}},
"sandbox.helpers": {"status": "ok", "details": {"approval policy": "OnRequest", "filesystem sandbox": "restricted", "network sandbox": "restricted"}},
"app_server.status": {"status": "ok", "details": {"mode": "ephemeral", "status": "not running"}}
}
}
The full report is overallStatus: warning, but the three warnings are unrelated to this issue: worktree not on a Dev Drive, Microsoft Defender exclusions unverified, and a rollout-file/state-DB inventory mismatch.
</details>
Steps to reproduce
- Configure exactly one stdio MCP server that starts lazily (on first tool use).
- In one session, run two subagents, each calling a tool from that server.
- Let both subagents finish. Do not exit the CLI — the supervisor stays live.
- Census the supervisor's direct children:
Get-CimInstance Win32_Process |
Where-Object { $_.ParentProcessId -eq (Get-Process codex).Id } |
Select-Object ProcessId, Name, CreationDate
(pstree -p or ps --forest on Linux/macOS.)
Expected behavior
One live instance of that server per active client/project, and its process exits when the subagent that used it ends.
Actual behavior
Two live instances — one per subagent — both still running after both subagents ended, both direct children of the still-live supervisor. In a controlled two-subagent run, the duplicate count under that supervisor rose from 10 to 12, and both new children were still resident afterwards.
This is not one misbehaving server. After the server used in that run was removed from the config entirely, the same live supervisor still held duplicate fleets of four unrelated stdio servers (a Node REPL runtime, uvx mcp-obsidian, and two bun-launched plugin-cache servers). An earlier observation on the same machine had one live supervisor holding 5 concurrent instances of a single server and a second holding 3 more — 8 redundant processes. Any stdio MCP server used from more than one subagent accumulates the same way.
Suggested fix
Either of these addresses the class:
- Reuse one fleet per active client/project — a second subagent attaches to the server already running under that supervisor instead of spawning its own; or
- Close stdin / the child process when the owning client or subagent ends, so the server observes EOF and exits on its own.
Separately, and useful either way: expose a stable terminal state for a connection or lease — an observable "this client is gone" signal.
Why this cannot be worked around outside the client
The supervisor is live the whole time, so nothing in the process tree distinguishes a redundant duplicate from a fleet in active use. Ownership is opaque from outside: there is no lease, no connection id, and no terminal state to read. Terminating a duplicate under a live supervisor is therefore a guess that can tear down a server another subagent is mid-call on.
Our local cleanup tooling reflects that. It only reclaims fleets whose supervisor is gone; for duplicates under a live supervisor it is deliberately report-only — it counts them and refuses to act, because no evidence available to it would make acting safe. A terminal connection/lease state would let external tooling act on facts instead of heuristics, but the ownership fix belongs in the client either way.
3 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Closing as duplicate of #38754 — consolidating there. Evidence from this report (CLI 0.149.0, per-subagent spawn under a live supervisor, the 10→12 census, and generality across four unrelated stdio servers) is now a comment on that thread: https://github.com/openai/codex/issues/38754#issuecomment-5373487264
Following up with a source-level root cause, since
docs/contributing.mdasks for analysis rather than PRs. Read againstmain@51ebf5b1842d44a8e2c955e8b5cd2a589d41e71e(our repro was CLI 0.149.0 on Windows 11), so line anchors are from that tree.I believe this is not a missing-teardown bug — teardown is correct where it runs. It is that completed subagent threads are deliberately kept resident, and each resident thread owns a full MCP fleet.
The chain
Session.services.mcp_runtime(codex-rs/core/src/session/mcp_runtime.rs). That runtime owns its ownMcpConnectionManager, which owns its own stdio child processes. A subagent does not share the parent's.shutdown_session_runtime(codex-rs/core/src/session/handlers.rs:397), specificallysess.services.mcp_runtime.shutdown().awaitathandlers.rs:417. That call does the right thing —McpConnectionManager::shutdown(codex-rs/codex-mcp/src/connection_manager.rs:812) is documented as "Stop all MCP clients owned by this manager and terminate stdio server processes". It is reached only viaOp::Shutdown.Op::Shutdownis sent from exactly two places:close_agent→shutdown_agent_tree→shutdown_live_agent(codex-rs/core/src/agent/control/legacy.rs:8and:48), i.e. only when the parent explicitly closes the agent; andcodex-rs/core/src/agent/control/residency.rs:117), which callsshutdown_and_wait()beforeremove_thread.AgentStatus::Completedand stays resident on purpose, so the parent can stillwait/resume_agent/send_input/ read its transcript. Its MCP fleet stays alive with it. Nothing in the completion path drops the runtime.Why the count climbs and then appears to stick
Eviction is lazy and spawn-triggered only:
reserve_v2_residency_slot(residency.rs:49) unloads an LRU resident only when a new spawn hits capacity (residency.rs:81,:117). Capacity ismulti_agent_v2.max_concurrent_threads_per_session - 1— default 4, so 3 (codex-rs/core/src/config/mod.rs:224,effective_agent_max_threadsatmod.rs:1507). Nothing proactively reaps a completed resident.So the steady state per root thread is up to 3 completed-but-resident subagents, each holding a duplicate fleet, and a long-lived supervisor hosts many root threads at once. Observable duplicate fleets ≈ (live root threads) × (1 + up to 3).
That matches what we measured: a controlled two-subagent pilot took the census 10 → 12, nothing was reaped when both subagents finished, and removing one server left other duplicate fleets standing — because the duplication is per-thread, not per-server.
One second-order note
impl Drop for McpServerConnection(connection_manager.rs:148) only callsself.client.cancel_token.cancel(). It does not awaitshutdown(). So any path that drops a connection without an explicitshutdown()first is not a guaranteed reap of the stdio child — worth a look on Windows in particular, where an orphaned child is not cleaned up by process-group semantics.Suggested direction (smallest change first)
The two properties currently conflated are "thread stays resident so history/resume work" and "thread holds live OS processes". Splitting them looks like the minimal fix:
Completed/Errored/Interruptedwith no active turn and no pending mailbox items (the exact predicate already written asis_unloadable,residency.rs:233), callmcp_runtime.shutdown()and mark the runtime dormant. Re-publish on the next turn — the machinery already exists (publish_mcp_runtime,request_mcp_runtime_refresh), andensure_v2_agent_loadedalready rebuilds fully evicted threads, so a dormant-runtime resume is a strictly smaller operation than what resume already handles. Resume/history/transcript reads are unaffected because they do not touch the MCP runtime.Alternatives we considered and think are worse: proactively evicting residents on completion (loses the resume affordance the residency cache exists to provide), and sharing one connection manager across parent and subagents (larger change; complicates per-thread auth, elicitation routing, and tool-filter scoping).
Either way, a stable terminal state for the connection/lease — so a client can tell "this fleet is retained but idle" from "this fleet is live" — would make this observable rather than something you find with a process census.
Happy to re-measure against a build if that would help. We did not build or patch anything here; this is a read of the source plus the process-census evidence already in the issue.