Skip to content

Runtime MCP mutations can return before tool registry reconciliation #39902

Closed

Description

@kitlangton

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:

  1. Start with an empty mocked MCP catalog.
  2. Publish mcp.tools.changed after adding voice.list_open_tabs to the catalog.
  3. Block McpTool after it begins reading the updated catalog but before registration completes.
  4. Snapshot ToolRegistry after publication has completed.
  5. Observe that voice.list_open_tabs is absent.
  6. 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

  1. omidsaffari commented on Aug 1, 2026

    @omidsaffari

    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?

  2. cestercian commented on Sep 17, 2026

    @cestercian
    Contributor

    Looking into the register-time / reconciliation ordering gap — will land a fix that waits for ToolRegistry reconciliation before MCP mutation endpoints return.

  3. added a commit that references this issue on Oct 18, 2026
    579e73f
  4. DeepxD-code commented on Oct 18, 2026

    @DeepxD-code

    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.

  5. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    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, publishes mcp.tools.changed the way MCP.add/remove/connect/disconnect do, and then takes a ToolRegistry snapshot right away. That snapshot contains only ["execute"]. voice_list_open_tabs only appears after waiting for reconciliation.

    The cause matches your description. In packages/core/src/tool/mcp.ts, McpTool handles the event on a stream it forks off and debounces by 100 ms before it runs reconcile. The mcp.add, mcp.remove, mcp.connect and mcp.disconnect handlers in packages/server/src/handlers/mcp.ts return without waiting for it. To run the test, put it in packages/core/test/ and run bun test from packages/core.

    Reproduction script (mcp-read-after-write.test.ts)
    // Reproduction for #39902: after mcp.tools.changed is published (as MCP.add/remove/connect/disconnect do
    // before returning), ToolRegistry still reflects the previous catalog.
    import { expect } from "bun:test"
    import { Effect, Layer, Ref } from "effect"
    import { McpEvent } from "@opencode/schema/mcp-event"
    import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
    import { LayerNode } from "@opencode/util/effect/layer-node"
    import { Bus } from "@opencode/core/bus"
    import { Mcp } from "@opencode/core/mcp/index"
    import { Permission } from "@opencode/core/permission"
    import { McpTool } from "@opencode/core/tool/mcp"
    import { Tool } from "@opencode/core/tool"
    import { Image } from "@opencode/core/image"
    import { testEffect } from "./lib/effect"
    import { imagePassthrough } from "./lib/image"
    import { toolDefinitions, waitForTool } from "./lib/tool"
    
    testEffect(Layer.empty).live("registry reflects MCP mutation once publish returns", () =>
      Effect.gen(function* () {
        const catalog = yield* Ref.make<Mcp.Tool[]>([])
        yield* Effect.gen(function* () {
          const registry = yield* Tool.Service
          const registration = yield* McpTool.Service
          const bus = yield* Bus.Service
          yield* registration.flush
          // Simulate MCP.add: catalog updated, then ToolsChanged published, then the request returns.
          yield* Ref.set(catalog, [
            { server: Mcp.ServerName.make("voice"), name: "list_open_tabs", codemode: false,
              inputSchema: { type: "object", properties: {} } },
          ])
          yield* bus.publish(McpEvent.ToolsChanged, { server: "voice" })
          const immediately = (yield* toolDefinitions(registry)).map((t) => t.name)
          console.log("snapshot right after publish:", immediately)
          yield* waitForTool(registry, "voice_list_open_tabs")
          console.log("snapshot after waiting:", (yield* toolDefinitions(registry)).map((t) => t.name))
          expect(immediately).toContain("voice_list_open_tabs") // fails: read-after-write gap
        }).pipe(
          Effect.provide(
            Layer.fresh(
              AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
                Mcp.node.replace(Layer.mock(Mcp.Service, { tools: () => Ref.get(catalog) })),
                Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
                Image.node.replace(imagePassthrough),
              ]),
            ),
          ),
        )
      }),
    )

    Run it: bun mcp-read-after-write.test.ts

  6. neriousy commented on Nov 13, 2026

    @neriousy
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions