Repository navigation
Description
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](
opencode/packages/core/src/tool/mcp.ts
Lines 124 to 144 in 7673ed6
| 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:
flushalso waits for a pending or runningreconcile, ormcp.addwaits 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
- Run
opencode serve, with the provider base URL pointed at a logging proxy. - Create a session.
PUT /api/experimental/mcp/<name>(callingawait client.mcp.add) with a remote MCP server that has tools. Wait for 204.GET /api/mcpshows<name>asconnected(callingawait client.mcp.list)- At once, send a prompt that needs 2 or more steps, for example "create a file, then read it back".
- In the proxy, compare the
toolsarray 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
argszero commentedon Sep 30, 2026
I'd like to work on this.
Reproduced the shape of the bug in the code on the current
v2tip (74dbc509d7):mcp.addreturns204as soon asMcp.Service.addresolves (packages/server/src/handlers/mcp.ts).McpEvent.ToolsChangedis published into a slidingPubSub, and the subscriber runsStream.debounce("100 millis")→reconcile→tools.reload()forked (packages/core/src/tool/mcp.ts).McpToolisflush, and it isEffect.asVoid(Fiber.await(initial))— the initial discovery only.packages/core/src/session/context.ts:127awaitsmcpTools.flushbefore building a step's tool snapshot, so the step aftermcp.addstill gets the pre-add catalog until the debounce fires.mcp.removehas 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 havemcp.add/mcp.removeawait that before answering204. I'll add a core test that drivesmcp.addplus the settle point and asserts the snapshot contains the server's tools, with the debounce still in place.#51812looks like the same family from a different trigger (first prompt after boot, nomcp.addinvolved) and is assigned, so I'll keep this scoped to the add/remove path.argszero commentedon Sep 30, 2026
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.connectandmcp.disconnectawait it before answering, so a step right after the response already sees the new tool set.flushis unchanged.The PR is based on
v2becausepackages/core/src/tool/mcp.tsandpackages/server/src/handlers/mcp.tsdo not exist ondev. The regression test lives inpackages/core/test/mcp.test.tsand fails on the unmodified tree.alexander-goncharuk commentedon Sep 30, 2026
Yep this would be exactly the expected behavior. Thanks for picking this!
coldsmirk commentedon Oct 11, 2026
Still happens on 2.0.25. We register our server with
PUT /api/experimental/mcp/{name}, wait untilGET /api/mcpsays 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.opencodejr commentedon Oct 28, 2026
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:
mcp.addand waited for the 204.mcp.listthen showed the server asconnected.pingsrv_pingtool, 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 itsflushonly waits for the initial registration. I didn't testmcp.remove, but it goes through the same reload. The script below reproduces it.Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.