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: refresh OAuth tokens proactively before expiry #49773
Remote MCP OAuth tokens never refreshed; restart loses auth state
What happens
A remote MCP server (IBKR mcp-public, OAuth via browser flow) works right after activation, then starts failing minutes later with 401 after successful authentication on every endpoint — snapshots, search, everything.
Restarting opencode flips the server back to needs_auth on its own.
Re-authenticating does not reliably revive the running session.
Evidence from my machine (Windows, global opencode.jsonc, type: remote, no custom headers)
~/.local/share/opencode/mcp-auth.json holds the entry with dynamically registered clientInfo, serverUrl, and a tokens object containing accessToken, refreshToken, expiresAt, scope. So both tokens persist — the refresh just never fires.
Stored access-token lifetimes are on the order of tens of minutes, so any session longer than that hits this wall.
Same IBKR sessions stay alive fine when driven from other MCP clients, so this is the client's refresh handling, not the authorization server.
Expected
Silent refresh_token grant before expiry; running session picks up the rotated token without re-auth or restart.
Adding a clean repro on OpenCode v2.0.18 that matches this issue and tightens the evidence: even a valid, freshly-refreshed access token on disk is not used by the running MCP connection — including after a full restart.
Server / auth setup
Remote MCP: https://mcp.drata.com/mcp/
OAuth via Auth0 (https://drata-prod.us.auth0.com/), OIDC
Scope includes offline_access; access-token lifetime is 24h
The Drata app authorization in the Drata UI is set to "Never expires" — that refers to the consent/grant, not the access token. The 24h access token still lapses and must be refreshed via the stored refresh token.
Timeline
Drata_listVendors started returning 401 Unauthorized (code: 10120) on every call.
Inspected ~/.local/share/opencode/mcp-auth.json: the cached accessToken had exp ~12 days in the past. The stored refreshToken (with offline_access) and clientInfo were intact.
Decoded the JWT: 24h lifetime, expired. So the symptom is an expired access token, not a revoked grant.
Ran a manual grant_type=refresh_token against Auth0's token endpoint using the stored refresh token + client credentials → HTTP 200, returned a fresh 24h access token. No refresh-token rotation; the original refresh token stayed valid.
Probed https://mcp.drata.com/mcp/ (MCP initialize) directly with the new bearer → HTTP 200. Same probe with the old expired bearer → 401. So the new token is valid and the old one is the sole problem.
Wrote the fresh token (new accessToken + expiresAt) back into mcp-auth.json, keeping the refresh token and client info unchanged.
Disabled and re-enabled the Drata MCP, then fully restarted OpenCode → still 401. The on-disk mcp-auth.json still contained the valid fresh token (exp 2026-09-29); OpenCode neither overwrote it nor consumed it.
opencode mcp list shows the server as ✓ connected while tool calls return 401
After the restart in step 8, opencode mcp list reports:
✓ drata connected
…yet Drata_listVendors still returns 401 on every call. So mcp list's "connected" status only reflects the MCP-level handshake (which succeeds with the valid on-disk token for initialize), not whether the underlying API actually accepts the bearer for tool calls. This makes the failure invisible to any status check a user would reach for.
Diagnostic commands actually available on v2.0.18 (for anyone reproducing)
opencode mcp list — server status (note: reports connected even when tool calls 401, per above)
opencode mcp auth <server> — re-run the OAuth flow
opencode mcp logout <server> — drop stored credentials
opencode debug config / opencode debug paths — config sources and global paths (data/config/cache/state)
Logs: opencode --log-level all --print-logs (server logs need --standalone)
(There is no opencode mcp debug subcommand on v2.0.18 — that command appeared in older issue reports.)
I'll take this. The evidence lines up with the OAuth provider storing tokens at auth time but never wiring a refresh path: mcp-auth.json keeps both accessToken and refreshToken, the access token lives for tens of minutes, and nothing schedules or triggers a refresh when expiresAt passes — so every remote MCP session longer than one token lifetime hits the wall, and a restart re-reads the still-valid refresh token but surfaces needs_auth instead of using it.
Plan: on the remote MCP auth path, check expiresAt before (and on) 401, use the stored refresh token to mint a new access token, persist it back to mcp-auth.json, and retry the failed call once. Cover with a test that simulates expiry and asserts the refresh round-trip plus recovery without restart.
Another repro on v2.0.22 (macOS) with LaunchDarkly's remote MCP (https://mcp.launchdarkly.com/mcp/launchdarkly), plus one detail that matters for the fix.
What happens
OAuth completes with hasRefreshToken=true expiresIn=7200 (credential row in opencode.db has refresh + expires).
~2h later every tool call fails, and opencode mcp logout launchdarkly, then manual re-auth via /mcps is the only fix. Happened 3× in one day.
Log shows no refresh attempt between expiry (06:10Z) and my manual re-auth (09:07Z).
Detail: LaunchDarkly doesn't reject the request at the HTTP level. The JSON-RPC call succeeds and the tool result carries the error:
{"error":"LaunchDarkly access token has expired","code":"token_expired","status":401,"action":"reauthenticate"}
So OpenCode never sees an HTTP 401 and no refresh path triggers. A fix that only refreshes on a transport 401 (or only at connect time, as in the #34582 fix) won't cover servers like this. Refreshing proactively before expiresAt would.
Because the MCP auth provider only attempts a refresh via onUnauthorized (401), operation=refresh never appears in the log at all — only operation=exchange at auth time. So unlike the IBKR case (which does return 401), here the refresh is never even tried.
Server status stays connected in /mcps and opencode mcp list while every tool call returns The access token provided has expired.
The stored refresh token is valid: POST https://mcp.athom.com/oauth2/token with grant_type=refresh_token returns 200 with a fresh token set.
So there are (at least) two failure modes: refresh firing but not helping (401 servers), and refresh never firing because the auth failure is hidden inside a 200 response. The second needs a proactive expiresAt check (or treating an isError auth response as an auth failure), independent of the 401 path.
Workaround for anyone hitting this: re-auth (/mcps → server → sign in); it works until the token expires again (~61 min here, expires_in=3660).
changed the title [-]Remote MCP OAuth tokens never refreshed; restart loses auth state (IBKR connector)[/-][+]MCP: refresh OAuth tokens proactively before expiry[/+]on Oct 18, 2026
Retitling to the remaining gap. On V2, MCP OAuth credentials persist across restarts and are refreshed when the server returns 401. Some servers (LaunchDarkly and Homey in this thread) instead return HTTP 200 with a tool-level "token expired" error, so a 401-triggered refresh never runs. We should refresh proactively when the stored token is close to expires.
Follow-up to my Sep 28 Drata repro: the V2 credential store is the credential table in opencode.db, not mcp-auth.json — this matters for where the fix persists
Debugged this end-to-end on v2.1.0 today after the 24h Drata token lapsed again. TL;DR: refreshing mcp-auth.json does nothing on V2 — the server only loads MCP OAuth creds from the credential table in ~/.local/share/opencode/opencode.db. Refreshing that row is what actually fixes the 401s.
Exchanged the stored refresh token manually against the Auth0 token endpoint → HTTP 200, fresh 24h access token, no rotation.
Wrote the fresh token into mcp-auth.json, restarted the opencode service → tool calls still 401. (Also re-confirmed: initialize/tools/list succeed with an expired bearer — Drata only enforces auth at tool-call level — so opencode mcp list keeps showing ✓ connected throughout, hiding the failure from every status check.)
Wrote the same token into the Drata row in the credential table (value.access / value.expires JSON blob), restarted the service → tool calls succeed.
Two observations that may help the fix:
A proactive refresh path already exists and works — for some servers. The Google sheets credential's DB row is refreshed ~hourly, with time_updated advancing each time (server log shows mcp oauth request ... operation=refresh status=200 on a regular schedule). The Drata row was written once at initial auth and never touched again — no operation=refresh line for it ever appears in the log. So the machinery to proactively refresh and persist already exists; it just never runs for servers whose auth failure surfaces as a JSON-RPC error inside a 200 instead of a transport-level 401 (same failure mode as LaunchDarkly and Homey above).
mcp-auth.json is a legacy artifact and has diverged from the DB. On my install it even holds a different dynamic client registration than the DB row (different client_id). Per the plan in the earlier comment, persisting the refreshed token back to mcp-auth.json won't be picked up by the running server — on 2.1.0 the persistence target needs to be the credential table (or both).
Workaround in the meantime (verified end-to-end today): a systemd user timer runs hourly; when the Drata credential has <6h of life left, it performs the refresh_token grant, updates the credential row, and restarts opencode service. The token now refreshes daily ~18h into each 24h lifetime and MCP calls have stayed green. Happy to share the script if useful.
Activity
kgleason commentedon Sep 28, 2026
Adding a clean repro on OpenCode v2.0.18 that matches this issue and tightens the evidence: even a valid, freshly-refreshed access token on disk is not used by the running MCP connection — including after a full restart.
Server / auth setup
https://mcp.drata.com/mcp/https://drata-prod.us.auth0.com/), OIDCoffline_access; access-token lifetime is 24hTimeline
Drata_listVendorsstarted returning401 Unauthorized(code: 10120) on every call.~/.local/share/opencode/mcp-auth.json: the cachedaccessTokenhadexp~12 days in the past. The storedrefreshToken(withoffline_access) andclientInfowere intact.grant_type=refresh_tokenagainst Auth0's token endpoint using the stored refresh token + client credentials → HTTP 200, returned a fresh 24h access token. No refresh-token rotation; the original refresh token stayed valid.https://mcp.drata.com/mcp/(MCPinitialize) directly with the new bearer → HTTP 200. Same probe with the old expired bearer → 401. So the new token is valid and the old one is the sole problem.accessToken+expiresAt) back intomcp-auth.json, keeping the refresh token and client info unchanged.Drata_listVendorsin the same session → still 401 (running connection holds the stale token in memory — matches MCP OAuth: opencode mcp auth doesn't refresh token in active sessions #21702).mcp-auth.jsonstill contained the valid fresh token (exp2026-09-29); OpenCode neither overwrote it nor consumed it.opencode mcp listshows the server as✓ connectedwhile tool calls return 401After the restart in step 8,
opencode mcp listreports:…yet
Drata_listVendorsstill returns 401 on every call. Somcp list's "connected" status only reflects the MCP-level handshake (which succeeds with the valid on-disk token forinitialize), not whether the underlying API actually accepts the bearer for tool calls. This makes the failure invisible to any status check a user would reach for.Diagnostic commands actually available on v2.0.18 (for anyone reproducing)
opencode mcp list— server status (note: reportsconnectedeven when tool calls 401, per above)opencode mcp auth <server>— re-run the OAuth flowopencode mcp logout <server>— drop stored credentialsopencode debug config/opencode debug paths— config sources and global paths (data/config/cache/state)opencode --log-level all --print-logs(server logs need--standalone)(There is no
opencode mcp debugsubcommand on v2.0.18 — that command appeared in older issue reports.)Two distinct failures visible in one repro
offline_accessscope + client secret being present inmcp-auth.json(the Remote MCP OAuth: access token is not refreshed despite refresh token being present #34582 "fixed on V2" path — regressed or not covering this server).Environment: OpenCode v2.0.18, Linux, Drata MCP remote (Auth0 OIDC), 24h access-token lifetime,
offline_accessscope. Not posting tokens or credentials here; happy to provide redacted logs on request.holny commentedon Sep 28, 2026
I'll take this. The evidence lines up with the OAuth provider storing tokens at auth time but never wiring a refresh path:
mcp-auth.jsonkeeps bothaccessTokenandrefreshToken, the access token lives for tens of minutes, and nothing schedules or triggers a refresh whenexpiresAtpasses — so every remote MCP session longer than one token lifetime hits the wall, and a restart re-reads the still-valid refresh token but surfacesneeds_authinstead of using it.Plan: on the remote MCP auth path, check
expiresAtbefore (and on) 401, use the stored refresh token to mint a new access token, persist it back tomcp-auth.json, and retry the failed call once. Cover with a test that simulates expiry and asserts the refresh round-trip plus recovery without restart.Fixes #49773
manubhai04 commentedon Oct 6, 2026
Another repro on v2.0.22 (macOS) with LaunchDarkly's remote MCP (
https://mcp.launchdarkly.com/mcp/launchdarkly), plus one detail that matters for the fix.What happens
hasRefreshToken=true expiresIn=7200(credential row inopencode.dbhasrefresh+expires).opencode mcp logout launchdarkly, then manual re-auth via/mcpsis the only fix. Happened 3× in one day.Detail: LaunchDarkly doesn't reject the request at the HTTP level. The JSON-RPC call succeeds and the tool result carries the error:
So OpenCode never sees an HTTP 401 and no refresh path triggers. A fix that only refreshes on a transport 401 (or only at connect time, as in the #34582 fix) won't cover servers like this. Refreshing proactively before
expiresAtwould.atwright147 commentedon Oct 17, 2026
Adding a distinct case where the refresh never fires because there is never a 401:
Server: Homey MCP (
https://mcp.athom.com), opencode 2.0.30,{"type":"remote","url":"https://mcp.athom.com"}.For an expired access token the server returns HTTP 200 with a JSON-RPC result carrying
isError: true, not 401:Because the MCP auth provider only attempts a refresh via
onUnauthorized(401),operation=refreshnever appears in the log at all — onlyoperation=exchangeat auth time. So unlike the IBKR case (which does return 401), here the refresh is never even tried.Server status stays
connectedin/mcpsandopencode mcp listwhile every tool call returnsThe access token provided has expired.The stored refresh token is valid:
POST https://mcp.athom.com/oauth2/tokenwithgrant_type=refresh_tokenreturns200with a fresh token set.So there are (at least) two failure modes: refresh firing but not helping (401 servers), and refresh never firing because the auth failure is hidden inside a 200 response. The second needs a proactive
expiresAtcheck (or treating anisErrorauth response as an auth failure), independent of the 401 path.Workaround for anyone hitting this: re-auth (
/mcps→ server → sign in); it works until the token expires again (~61 min here,expires_in=3660).[-]Remote MCP OAuth tokens never refreshed; restart loses auth state (IBKR connector)[/-][+]MCP: refresh OAuth tokens proactively before expiry[/+]opencode-agent commentedon Oct 18, 2026
Retitling to the remaining gap. On V2, MCP OAuth credentials persist across restarts and are refreshed when the server returns 401. Some servers (LaunchDarkly and Homey in this thread) instead return HTTP 200 with a tool-level "token expired" error, so a 401-triggered refresh never runs. We should refresh proactively when the stored token is close to
expires.kgleason commentedon Oct 20, 2026
Follow-up to my Sep 28 Drata repro: the V2 credential store is the
credentialtable inopencode.db, notmcp-auth.json— this matters for where the fix persistsDebugged this end-to-end on v2.1.0 today after the 24h Drata token lapsed again. TL;DR: refreshing
mcp-auth.jsondoes nothing on V2 — the server only loads MCP OAuth creds from thecredentialtable in~/.local/share/opencode/opencode.db. Refreshing that row is what actually fixes the 401s.Experiment (Drata MCP, Auth0 OIDC, 24h access tokens):
mcp-auth.json, restarted the opencode service → tool calls still 401. (Also re-confirmed:initialize/tools/listsucceed with an expired bearer — Drata only enforces auth at tool-call level — soopencode mcp listkeeps showing✓ connectedthroughout, hiding the failure from every status check.)credentialtable (value.access/value.expiresJSON blob), restarted the service → tool calls succeed.Two observations that may help the fix:
sheetscredential's DB row is refreshed ~hourly, withtime_updatedadvancing each time (server log showsmcp oauth request ... operation=refresh status=200on a regular schedule). The Drata row was written once at initial auth and never touched again — nooperation=refreshline for it ever appears in the log. So the machinery to proactively refresh and persist already exists; it just never runs for servers whose auth failure surfaces as a JSON-RPC error inside a 200 instead of a transport-level 401 (same failure mode as LaunchDarkly and Homey above).mcp-auth.jsonis a legacy artifact and has diverged from the DB. On my install it even holds a different dynamic client registration than the DB row (differentclient_id). Per the plan in the earlier comment, persisting the refreshed token back tomcp-auth.jsonwon't be picked up by the running server — on 2.1.0 the persistence target needs to be thecredentialtable (or both).Workaround in the meantime (verified end-to-end today): a systemd user timer runs hourly; when the Drata credential has <6h of life left, it performs the
refresh_tokengrant, updates thecredentialrow, and restartsopencode service. The token now refreshes daily ~18h into each 24h lifetime and MCP calls have stayed green. Happy to share the script if useful.Environment: opencode 2.1.0, Linux x64, Drata MCP (
https://mcp.drata.com/mcp/, Auth0, 24h access-token lifetime,offline_accessscope).