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
MCP: stored OAuth token is never refreshed when the server reports expiry in a 200 tool result #62199
On OpenCode v2.2.5, a remote MCP server that uses OAuth is never refreshed when its access token expires if the server reports the expiry inside a successful HTTP 200 JSON-RPC tool result instead of an HTTP 401.
LaunchDarkly's hosted MCP is one such server. After the ~2h access token expires, a tool call returns isError: true with {"code":"token_expired","status":401,"action":"reauthenticate"} inside a 200 response. OpenCode's MCP client refreshes only after a transport 401, so the stored refresh token is never used. The server still connects and lists tools, so opencode mcp list shows it as connected. The only workaround is opencode mcp auth <server> or reconnecting through /mcp, and the problem returns at the next expiry.
Related: #49773 (closed not planned; the 2026-10-06 LaunchDarkly comment reports this behavior) and #34582 (V2 refresh on a transport 401).
Plugins
None.
OpenCode version
v2.2.5 (Homebrew opencode-v2)
Steps to reproduce
Configure a remote MCP OAuth server whose tool calls return a 200 tool result carrying an expired-token error (LaunchDarkly https://mcp.launchdarkly.com/mcp is an example).
Complete opencode mcp auth <server>; the credential stores a refresh token and a ~7200s access token.
Let the access token expire while the session stays open.
Call a tool. It fails with the server's reauthenticate error; there is no operation=refresh for that server in the log. opencode mcp list still reports the server as connected.
Run opencode mcp auth <server>; calls work again until the next expiry.
Thanks for the detailed report. I reproduced this on v2.2.5 with a local mock MCP/OAuth server built to act like the servers you describe.
The mock issues 15-second access tokens plus a refresh token. Once a token expires, it still accepts initialize/tools/list, but tools/call returns an HTTP 200 result with isError: true and {"code":"token_expired","status":401,"action":"reauthenticate"}.
Steps:
Run opencode mcp auth mock. The token exchange succeeds and opencode mcp list shows the server as connected.
Wait past the token's expiry.
Start OpenCode again against the same database.
What I saw: OpenCode reconnects using the stored, already expired access token, the server still counts as connected, and no refresh_token request ever reaches the token endpoint. The only token requests were the initial authorization-code exchanges. So the stored expiry and refresh token go unused unless the server returns a real 401. I ran out of time before capturing the failing tool call itself in the TUI.
I haven't run the latest dev (e3b0ca6). From reading the code, refresh is still triggered only by a 401 there. The script below is the TUI part of the reproduction and needs the mock server running.
Related: #49773 (closed, but its later comments describe the same LaunchDarkly/Homey behaviour on v2), #60269 (same missing refresh when the server answers 429 instead of 401), and #60612 (concurrent refreshes of rotating tokens).
Reproduction script (drive.ts)
// Repro for #62199. Prereqs: `bun mock.ts` running (mock MCP+OAuth, 15s token TTL, expiry reported as HTTP 200 isError),// and `opencode mcp auth mock` done against OPENCODE_DB=/workspace/repro/db.sqlite. Run with OPENCODE_DRIVE_DB=/workspace/repro/db.sqlite.import{Effect,Stream}from"effect"import{Llm,OpenCodeDriver}from"opencode-drive"exportdefaultOpenCodeDriver.use({
...(process.env.OC_DEV ? {opencode: {dev: process.env.OC_DEV}} : {}),tui: {recording: true},config: {mcp: {mock: {type: "remote",url: "http://127.0.0.1:8787/mcp",codemode: false}},permission: "allow"},},({ ui, llm })=>Effect.gen(function*(){// Each user turn: first request -> call the MCP tool, second request -> a text marker.letn=0yield*llm.serve((request)=>{constraw=JSON.stringify(request)if(raw.includes("You name conversations"))returnStream.make(Llm.text("MCP refresh repro"))constindex=n++consttool=raw.match(/"(mock[_.]ping)"/)?.[1]??"mock_ping"console.log("REQUEST",index,"tool name in request:",tool)returnindex%2===0
? Stream.make(Llm.toolCall({index: 0,id: `call_${index}`,name: tool,input: {}}),Llm.finish("tool-calls"))
: Stream.make(Llm.text(`TURN ${(index+1)/2} DONE`))})yield*ui.submit("call mock ping")yield*ui.waitFor("TURN 1 DONE",{timeout: 60000})yield*ui.screenshot("first-call")// wait past the mock's 15s access-token lifetimeyield*Effect.sleep("25 seconds")yield*ui.submit("call mock ping again")yield*ui.waitFor("TURN 2 DONE",{timeout: 60000})yield*ui.screenshot("second-call-token-expired")yield*Effect.sleep("2 seconds")}),)
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.
Activity
opencodejr commentedon Nov 5, 2026
Thanks for the detailed report. I reproduced this on v2.2.5 with a local mock MCP/OAuth server built to act like the servers you describe.
The mock issues 15-second access tokens plus a refresh token. Once a token expires, it still accepts
initialize/tools/list, buttools/callreturns an HTTP 200 result withisError: trueand{"code":"token_expired","status":401,"action":"reauthenticate"}.Steps:
opencode mcp auth mock. The token exchange succeeds andopencode mcp listshows the server as connected.What I saw: OpenCode reconnects using the stored, already expired access token, the server still counts as connected, and no
refresh_tokenrequest ever reaches the token endpoint. The only token requests were the initial authorization-code exchanges. So the stored expiry and refresh token go unused unless the server returns a real 401. I ran out of time before capturing the failing tool call itself in the TUI.I haven't run the latest
dev(e3b0ca6). From reading the code, refresh is still triggered only by a 401 there. The script below is the TUI part of the reproduction and needs the mock server running.Related: #49773 (closed, but its later comments describe the same LaunchDarkly/Homey behaviour on v2), #60269 (same missing refresh when the server answers 429 instead of 401), and #60612 (concurrent refreshes of rotating tokens).
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.