Subagents need an MCP capability broker: parent allowlists, zero-start by default, bounded pooling, and deterministic teardown

Open 💬 5 comments Opened Aug 13, 2026 by NgoQuocViet2001
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

What variant of Codex are you using?

Codex Desktop / app-server multi-agent workflows. The design should also apply to CLI and IDE subagents.

Observed on:

  • Codex App 26.803.10989.0
  • Windows 11 x64, build 26200
  • multi_agent = true
  • max_concurrent_threads_per_session = 128

What feature would you like to see?

Treat MCP access as a leased capability assigned by the parent/orchestrator, rather than automatically turning every inherited MCP configuration into a live runtime for every subagent.

A general-purpose child should start with no live MCP processes. The parent should be able to give that child an explicit MCP allowlist, and an allowed server should start only when the child actually calls one of its tools. MCP runtimes should be bounded, attributable to an owner/lease, and deterministically released.

This is both a reliability feature and a least-privilege feature: most coding/research/QA children do not need every credentialed MCP available to the parent.

Why this is a distinct gap

There are several related reports, but they cover individual symptoms or only part of the lifecycle:

  • #30408: per-thread MCP processes are retained and consume large amounts of memory.
  • #20883: proposes a project-scoped process pool.
  • #37426: stale children and the full inherited stdio MCP suite on Windows Desktop.
  • #38247: completed v2 subagents retain their stdio runtimes.
  • #18881 was fixed by #19753, which added explicit shutdown and process-tree cleanup.
  • #38217 recently added lazy startup for required subagent MCP servers when usable cached tool definitions already exist.

Those are valuable pieces. The missing abstraction is a single policy/ownership layer covering:

  1. which MCPs a child is allowed to use;
  2. whether any process must start at child creation;
  3. how first-run/catalog discovery avoids an N-child fan-out;
  4. how live instances are bounded and reused where safe;
  5. who owns each process and when its lease ends;
  6. what happens when resource limits are reached.

In particular, #38217 is a good foundation, but cached-tool lazy startup alone does not cover first run/cache miss, explicit per-child capability selection, bounded process ownership, or completed-child teardown.

Concrete production incident

In one Codex Desktop task, the UI showed 128 active subagents and remained in progress for more than an hour. Steering/new messages stopped being accepted.

At the time of inspection, one globally configured Node stdio server (@sjawhar/whatsapp-mcp) had multiplied into:

  • 11 Node server processes;
  • 11 launcher processes;
  • about 1.465 GB working set for the Node servers alone;
  • several old instances with roughly 3,600 CPU seconds each.

This MCP was not relevant to the children doing repository research.

Killing only that exact 22-process MCP tree reduced the pressure immediately. Two instances respawned while the configuration was still enabled. After removing only that MCP configuration and terminating those two exact processes, the count stayed at zero in the follow-up check. No unrelated MCP configuration was changed.

The high concurrency setting made the incident severe, but this is exactly why fan-out needs backpressure: an accepted subagent limit should not silently permit subagents × configured MCP process trees to exhaust the machine or make the parent UI unsteerable.

Proposed model

1. Parent-controlled capability manifest

The parent supplies an allowlist when spawning a child. The default lightweight child inherits no live MCP servers.

Illustrative configuration/API (names are only examples):

[agents.default.mcp]
inherit = "none"
startup = "on_tool_call"
max_live_servers = 2
idle_timeout_sec = 60

[agents.docs_researcher.mcp]
allow = ["openai-docs"]

An equivalent spawn-time override could be:

spawn_agent(..., mcp_allow = ["github"], mcp_inherit = false)

Keep inherit = "all" as an explicit compatibility option.

2. Separate tool catalogs from live connections

A child may need tool schemas for planning, but that should not require one live server per child.

  • Reuse a centrally cached catalog keyed by effective server configuration.
  • On a cache miss, perform at most one coordinated discovery startup for that configuration, cache the result, then stop the discovery process if it is not leased.
  • Concurrent children wait on the same discovery future instead of each starting a copy.
  • A selected/required MCP should be eager only when explicitly required for that specific child, not merely because it is required in the parent's global configuration.
3. Bounded MCP broker/pool

The app-server should broker runtimes by a stable key such as workspace + effective config hash + auth/permission scope.

  • Share only servers that are declared safe to share.
  • For stateful/non-shareable servers, use a bounded per-server pool.
  • Queue requests or return a clear resource-limit error instead of spawning without a ceiling.
  • Apply a global process/memory budget independent of the maximum subagent count.
4. Lease-based ownership and deterministic teardown

Every live MCP runtime should expose an owner/lease:

server_config_hash
runtime_id
owning_task_or_pool
agent_ids
started_at
last_used_at
lease_count
shutdown_reason

Release the lease on child completion, cancellation, failure, interruption, thread close, and app-server shutdown. When the last lease is released, terminate immediately or after a short idle TTL. Process-tree teardown must work even if client objects or resumable agent identities still exist.

5. Resource backpressure and diagnostics

Before spawning a child or MCP runtime, estimate/check the resulting budget.

If the limit would be exceeded:

  • keep the MCP dormant;
  • queue the child/tool call;
  • or show a clear warning with the server and owning agents.

The Desktop diagnostics panel should show live MCP instance count, owning task/agent, age, CPU/memory, and a safe “unload idle MCPs” action.

Suggested implementation path

  1. Extend the lazy-start work from #38217 so the tool catalog is not coupled to a per-child live connection, including cache-miss coalescing.
  2. Add a per-agent MCP capability manifest to the spawn/session configuration.
  3. Put process creation behind an app-server broker with per-config single-flight and configurable pool limits.
  4. Reuse the explicit shutdown/process-handle machinery from #19753 for lease release.
  5. Add a watchdog/circuit breaker so MCP fan-out can never make the parent unable to accept cancellation or steering.

Acceptance tests

A PID-recording stdio fixture could make this deterministic:

  1. No-use fan-out: spawn 64 children that never call MCP tools. After catalog discovery, live MCP process count must remain at the parent baseline, not grow with N.
  2. Cold-cache single-flight: with no cached catalog, spawn 64 children concurrently. At most one discovery runtime per effective server config may start, and it must exit after discovery if unleased.
  3. Explicit allowlist: only one designated child is allowed to use server A. Other children must neither see its callable tools nor receive its credentials.
  4. First tool call: the designated child calls one MCP tool. Exactly one runtime (or the configured bounded count) starts.
  5. Completion/cancel/error: after that child completes, is cancelled, or crashes, the runtime returns to baseline within the teardown/idle timeout.
  6. Resume: a later follow-up lazily reacquires a healthy runtime; historical thread viewing alone starts none.
  7. Backpressure: setting a high subagent limit cannot exceed the separate MCP runtime budget, and parent cancellation/steering remains responsive.
  8. Windows process tree: wrapper and descendant processes are gone after lease release, not only the direct launcher.

Additional information

Official documentation currently explains that subagents inherit parent session settings such as mcp_servers unless overridden, and that concurrency is separately controlled by agents.max_concurrent_threads_per_session:

Configuration inheritance is useful, but it should mean “available under policy,” not “eagerly instantiate the entire capability surface for every child.”

This proposal would turn the recent lazy-start and shutdown fixes into a coherent invariant:

Creating a subagent must not create a live MCP process unless that child was explicitly granted the capability and actually needs a connection; every created runtime must have a bounded owner and a deterministic release path.

View original on GitHub ↗

5 Comments

github-actions[bot] contributor · 15 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #37426
  • #38247
  • #37870
  • #37453

Powered by Codex Action

dajiaohuang · 14 days ago

I reviewed the current main implementation and the related reports/PRs before considering code. Today, spawned children clone the parent’s effective config; each thread owns its own McpRuntime; the shared McpManager provides catalog caching but not runtime ownership/pooling. LazyWhenCached avoids startup only when usable cached schemas already exist, so cold-cache fan-out and completed-child retention remain. Draft #31922 is a useful partial foundation for completely tool-free helper threads, but it does not provide per-child MCP selection or lifecycle ownership.

For account context, I use Codex on a Pro 20x subscription.

Would maintainers consider a first, reviewable stage limited to a durable spawn-time MCP allowlist?

  • omitted allowlist: inherit all, preserving current behavior initially;
  • empty allowlist: expose/start no MCP servers;
  • populated allowlist: permit only exact server names available to the parent;
  • apply the filter after config/plugin/extension contributions but before connection creation;
  • persist the policy with the spawned-thread source so residency reload and cold resume cannot broaden authority;
  • cover tool/resource invisibility and zero PID creation for disallowed servers.

This stage would deliberately leave cold-cache single-flight discovery, pooling/leases, automatic completion teardown, budgets, diagnostics, and any default switch to deny-all for follow-ups.

The blocking design questions are:

  1. Is that a suitable first-stage boundary, or should capability policy land only together with broker/lease ownership?
  2. Should an empty allowlist reuse/replace #31922’s tool_free path, or remain an MCP-specific policy alongside it?
  3. Should the initial compatibility default remain inherit-all, or should new subagents become deny-all immediately?

I will not implement or submit an external code PR unless a maintainer confirms the direction and explicitly invites the contribution.

NgoQuocViet2001 · 14 days ago

Thanks for reviewing main and #31922 at the code level. Speaking as the issue reporter, not for the maintainers, I strongly support the durable spawn-time allowlist as an independently reviewable first stage.

My answers to the three questions are:

  1. Land the allowlist before broker/lease ownership rather than wait for the entire architecture. It creates an enforceable capability boundary and immediately prevents cold-cache startup for servers a child was never granted. Pooling, single-flight discovery, budgets, and teardown can remain follow-up stages. This first stage would not fix retention of an allowed server, so this issue should remain open as the architectural umbrella.
  1. Keep an empty MCP allowlist semantically separate from tool_free. tool_free removes the entire tool/skill/plugin surface. mcp_allow = [] should mean “no MCP” while still permitting appropriate built-in tools such as shell and apply-patch. The implementations may share low-level filtering, but overloading tool_free would make the policy too coarse.
  1. Use inherit-all as the initial compatibility default, with an explicit deny-all option. The API needs to preserve the tri-state distinction:
  • omitted: compatibility inheritance;
  • empty: deny every MCP;
  • populated: allow exactly those server IDs.

One important nuance: after spawn-time resolution, even omitted/inherit-all should be normalized to the exact effective server IDs granted at spawn and persisted with the child. Otherwise, adding a global/plugin MCP later could silently broaden an old child’s authority on cold resume. Only an explicit parent action should expand the persisted grant.

A few acceptance details would make this an authorization boundary rather than only a startup optimization:

  • resolve config/plugin/extension contributions first, then filter before catalog lookup, authentication/environment propagation, or connection creation;
  • filter every MCP surface—tools, resources, prompts, deferred/tool-search candidates—not only live connections;
  • use stable server IDs; unknown requested names should fail closed with a clear spawn error, never fall back to inherit-all;
  • enforce the persisted grant on follow-up, residency reload, and cold resume;
  • cover both warm and cold catalog states and assert zero PID creation for disallowed stdio servers;
  • verify that an empty MCP allowlist preserves permitted non-MCP built-in tools.

There is now a second independent Windows incident behind this request. After removing the previously identified WhatsApp MCP, a later session again reached 91% memory with dozens of Python (2) groups. The remaining Python MCPs were identified from their live process trees as workspace-mcp and mcp-atlassian; each runtime used an uvx -> uv -> wrapper -> python -> python chain. The Desktop log simultaneously showed app-server saturation at six in-flight requests, repeated thread/read hydration expiry, and even critical turn/steer requests expiring after 30 seconds. Disabling only those two MCPs and reducing subagent concurrency from 128 to 6 left multi-agent enabled, removed the exact ten-process current baseline, and produced zero respawns in the follow-up check.

Per docs/contributing.md, I will not open an unsolicited upstream PR. I am preparing a focused, tested branch implementing only this first-stage allowlist so the design can be evaluated concretely.

@openai/codex-core-agent-team: could a maintainer confirm whether this stage boundary is aligned and, if so, explicitly invite the contribution? The proposed PR will stay under the repository’s size guidance and include integration coverage for omitted/empty/populated policy, unknown-name failure, zero disallowed PID startup, and persisted resume behavior.

NgoQuocViet2001 · 14 days ago

@openai/codex-core-agent-team I implemented the proposed first-stage mitigation as a tested draft review artifact: https://github.com/NgoQuocViet2001/codex/pull/1

The patch adds a V1/V2 spawn_agent.mcp_allow contract with three explicit states (snapshot current effective set / deny all / exact validated subset), applies it after all MCP contributions are merged but before connections start, and persists the exact grant across cold resume. A PID-level Windows test verifies that mcp_allow: [] does not launch another stdio MCP process.

I did not open an upstream PR because the contribution policy says external PRs are invitation-only. If this direction fits the team's architecture, an invitation would let me retarget the same rebased and tested commit to openai/codex without rework.

NgoQuocViet2001 · 14 days ago

Follow-up correction after a deeper state/runtime inspection: the Desktop "Active subagents" counter is not a trustworthy concurrency measurement.

In a separate affected task on the same Desktop build, the UI showed 273 Active + 13 Done. A read-only inspection found exactly 286 persisted descendants, and all 286 thread_spawn_edges rows were still open. At the same time, the parent reported only 1 executing child, and the Windows process tree contained 9 node_repl.exe children rather than hundreds of agent runtimes.

Detailed evidence and the proposed lifecycle/reconciliation invariant are here: #38364 (comment https://github.com/openai/codex/issues/38364#issuecomment-5289195013).

Therefore, the original incident's “128 active” UI value should be read as 128 entries classified Active by the panel, not as proof that 128 model threads were simultaneously executing. I do not want the capability-broker request to rely on an incorrect concurrency claim.

The MCP resource incident itself remains independently verified:

  • real duplicated MCP process trees and their working sets were measured in Task Manager/process inventory;
  • lowering the resident subagent cap reduced the amplification;
  • each resident/resumed child can inherit multiple stdio MCP runtimes, so a cap of 20 can plausibly produce roughly 100 OS processes without there being 100 concurrent agents;
  • historical hydration can also recreate MCP runtimes, consistent with #37453.

So there are two related but distinct defects:

  1. Lifecycle/UI reconciliation: historical/open edges are mislabeled as currently Active (#38364 / #37453).
  2. Capability/resource amplification: each genuinely resident or resumed child may instantiate inherited MCP stacks; spawn-time allowlists and bounded ownership remain useful even when the true live agent count is small (this issue).

This distinction also suggests a useful diagnostic contract: expose separate counts for executing, resident, and historical/open-edge, plus MCP runtimes owned by each execution lease.