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
ACP resume sometimes marks a reattached MCP tool as removed #54794
Description
On 2.0.27, a cold session/resume over ACP with the same mcpServers sometimes leaves the reattached MCP tool unusable on the first turn. The log shows mcp connected for the server with its one tool. 50 to 280 ms later, a core/codemode instruction is saved: "The following tools are no longer available and must not be called: tools.echo.echo". The turn then ends without calling the tool. The next cold resume adds the tool back and it works. About 1 in 6 cold resumes, reproduced in two fresh homes. A new session is fine.
Expected: when the server reconnects with the tool, the first turn after resume sees it instead of a removal notice.
Plugins: none (empty HOME and XDG dirs, no config, no provider credentials)
OpenCode version: 2.0.27 (npm)
Steps to reproduce node opencode-mcp-resume-native.ts <path to opencode> <output dir> 6 (script, Node built-ins only).
It starts a stdio echo MCP server.
It opens a session with opencode acp, then resumes it six times, each in a fresh opencode acp process, using the free opencode/big-pickle.
It fails a round if that turn has no real echo result.
It saves the wire log, the persisted messages and the connection times.
Operating System: Linux x64
Terminal: none (ACP over stdio)
Found while testing OpenCode in OAR (github.com/botiverse/oar, a library that drives coding agents through their own interfaces); investigated with an AI agent's help.
Thanks for the detailed report and script. I reproduced this on 2.0.27 with your ACP flow: a stdio echo MCP server, session/new, then cold session/resume in fresh opencode acp processes with the same mcpServers, using opencode/big-pickle.
In my runs the removal notice was saved on every cold resume (12 of 12), not just about 1 in 6. Just before each resumed user message, a core/codemode message was saved: "The following tools are no longer available and must not be called: tools.echo.echo." It came about 37 ms after the log showed mcp connected server=echo tools=1. The "New tools are available … tools.echo.echo" notice only arrived about 2 s into the turn, after the first model step. Whether a turn fails therefore seems to depend on whether the model calls the tool before the catalog is re-read.
The script below is your script with a short summary at the end. It prints the last catalog notice before each prompt (REMOVED means the notice was saved).
I couldn't check the latest dev build (bcf23b6): the free model rejects local builds, and a local mock model didn't trigger the race even on 2.0.27. A possibly related earlier change is fb72e25 ("wait for a cold location's plugins before the first catalog read"). Related but different reports: #51788 and #40978 (ACP mcpServers scoping).
Reproduction script (repro.ts)
/* oxlint-disable eslint/max-statements, eslint/max-lines-per-function -- Keep the independent native reproduction and its cleanup together. *//** * Direct OpenCode ACP MCP resume probe. No OAR imports, account or login. * node experiments/opencode-mcp-resume-native.ts <binary> <output-dir> [resume-count=6] * Uses free opencode/big-pickle. Each turn runs in a fresh native process. * October 12, 2026: investigates the intermittent catalog failure in oar#371. */importassertfrom"node:assert/strict";import{execFileSync,spawn}from"node:child_process";import{mkdir,mkdtemp,readFile,writeFile}from"node:fs/promises";import{tmpdir}from"node:os";importpathfrom"node:path";import{createInterface}from"node:readline";import{DatabaseSync}from"node:sqlite";const[command,output,countText="6"]=process.argv.slice(2);assert.ok(command!==undefined&&output!==undefined,"usage: <binary> <output-dir> [resume-count]");assert.ok(process.platform!=="win32","this diagnostic uses POSIX process groups for cleanup");constcount=Number(countText);assert.ok(Number.isSafeInteger(count)&&count>0);constbinary=path.resolve(command);constout=path.resolve(output);constroot=awaitmkdtemp(path.join(tmpdir(),"opencode-native-mcp-"));constcwd=path.join(root,"project");consthome=path.join(root,"home");awaitPromise.all([out,cwd,home].map(async(directory)=>{awaitmkdir(directory,{recursive: true});}));constenv: NodeJS.ProcessEnv={};for(constkeyof["PATH","LANG","LC_ALL","HTTP_PROXY","HTTPS_PROXY","ALL_PROXY","NO_PROXY","http_proxy","https_proxy","all_proxy","no_proxy"]){if(process.env[key]!==undefined){env[key]=process.env[key];}}Object.assign(env,{HOME: home,OPENCODE_TEST_HOME: home,XDG_CONFIG_HOME: path.join(root,"config"),XDG_DATA_HOME: path.join(root,"data"),XDG_STATE_HOME: path.join(root,"state"),XDG_CACHE_HOME: path.join(root,"cache"),OPENCODE_DISABLE_AUTOUPDATE: "1",OPENCODE_DISABLE_LSP_DOWNLOAD: "1",OPENCODE_DISABLE_DEFAULT_PLUGINS: "1",OPENCODE_DISABLE_CLAUDE_CODE: "1",});constversion=execFileSync(binary,["--version"],{ env,encoding: "utf8"}).trim();constserver=path.join(root,"echo.mjs");awaitwriteFile(server,`import { createInterface } from "node:readline";createInterface({ input: process.stdin }).on("line", (line) => { const message = JSON.parse(line); if (message.id === undefined) return; const send = (result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\\n"); switch (message.method) { case "initialize": send({ protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "native-echo", version: "1" } }); break; case "tools/list": send({ tools: [{ name: "echo", description: "Echo text with a native marker", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] }); break; case "tools/call": send({ content: [{ type: "text", text: "native-echo:" + message.params.arguments.text }] }); break; case "ping": send({}); break; default: process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unknown method" } }) + "\\n"); }});`);constmcpServers=[{name: "echo",command: process.execPath,args: [server],env: []}];functionisObject(value: unknown): value is Record<string,unknown>{returntypeofvalue==="object"&&value!==null&&!Array.isArray(value);}functionobject(value: unknown): Record<string,unknown>{returnisObject(value) ? value : {};}interfaceWire{at: number;direction: "in"|"out";message: unknown}asyncfunctionturn(round: number,resume: string|null): Promise<{id: string;facts: unknown}>{constwire: Wire[]=[];conststderr: string[]=[];constchild=spawn(binary,["acp"],{ cwd, env,detached: true,stdio: ["pipe","pipe","pipe"]});constpending=newMap<number,{resolve: (value: unknown)=>void;reject: (error: Error)=>void}>();letnextId=0;letid=resume;constclosed=Promise.withResolvers<void>();child.once("close",()=>{for(constwaiterofpending.values()){waiter.reject(newError("native process exited"));}pending.clear();closed.resolve();});child.stderr.on("data",(chunk: Buffer)=>{stderr.push(chunk.toString());});child.on("error",(error)=>{for(constwaiterofpending.values()){waiter.reject(error);}});constsend=(message: unknown): void=>{wire.push({at: Date.now(),direction: "out", message });child.stdin.write(`${JSON.stringify(message)}\n`);};constlines=createInterface({input: child.stdout});lines.on("line",(line)=>{constmessage: unknown=JSON.parse(line);wire.push({at: Date.now(),direction: "in", message });constdata=object(message);if(typeofdata.method==="string"&&data.id!==undefined){const{ options }=object(data.params);constoption=Array.isArray(options) ? options.map((item: unknown)=>object(item)).find((item)=>item.kind==="allow_once") : undefined;send(data.method==="session/request_permission"&&option!==undefined
? {jsonrpc: "2.0",id: data.id,result: {outcome: {outcome: "selected",optionId: option.optionId}}}
: {jsonrpc: "2.0",id: data.id,error: {code: -32_601,message: "Client method unsupported"}});}elseif(typeofdata.id==="number"){constwaiter=pending.get(data.id);pending.delete(data.id);if(data.error===undefined){waiter?.resolve(data.result);}else{waiter?.reject(newError(JSON.stringify(data.error)));}}});constrequest=async(method: string,params: unknown,timeout=30_000): Promise<unknown>=>{constrequestId=nextId++;constresponse=Promise.withResolvers<unknown>();pending.set(requestId,response);consttimer=setTimeout(()=>{pending.delete(requestId);response.reject(newError(`timeout: ${method}`));},timeout);try{send({jsonrpc: "2.0",id: requestId, method, params });returnawaitresponse.promise;}finally{clearTimeout(timer);pending.delete(requestId);}};try{awaitrequest("initialize",{protocolVersion: 1,clientCapabilities: {},clientInfo: {name: "native-mcp-repro",version: "1"}});constopened=object(awaitrequest(resume===null ? "session/new" : "session/resume",{
cwd, mcpServers, ...(resume===null ? {} : {sessionId: resume}),}));if(resume===null){assert.equal(typeofopened.sessionId,"string");id=String(opened.sessionId);}assert.ok(id!==null);awaitrequest("session/set_config_option",{sessionId: id,configId: "model",value: "opencode/big-pickle"});constmarker=`NATIVE_MCP_${String(round)}`;constpromptStart=Date.now();constresult=awaitrequest("session/prompt",{sessionId: id,prompt: [{type: "text",text: `Call the echo MCP tool with text ${marker}. Reply with the exact tool result. Do not use shell or fake a tool result.`}],},180_000);consttoolResults=wire.filter(({ direction, message })=>{constupdate=object(object(object(message).params).update);returndirection==="in"&&update.sessionUpdate==="tool_call_update"&&update.status==="completed";});constechoCalled=JSON.stringify(toolResults).includes(`native-echo:${marker}`);if(!echoCalled){process.exitCode=1;}return{ id,facts: { round,method: resume===null ? "session/new" : "session/resume", promptStart, result, echoCalled }};}finally{if(child.exitCode===null&&child.signalCode===null&&child.pid!==undefined){process.kill(-child.pid,"SIGTERM");constkill=setTimeout(()=>{if(child.exitCode===null&&child.signalCode===null&&child.pid!==undefined){process.kill(-child.pid,"SIGKILL");}},3000);awaitclosed.promise;clearTimeout(kill);}lines.close();awaitwriteFile(path.join(out,`round-${String(round)}.wire.jsonl`),`${wire.map((entry)=>JSON.stringify(entry)).join("\n")}\n`);awaitwriteFile(path.join(out,`round-${String(round)}.stderr.txt`),stderr.join(""));}}constfacts: unknown[]=[];letsessionId: string|null=null;try{for(letround=0;round<=count;round++){// oxlint-disable-next-line eslint/no-await-in-loop -- Each process resumes the preceding process's session.constresult=awaitturn(round,sessionId);sessionId=result.id;facts.push(result.facts);console.log(JSON.stringify(result.facts));}}finally{constdb=newDatabaseSync(path.join(root,"data","opencode","opencode.db"),{readOnly: true});try{constmessages=db.prepare("SELECT seq, type, time_created, data FROM session_message WHERE session_id = ? ORDER BY seq").all(sessionId??"");awaitwriteFile(path.join(out,"native-messages.json"),JSON.stringify(messages,null,2));constcatalogUpdates=messages.filter((message)=>message.type==="system"&&typeofmessage.data==="string"&&message.data.includes("Code Mode tool catalog"));constnativeLog=awaitreadFile(path.join(root,"data","opencode","log","opencode.log"),"utf8");awaitwriteFile(path.join(out,"native.log"),nativeLog);constconnections=nativeLog.split("\n").filter((line)=>line.includes("mcp connected"));awaitwriteFile(path.join(out,"report.json"),JSON.stringify({ version,credentials: "none", root, sessionId, facts, catalogUpdates, connections },null,2));// Which Code Mode catalog notice the model saw last before each prompt (REMOVED = the bug).letlast="";for(constmofmessagesasArray<{type: string;data: string}>){if(m.type==="system"&&m.data.includes("Code Mode tool catalog"))last=m.data.includes("no longer available") ? "REMOVED" : "ADDED";if(m.type==="user"){console.log(`prompt ${/NATIVE_MCP_\d+/.exec(m.data)?.[0]}: latest catalog notice before it = ${last||"none"}`);}}}finally{db.close();}console.log(`Native evidence retained in ${out}; isolated state in ${root}`);}
I am working on the first-turn MCP catalog race. The tool snapshot currently waits for the initial registration but can run before the debounced update for an already-connected server. I am adding a synchronous catalog refresh at that boundary and a regression test.
This is fixed in v2.2.4 and later by #60490. Before each model call, the tool list now picks up MCP servers that have just connected. Before, it waited out the 100 ms debounced reload, and that gap is what produced the false "no longer available" notice on resume.
We checked it end to end over ACP on current dev:
session/new with an HTTP MCP server, then a prompt.
session/resume with the same server in a fresh opencode acp process, then another prompt.
The resumed model request still lists the tool and has no catalog-change notice. With the old behavior the same run reproduces the bug every time.
Thanks for the detailed repro script! If you still see this on 2.2.4 or later, please reopen with your opencode --version.
Activity
opencode-agent commentedon Oct 12, 2026
Thanks for the detailed report and script. I reproduced this on 2.0.27 with your ACP flow: a stdio echo MCP server,
session/new, then coldsession/resumein freshopencode acpprocesses with the samemcpServers, usingopencode/big-pickle.In my runs the removal notice was saved on every cold resume (12 of 12), not just about 1 in 6. Just before each resumed user message, a
core/codemodemessage was saved: "The following tools are no longer available and must not be called: tools.echo.echo." It came about 37 ms after the log showedmcp connected server=echo tools=1. The "New tools are available … tools.echo.echo" notice only arrived about 2 s into the turn, after the first model step. Whether a turn fails therefore seems to depend on whether the model calls the tool before the catalog is re-read.The script below is your script with a short summary at the end. It prints the last catalog notice before each prompt (
REMOVEDmeans the notice was saved).I couldn't check the latest
devbuild (bcf23b6): the free model rejects local builds, and a local mock model didn't trigger the race even on 2.0.27. A possibly related earlier change is fb72e25 ("wait for a cold location's plugins before the first catalog read"). Related but different reports: #51788 and #40978 (ACPmcpServersscoping).Reproduction script (repro.ts)
Run it:
bun repro.tsDante-dan commentedon Oct 12, 2026
I am working on the first-turn MCP catalog race. The tool snapshot currently waits for the initial registration but can run before the debounced update for an already-connected server. I am adding a synchronous catalog refresh at that boundary and a regression test.
chore: release v0.62.0 (#385)
An OpenCode session refuses work only when CawCo's own MCP server doe…
OpenCode: cut a workspace session's output into its own folder, and r…
An OpenCode session's first prompt in a fresh location waits until Co…
neriousy commentedon Nov 5, 2026
This is fixed in v2.2.4 and later by #60490. Before each model call, the tool list now picks up MCP servers that have just connected. Before, it waited out the 100 ms debounced reload, and that gap is what produced the false "no longer available" notice on resume.
We checked it end to end over ACP on current
dev:session/newwith an HTTP MCP server, then a prompt.session/resumewith the same server in a freshopencode acpprocess, then another prompt.The resumed model request still lists the tool and has no catalog-change notice. With the old behavior the same run reproduces the bug every time.
Thanks for the detailed repro script! If you still see this on 2.2.4 or later, please reopen with your
opencode --version.