Skip to content

mcp.add resolves befor the session tool registry has the new server's tools #51089

Open

Description

@alexander-goncharuk

Description

PUT /api/experimental/mcp/{server} (mcp.add) returns after the server connects. GET /api/mcp then reports connected. But the tool registry that a step reads is updated later, by a debounced reload, so a prompt sent right after mcp.add can run its first step without the new server's tools.

Proof in code:

The mcp.add handler waits for Mcp.Service.add, then returns 204: [packages/server/src/handlers/mcp.ts#L29-L36]

McpTool handles ToolsChanged through Stream.debounce("100 millis") and then runs reconcile => tools.reload(). Its flush waits only for the initial registration: [packages/core/src/tool/mcp.ts#L124-L144](

const reconcile = lock.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
yield* tools.reload()
}),
)
// Servers announce tools in bursts and each read loads the whole catalog, so settle and refresh
// once. The bus subscription stays eager; only the already-open sliding subscription is debounced.
const changes = yield* PubSub.sliding<void>(1)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => PubSub.publish(changes, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
)

Expected: when mcp.add resolves, the next step's tool snapshot includes the server's tools. Two possible ways:

  • flush also waits for a pending or running reconcile, or
  • mcp.add waits for the reconcile that its own connect started.

The same applies to mcp.remove: its tools stay in the snapshot until the debounced reload runs.

Plugins

No response

OpenCode version

2.0.8

Steps to reproduce

  1. Run opencode serve, with the provider base URL pointed at a logging proxy.
  2. Create a session.
  3. PUT /api/experimental/mcp/<name> (calling await client.mcp.add) with a remote MCP server that has tools. Wait for 204.
  4. GET /api/mcp shows <name> as connected (calling await client.mcp.list)
  5. At once, send a prompt that needs 2 or more steps, for example "create a file, then read it back".
  6. In the proxy, compare the tools array of the first and the second model request. The first request lacks <name>_* tools. The second request has them.

A wait of about 125 ms between steps 4 and 5 makes the first request include the tools.

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Activity

  1. argszero commented on Sep 30, 2026

    @argszero
    Contributor

    I'd like to work on this.

    Reproduced the shape of the bug in the code on the current v2 tip (74dbc509d7):

    • mcp.add returns 204 as soon as Mcp.Service.add resolves (packages/server/src/handlers/mcp.ts).
    • The registry that a step reads is refreshed on a separate path: McpEvent.ToolsChanged is published into a sliding PubSub, and the subscriber runs Stream.debounce("100 millis") → reconcile → tools.reload() forked (packages/core/src/tool/mcp.ts).
    • The only settle point exposed by McpTool is flush, and it is Effect.asVoid(Fiber.await(initial)) — the initial discovery only. packages/core/src/session/context.ts:127 awaits mcpTools.flush before building a step's tool snapshot, so the step after mcp.add still gets the pre-add catalog until the debounce fires. mcp.remove has the mirror problem.

    So the gap is exactly what you describe: nothing awaits the reconcile that the connect started.

    Planned fix, keeping it small: make the registry's settle point cover a pending or running reconcile (not just the initial scan), and have mcp.add / mcp.remove await that before answering 204. I'll add a core test that drives mcp.add plus the settle point and asserts the snapshot contains the server's tools, with the debounce still in place.

    #51812 looks like the same family from a different trigger (first prompt after boot, no mcp.add involved) and is assigned, so I'll keep this scoped to the add/remove path.

  2. argszero commented on Sep 30, 2026

    @argszero
    Contributor

    Opened #52214 for this.

    It keeps the debounced reload for background announcements and adds McpTool.refresh: re-read the connected servers and reload the registry under the same semaphore the debounced path uses, so a refresh cannot interleave with a queued one. mcp.add, mcp.remove, mcp.connect and mcp.disconnect await it before answering, so a step right after the response already sees the new tool set. flush is unchanged.

    The PR is based on v2 because packages/core/src/tool/mcp.ts and packages/server/src/handlers/mcp.ts do not exist on dev. The regression test lives in packages/core/test/mcp.test.ts and fails on the unmodified tree.

  3. alexander-goncharuk commented on Sep 30, 2026

    @alexander-goncharuk
    Author

    Planned fix, keeping it small: make the registry's settle point cover a pending or running reconcile (not just the initial scan), and have mcp.add / mcp.remove await that before answering 204.

    Yep this would be exactly the expected behavior. Thanks for picking this!

  4. coldsmirk commented on Oct 11, 2026

    @coldsmirk

    Still happens on 2.0.25. We register our server with PUT /api/experimental/mcp/{name}, wait until GET /api/mcp says connected, then send a prompt: the first model request has only the built-in tools, the second has ours too. Sleeping ~300 ms after connected works around it for now; #52214 would fix it for us.

  5. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I could reproduce this on 2.0.8, and it still happens on the latest dev (fa1260d).

    What I did, using a small local stdio MCP server with one tool and a simulated model:

    1. Called mcp.add and waited for the 204. mcp.list then showed the server as connected.
    2. Right away, created a session and sent a prompt that takes two steps.
    3. Compared the model requests: the first had no pingsrv_ping tool, the second did. With a 1 s wait before the prompt, both had it.

    This matches your reading of the code. The tools are only re-registered by the 100 ms debounced reload in McpTool, and its flush only waits for the initial registration. I didn't test mcp.remove, but it goes through the same reload. The script below reproduces it.

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#51089: a prompt sent right after `mcp.add` resolves
    // runs its first step without the new MCP server's tools.
    //   opencode-drive run drive.ts                  -> opencode on PATH
    //   OC_DEV=/path/to/checkout opencode-drive run drive.ts
    //   DELAY_MS=1000 ...                            -> wait after mcp.add; tools then present
    import { writeFileSync } from "node:fs"
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    import * as Mcp from "@opencode/schema/mcp"
    
    // Minimal stdio MCP server exposing one tool, "ping".
    const SERVER = "/tmp/opencode-51089-mcp.mjs"
    writeFileSync(
      SERVER,
      `import readline from "node:readline"
    const send = (m) => process.stdout.write(JSON.stringify(m) + "\\n")
    readline.createInterface({ input: process.stdin }).on("line", (line) => {
      if (!line.trim()) return
      const msg = JSON.parse(line)
      if (msg.id === undefined) return
      if (msg.method === "initialize")
        return send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: msg.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "pingsrv", version: "1.0.0" } } })
      if (msg.method === "tools/list")
        return send({ jsonrpc: "2.0", id: msg.id, result: { tools: [{ name: "ping", description: "Ping", inputSchema: { type: "object", properties: {} } }] } })
      if (msg.method === "tools/call")
        return send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: "pong" }] } })
      send({ jsonrpc: "2.0", id: msg.id, result: {} })
    })
    `,
    )
    
    const DELAY = Number(process.env.DELAY_MS ?? 0)
    const toolNames = (body: any): string[] => (body?.tools ?? []).map((t: any) => t?.function?.name ?? t?.name).sort()
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        project: { git: true, files: { "a.txt": "hello\n" } },
      },
      ({ llm, opencode }) =>
        Effect.gen(function* () {
          const seen: string[][] = []
          yield* llm.serve((request: any) => {
            const names = toolNames(request.body)
            if (names.length === 0) return Stream.make(Llm.text("Title")) // title generation, not an agent step
            const step = seen.length
            seen.push(names)
            if (step === 0)
              return Stream.make(
                Llm.toolCall({ index: 0, id: "call_1", name: "read", input: { filePath: "a.txt" } }),
                Llm.finish("tool-calls"),
              )
            return Stream.make(Llm.text("done"))
          })
          const c: any = opencode
          const call = (f: (c: any) => any) => f(c) as Effect.Effect<any, unknown, never>
    
          yield* call((client) =>
            client.mcp.add({
              server: "pingsrv",
              config: new Mcp.LocalConfig({ type: "local", command: ["node", SERVER], codemode: false }),
            }),
          )
          const servers = yield* call((client) => client.mcp.list())
          console.log("mcp.list after add:", JSON.stringify(servers.data))
          if (DELAY) yield* Effect.sleep(DELAY)
          const session = yield* call((client) => client.session.create({ location: servers.location }))
          yield* call((client) => client.session.prompt({ sessionID: session.id, text: "read a.txt then answer" }))
          for (let i = 0; i < 50 && seen.length < 2; i++) yield* Effect.sleep(200)
          seen.forEach((names, i) =>
            console.log(`step ${i + 1} request: pingsrv_ping present = ${names.includes("pingsrv_ping")}; tools = ${names.join(",")}`),
          )
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions