You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
First prompt after start has no MCP tools: tool list does not wait for pending MCP connections #51812
After opencode serve starts (or on opencode run --standalone), the first prompt sent for a directory is built without any MCP tools. From the second prompt on, the MCP tools are present.
As far as I can tell, the per-directory instance is booted lazily on first access and MCP servers connect in the background. Building the tool list (MCP.tools()) does not wait for connections that are still pending (unlike callTool), so the first request only contains built-in tools.
For opencode run --standalone this means a one-shot run never gets MCP tools, since there is no second prompt.
Steps to reproduce
Configure one or more remote MCP servers (type: remote, codemode: false).
opencode serve (fresh start), then send the first prompt for a directory, or run opencode run --standalone "...".
Inspect the request sent to the model: no MCP tools. GET /api/mcp right before the prompt shows the servers as pending.
Send a second prompt: MCP tools are included.
Expected
The first prompt waits (with a timeout) for MCP servers that are still connecting, or the instance connects MCP eagerly on boot, so the first request gets the same tool set as later ones.
Workaround
Poll GET /api/mcp (with x-opencode-directory) after start until no server is pending, then send the prompt. For run, start a private opencode serve, wait the same way, then opencode run --server <url>.
Environment
opencode 2.0.18 (Linux), opencode serve --service and opencode run --standalone
This issue might be a duplicate of or related to an existing issue. Please check:
#51089: A prompt immediately after mcp.add can run before the connected server’s tools are reconciled into the session registry, so MCP tools are also missing from the first model request. That issue concerns runtime MCP additions; this report concerns servers still connecting at startup.
I reproduced the initial-read boundary with the real Mcp and McpTool layers at v2.0.18 (cd9a14a), using the existing local HTTP MCP fixture and a Deferred gate on its POST handshake. No model or external MCP server was involved.
The test observes, in order:
The server has received the handshake but the gate is still closed.
McpTool.flush returns successfully.
Mcp.servers() still reports pending, and the Tool registry has no resources_echo definition.
After releasing the gate and waiting for connection settlement, Mcp.tools() includes echo.
This passes as a characterization test (1 test, 3 assertions). It exercises the readiness boundary used by SessionContext.select, not a complete serve/run session.
The source path is: initial connections deliberately fork in Mcp.reconcile; Mcp.tools() returns the current catalog without waiting; the initial registration fiber in tool/mcp.ts therefore finishes with an empty catalog. flush awaits that fiber, and SessionContext.select then takes its snapshot. The relevant logic is unchanged on current v2 39e1ce55.
This appears distinct from #51089's post-mcp.add debounce window: here the connection itself is still pending. A readiness fix should retain asynchronous Location startup while deciding what the first session snapshot waits for, including failed/disabled/cancelled servers. The draft #47389 also touches this boundary, so I am leaving this scoped reproduction here for the assigned maintainer rather than opening a competing patch.
Our case for changing this: T3 Code starts opencode serve, adds a per-thread MCP server with PUT /api/experimental/mcp/:server, and sends the first prompt right away. Some providers fix the tool list for the whole turn (cursor-opencode-provider does), so turn 1 never sees that server's tools. Here's a proposal that keeps startup async: on a session's first model request only, wait for servers that are still pending, up to a short limit (say 3 s, configurable), then go on with whatever has connected. Later requests never wait, and one slow server can't hold up Location startup. We do this today in a plugin on 2.0.22 (session.hook("prompt") holds the first prompt, with a cap, until the servers are registered): the wait was about 100 ms on a new thread and 0 ms on later turns. The mcp.add side of the same race is #51089 / PR #52214.
Waiting until /api/mcp shows no pending servers (the workaround above) isn't enough on 2.0.28. A server can show connected and still be missing from the first turn's tools for ~100ms. Probably the same debounce as #53299 / #51089, but over opencode serve instead of ACP.
Repro: opencode serve with two local MCP servers, one of which starts 2s late. Wait until both show connected, then ask the model which tools it has:
Prompt sent after both connected
Tools the model saw
immediately
fast only (slow missing)
1.5s later
fast and slow
Same in 2/2 runs, and in 3 real sessions with the Atlassian MCP server. Our workaround: wait 1s after everything shows connected.
Same here. Our local workaround (a plugin that holds the first prompt) ended up waiting until each connected server's tools show up in the tool registry, not just until its status is connected, because of exactly that gap. So if the bounded wait I suggested above gets picked up, it should key off the registry rather than the status.
This still reproduces on 2.0.29 and 2.0.31 with a self-contained repro. The MCP server is @modelcontextprotocol/server-everything@1.0.0, started as sh -c "sleep 10; exec npx -y @modelcontextprotocol/server-everything@1.0.0" (local, codemode: false). The client is plain curl against opencode serve, with fresh XDG dirs and anthropic/claude-haiku-5-5.
Prompt: "Call the echo tool with the message hi, then reply with what it returned."
Version
Turn
GET /api/mcp at prompt
Prompt tokens (1st request)
echo called
2.0.29 / 2.0.31
first turn, ~0.35 s after /api/info
[]
8,077
no ("none is available in this session")
2.0.29 / 2.0.31
first turn, held until the server shows pending
pending
8,092
no
2.0.29 / 2.0.31
new session, 2 s after connected
connected
11,600
yes, everything_echo returns "Echo: hi"
One more data point for the polling workaround: for the first 0.3–0.45 s after the first request for a directory, GET /api/mcp returns []. The configured server is not listed even as pending, so polling until no server is pending can pass before the server is registered at all. To be safe, wait until each configured server is listed and connected, and its tools are in the registry.
The source path is unchanged on v2.0.31 and dev (180fd9d): Mcp.tools() at mcp/index.ts L663–L668 does not await startup, McpTool.flush at tool/mcp.ts L144 waits only for the initial registration, and SessionContext.select at session/context.ts L127–L134 snapshots right after it. PR #54942 would not change this, because it re-reads Mcp.tools(), which still leaves pending servers out.
Thanks for the detailed report, I can reproduce this.
With a mock remote MCP server that takes ~1.5 s to answer initialize (codemode: false) and a mock OpenAI-compatible provider that logs the tools in each request, on 2.0.18:
opencode run --standalone "...": the model requests only ever contain the built-in tools. The run finishes before the MCP server connects, so a one-shot run never gets MCP tools.
A fresh opencode serve, then prompts via opencode run --server:
The first prompt has no MCP tools, and GET /api/mcp shows the server as pending.
Later prompts include the MCP tool.
One extra finding: on 2.0.18 the second prompt was still missing the tools even though I waited until GET /api/mcp reported connected (the mock server got a second initialize around then). So polling /api/mcp may not be a fully reliable workaround.
On the latest dev (86ce19d), the first prompt after opencode serve still has no MCP tools. I couldn't check run --standalone on dev from source because the standalone server didn't start there.
The script below reproduces both cases. Related, but different problems: #45333 and #38470.
Reproduction script (repro.ts)
// Reproduction for anomalyco/opencode#51812: the first prompt after start has no MCP tools.// Starts a mock OpenAI-compatible provider (logs the tool names in each request) and a mock remote MCP// server whose `initialize` takes MCP_DELAY ms, then runs `opencode run --standalone` once.// Usage: bun repro.ts (uses `opencode` on PATH)// OC_CMD="bun ... /workspace/repo/packages/cli/src/index.ts" bun repro.ts (from source)import{mkdtempSync,writeFileSync}from"node:fs"import{tmpdir}from"node:os"import{join}from"node:path"constDELAY=Number(process.env.MCP_DELAY??1500)constrequests: string[][]=[]constllm=Bun.serve({port: 0,asyncfetch(req){consturl=newURL(req.url)if(!url.pathname.endsWith("/chat/completions"))returnnewResponse("{}",{status: 404})constbody=awaitreq.json()constnames=(body.tools??[]).map((t: any)=>t.function?.name??t.name)// Code Mode lists MCP tools inside the `execute` tool description; count those too.if(JSON.stringify(body.tools??[]).includes("echo_probe")&&!names.includes("echo_probe"))names.push("echo_probe(in execute)")requests.push(names)console.log(`[llm] request #${requests.length}: ${names.length} tools: ${names.join(", ")}`)constid="chatcmpl-1"constchunk=(delta: any,finish: string|null=null)=>`data: ${JSON.stringify({ id,object: "chat.completion.chunk",created: 0,model: "m",choices: [{index: 0, delta,finish_reason: finish}]})}\n\n`// Answer the first agent step with a tool call, so a second step shows the tool set once MCP has connected.constagentSteps=requests.filter((r)=>r.length>0).length// Hold the first step long enough for the MCP server to finish connecting meanwhile.constsse=(names.length>0&&agentSteps===1
? chunk({role: "assistant",tool_calls: [{index: 0,id: "call_1",type: "function",function: {name: "glob",arguments: JSON.stringify({pattern: "*.json"})}}]})+chunk({},"tool_calls")
: chunk({role: "assistant",content: "hello"})+chunk({},"stop"))+`data: ${JSON.stringify({ id,object: "chat.completion.chunk",created: 0,model: "m",choices: [],usage: {prompt_tokens: 1,completion_tokens: 1,total_tokens: 2}})}\n\n`+"data: [DONE]\n\n"returnnewResponse(sse,{headers: {"content-type": "text/event-stream"}})},})constmcp=Bun.serve({port: 0,asyncfetch(req){if(req.method!=="POST")returnnewResponse(null,{status: 405})constmsg=awaitreq.json()if(msg.id===undefined)returnnewResponse(null,{status: 202})console.log(`[mcp] ${msg.method}`)letresult: any={}if(msg.method==="initialize"){awaitBun.sleep(DELAY)result={protocolVersion: msg.params?.protocolVersion??"2025-03-26",capabilities: {tools: {}},serverInfo: {name: "slow",version: "1.0.0"}}}elseif(msg.method==="tools/list"){result={tools: [{name: "echo_probe",description: "Echo",inputSchema: {type: "object",properties: {text: {type: "string"}}}}]}}returnResponse.json({jsonrpc: "2.0",id: msg.id, result },{headers: {"mcp-session-id": "s1"}})},})constdir=mkdtempSync(join(tmpdir(),"oc-51812-"))writeFileSync(join(dir,"opencode.json"),JSON.stringify({$schema: "https://opencode.ai/config.json",model: "mock/m",provider: {mock: {npm: "@ai-sdk/openai-compatible",name: "Mock",options: {baseURL: `http://127.0.0.1:${llm.port}/v1`,apiKey: "x"},models: {m: {name: "m"}}},},mcp: {servers: {slow: {type: "remote",url: `http://127.0.0.1:${mcp.port}/mcp`,codemode: false}}},},null,2,),)import{mkdirSync,copyFileSync}from"node:fs"mkdirSync(join(dir,".config/opencode"),{recursive: true})copyFileSync(join(dir,"opencode.json"),join(dir,".config/opencode/opencode.json"))Bun.spawnSync(["git","init","-q"],{cwd: dir})constcmd=(process.env.OC_CMD??"opencode").split(" ")constenv={ ...process.env,OPENCODE_PASSWORD: "repro",HOME: dir,XDG_CONFIG_HOME: join(dir,".config"),XDG_DATA_HOME: join(dir,".data"),XDG_STATE_HOME: join(dir,".state"),XDG_CACHE_HOME: join(dir,".cache")}construn=async(args: string[])=>{constproc=Bun.spawn([...cmd, ...args],{cwd: dir,stdout: "pipe",stderr: "pipe", env })constcode=awaitproc.exitedconsole.log(`[opencode ${args.slice(0,2).join(" ")}] exit ${code}`,(awaitnewResponse(proc.stderr).text()).trim().slice(-800))}// Part 1: one-shot `opencode run --standalone`.console.log("=== opencode run --standalone ===")awaitrun(["run","--standalone","say hello"])conststandalone=requests.splice(0)// Part 2: `opencode serve`, then two prompts against it (first sent right after the server is up).console.log("=== opencode serve + two prompts ===")constport=40000+Math.floor(Math.random()*10000)constserver=Bun.spawn([...cmd,"serve","--port",String(port),"--hostname","127.0.0.1"],{cwd: dir,stdout: "ignore",stderr: "ignore", env })for(leti=0;i<100;i++){constok=awaitfetch(`http://127.0.0.1:${port}/api/mcp`,{headers: {"x-opencode-directory": dir,authorization: "Basic "+btoa("opencode:repro")}}).then((r)=>r.text()).catch(()=>undefined)if(ok!==undefined){console.log("[serve] up; GET /api/mcp:",ok.slice(0,300));break}awaitBun.sleep(100)}awaitrun(["run","--server",`http://127.0.0.1:${port}`,"first prompt"])constp1=requests.splice(0)console.log("[serve] GET /api/mcp after first prompt:",(awaitfetch(`http://127.0.0.1:${port}/api/mcp`,{headers: {"x-opencode-directory": dir,authorization: "Basic "+btoa("opencode:repro")}}).then((r)=>r.text())).slice(0,300))// Wait (as the author's workaround does) until no MCP server is pending, then send the second prompt.for(leti=0;i<100;i++){consttext=awaitfetch(`http://127.0.0.1:${port}/api/mcp`,{headers: {"x-opencode-directory": dir,authorization: "Basic "+btoa("opencode:repro")}}).then((r)=>r.text())if(!text.includes('"pending"')){console.log("[serve] GET /api/mcp before second prompt:",text.slice(0,300));break}awaitBun.sleep(100)}awaitrun(["run","--server",`http://127.0.0.1:${port}`,"second prompt"])constp2=requests.splice(0)awaitBun.sleep(DELAY+3000)console.log("[serve] GET /api/mcp before third prompt:",(awaitfetch(`http://127.0.0.1:${port}/api/mcp`,{headers: {"x-opencode-directory": dir,authorization: "Basic "+btoa("opencode:repro")}}).then((r)=>r.text())).slice(0,300))awaitrun(["run","--server",`http://127.0.0.1:${port}`,"third prompt"])constp3=requests.splice(0)server.kill()consthas=(reqs: string[][])=>reqs.filter((r)=>r.length>0).some((r)=>r.some((n)=>n.startsWith("echo_probe")))console.log(`RESULT standalone run: MCP tool ${has(standalone) ? "present" : "MISSING"}`)console.log(`RESULT serve, first prompt: MCP tool ${has(p1) ? "present" : "MISSING"}`)console.log(`RESULT serve, second prompt: MCP tool ${has(p2) ? "present" : "MISSING"}`)console.log(`RESULT serve, third prompt: MCP tool ${has(p3) ? "present" : "MISSING"}`)llm.stop(true)mcp.stop(true)
Activity
github-actions commentedon Sep 28, 2026
This issue might be a duplicate of or related to an existing issue. Please check:
mcp.addcan run before the connected server’s tools are reconciled into the session registry, so MCP tools are also missing from the first model request. That issue concerns runtime MCP additions; this report concerns servers still connecting at startup.r266-tech commentedon Sep 28, 2026
I reproduced the initial-read boundary with the real
McpandMcpToollayers at v2.0.18 (cd9a14a), using the existing local HTTP MCP fixture and aDeferredgate on its POST handshake. No model or external MCP server was involved.The test observes, in order:
McpTool.flushreturns successfully.Mcp.servers()still reportspending, and the Tool registry has noresources_echodefinition.Mcp.tools()includesecho.This passes as a characterization test (1 test, 3 assertions). It exercises the readiness boundary used by
SessionContext.select, not a completeserve/runsession.The source path is: initial connections deliberately fork in
Mcp.reconcile;Mcp.tools()returns the current catalog without waiting; the initial registration fiber intool/mcp.tstherefore finishes with an empty catalog.flushawaits that fiber, andSessionContext.selectthen takes its snapshot. The relevant logic is unchanged on current v239e1ce55.This appears distinct from #51089's post-
mcp.adddebounce window: here the connection itself is still pending. A readiness fix should retain asynchronous Location startup while deciding what the first session snapshot waits for, including failed/disabled/cancelled servers. The draft #47389 also touches this boundary, so I am leaving this scoped reproduction here for the assigned maintainer rather than opening a competing patch.rekram1-node commentedon Sep 29, 2026
Yeah this was intentional, will think about it more
nkoynov commentedon Oct 5, 2026
Our case for changing this: T3 Code starts
opencode serve, adds a per-thread MCP server withPUT /api/experimental/mcp/:server, and sends the first prompt right away. Some providers fix the tool list for the whole turn (cursor-opencode-provider does), so turn 1 never sees that server's tools. Here's a proposal that keeps startup async: on a session's first model request only, wait for servers that are stillpending, up to a short limit (say 3 s, configurable), then go on with whatever has connected. Later requests never wait, and one slow server can't hold up Location startup. We do this today in a plugin on 2.0.22 (session.hook("prompt")holds the first prompt, with a cap, until the servers are registered): the wait was about 100 ms on a new thread and 0 ms on later turns. Themcp.addside of the same race is #51089 / PR #52214.epulla commentedon Oct 14, 2026
Waiting until
/api/mcpshows nopendingservers (the workaround above) isn't enough on 2.0.28. A server can showconnectedand still be missing from the first turn's tools for ~100ms. Probably the same debounce as #53299 / #51089, but overopencode serveinstead of ACP.Repro:
opencode servewith two local MCP servers, one of which starts 2s late. Wait until both showconnected, then ask the model which tools it has:connectedfastonly (slowmissing)fastandslowSame in 2/2 runs, and in 3 real sessions with the Atlassian MCP server. Our workaround: wait 1s after everything shows
connected.nkoynov commentedon Oct 14, 2026
Same here. Our local workaround (a plugin that holds the first prompt) ended up waiting until each connected server's tools show up in the tool registry, not just until its status is
connected, because of exactly that gap. So if the bounded wait I suggested above gets picked up, it should key off the registry rather than the status.geoff-ux commentedon Oct 16, 2026
This still reproduces on 2.0.29 and 2.0.31 with a self-contained repro. The MCP server is
@modelcontextprotocol/server-everything@1.0.0, started assh -c "sleep 10; exec npx -y @modelcontextprotocol/server-everything@1.0.0"(local,codemode: false). The client is plaincurlagainstopencode serve, with fresh XDG dirs andanthropic/claude-haiku-5-5.Prompt: "Call the echo tool with the message hi, then reply with what it returned."
GET /api/mcpat prompt/api/info[]pendingpendingconnectedconnectedeverything_echoreturns "Echo: hi"One more data point for the polling workaround: for the first 0.3–0.45 s after the first request for a directory,
GET /api/mcpreturns[]. The configured server is not listed even aspending, so polling until no server ispendingcan pass before the server is registered at all. To be safe, wait until each configured server is listed andconnected, and its tools are in the registry.The source path is unchanged on
v2.0.31anddev(180fd9d):Mcp.tools()atmcp/index.tsL663–L668 does not awaitstartup,McpTool.flushattool/mcp.tsL144 waits only for the initial registration, andSessionContext.selectatsession/context.tsL127–L134 snapshots right after it. PR #54942 would not change this, because it re-readsMcp.tools(), which still leaves pending servers out.fix(opencode): wait for the MCP tools in an OpenCode plugin instead o…
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report, I can reproduce this.
With a mock remote MCP server that takes ~1.5 s to answer
initialize(codemode: false) and a mock OpenAI-compatible provider that logs the tools in each request, on 2.0.18:opencode run --standalone "...": the model requests only ever contain the built-in tools. The run finishes before the MCP server connects, so a one-shot run never gets MCP tools.opencode serve, then prompts viaopencode run --server:GET /api/mcpshows the server aspending.GET /api/mcpreportedconnected(the mock server got a secondinitializearound then). So polling/api/mcpmay not be a fully reliable workaround.On the latest
dev(86ce19d), the first prompt afteropencode servestill has no MCP tools. I couldn't checkrun --standaloneondevfrom source because the standalone server didn't start there.The script below reproduces both cases. Related, but different problems: #45333 and #38470.
Reproduction script (repro.ts)
Run it:
bun repro.tscktang88 commentedon Oct 30, 2026
also ran into this