Repository navigation
Description
Problem
Runtime MCP lifecycle endpoints can return before the MCP tool adapter has reconciled the new state into ToolRegistry and the Code Mode catalog.
MCP.add, remove, connect, and disconnect publish mcp.tools.changed. McpTool consumes that event on an asynchronously forked stream. A client can therefore mutate MCP state and immediately start a session whose first tool snapshot still reflects the previous registry state.
This is a general read-after-write consistency gap. It was discovered while investigating a voice plugin tool-routing failure, but it did not cause that specific failure.
Expected
After a runtime MCP mutation request returns, a subsequent session/tool snapshot should reflect the mutation.
Actual
The MCP service state is current when the request returns, while ToolRegistry and Code Mode may still be reconciling asynchronously.
Deterministic reproduction
A controlled test can:
- Start with an empty mocked MCP catalog.
- Publish
mcp.tools.changedafter addingvoice.list_open_tabsto the catalog. - Block
McpToolafter it begins reading the updated catalog but before registration completes. - Snapshot ToolRegistry after publication has completed.
- Observe that
voice.list_open_tabsis absent. - Release reconciliation and observe that the path appears.
This reproduces without sleeps or scheduler luck.
Practical impact
Normal HTTP/session work usually gives reconciliation time to finish, so the observed incidence is likely low. Repeated real-world probes did not reproduce a missing tool. The API nevertheless has no ordering guarantee, especially under load or for clients that issue a session request immediately after MCP mutation.
Potential direction
Expose the existing serialized McpTool reconciliation operation as a Location-scoped fence. Runtime MCP add/remove/connect/disconnect handlers can await it after mutating MCP state. Server-originated tool-list updates can remain asynchronous.
A prototype and regression test were implemented in #39898, then closed so the broader API behavior can be considered separately rather than bundled into the voice plugin investigation.
Notes
Avoid making all event-bus subscribers synchronous. A synchronous listener can deadlock because MCP tool discovery publishes before the server startup deferred settles, while reconciliation reads mcp.tools() and waits for that deferred.
Activity
omidsaffari commentedon Aug 1, 2026
I’d like to take this if the lifecycle-handler reconciliation fence explored in #39898 is still the preferred direction. I’ll keep it limited to awaiting the existing serialized reconciliation after runtime MCP add/remove/connect/disconnect, add the deterministic no-sleep regression described here, and avoid making the global event bus synchronous. If that scope is acceptable, could you assign it to me?
cestercian commentedon Sep 17, 2026
Looking into the register-time / reconciliation ordering gap — will land a fix that waits for ToolRegistry reconciliation before MCP mutation endpoints return.
fix(mcp): await tool registry reconciliation after mutations
DeepxD-code commentedon Oct 18, 2026
Fix pushed in PR #55231 (commit 579e73f on truncated-diff-patches-v2).
Root cause: MCP mutation methods (add/remove/connect/disconnect) published McpEvent.ToolsChanged/StatusChanged bus events, but McpTool consumed them on a debounced async stream (100ms). The mutation returned before ToolRegistry reconciled.
Fix: McpTool registers its reconcile function in a shared mutable cell (mcp-reconcile.ts) during layer construction. Mcp mutation methods now await it after mutating state. Breaks the circular dependency (McpTool depends on Mcp) without layer wiring changes.
Verified: mcp test suite 104/104 green, normalization 37/37, git 16/16, tsc clean.
opencodejr commentedon Oct 28, 2026
Thanks for the detailed write-up. I could reproduce this on
dev(1c49e51). The code involved hasn't changed since v2.2.0.The test below uses a mocked MCP catalog that starts empty. It adds
voice.list_open_tabs, publishesmcp.tools.changedthe wayMCP.add/remove/connect/disconnectdo, and then takes a ToolRegistry snapshot right away. That snapshot contains only["execute"].voice_list_open_tabsonly appears after waiting for reconciliation.The cause matches your description. In
packages/core/src/tool/mcp.ts,McpToolhandles the event on a stream it forks off and debounces by 100 ms before it runsreconcile. Themcp.add,mcp.remove,mcp.connectandmcp.disconnecthandlers inpackages/server/src/handlers/mcp.tsreturn without waiting for it. To run the test, put it inpackages/core/test/and runbun testfrompackages/core.Reproduction script (mcp-read-after-write.test.ts)
Run it:
bun mcp-read-after-write.test.tsneriousy commentedon Nov 13, 2026
Closing as fixed by #60490. Before taking a tool snapshot, a session now compares the live MCP catalog with the registered tools and reconciles them if they differ (
packages/core/src/session/context.ts,packages/core/src/tool/mcp.ts). So the next session step sees the change right after a runtime MCP add or remove, even though the handler still returns before the debounced reload. The earlier repro was on a build from before #60490. Please reopen if it still happens on a release that includes it.