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.
From the details, this looks like OpenCode v1 (0.x/1.x). The tokens are in ~/.local/share/opencode/mcp-auth.json with accessToken/refreshToken/expiresAt, which is v1's format, and mcp debug is a v1 command. v1 now only gets fixes for critical bugs (data loss, security problems, breakage with no workaround), so this is unlikely to be fixed there.
We'd encourage upgrading to v2 (@opencode/cli, the opencode command). v2 is a separate implementation with its own MCP OAuth handling. It saves the refresh token and expiry, hands them to the MCP SDK so it refreshes on its own, and shares a single refresh request when several fire at once (refresh tokens rotate). Note that v2 doesn't import v1's mcp-auth.json, so you'll need to authenticate the IBKR server once after upgrading (see #54879).
Related v2 issues, in case you see something similar there:
Still relevant on OpenCode v2.2.0 (not v1). Adding a related failure mode against a remote GitLab MCP (https://<instance>/api/v4/mcp, static OAuth app, scope=mcp).
What we see
Access tokens expire after ~2h (expiresIn=7200). OpenCode keeps a valid refresh token in the credential table.
On reconnect, the client attaches the expired access token as Authorization: Bearer … and POSTs /api/v4/mcp.
Refresh only starts after an HTTP 401. When the shared unauthenticated per-IP rate limit is already exhausted, GitLab returns HTTP 429 with plain body Retry later instead of 401.
Because there is no 401, no operation=refresh runs. The MCP stays failed with Error POSTing to endpoint: Retry later (HTTP 429).
Hourly reconnects keep posting the same expired bearer (still counted as unauthenticated) and prolong the lockout. The same 429 also hits POST /oauth/token during opencode mcp auth (token exchange) and OAuth discovery (/.well-known/…).
When the IP bucket clears, the normal path works again: MCP POST → 401 → operation=refresh 200 → connected with tools. Instance admins confirmed authenticated user rate limits are off; the failing traffic matches the unauthenticated IP threshold (expired/missing bearer and OAuth endpoints without a user session).
Why this belongs here
#34582 claimed V2 refreshes expired tokens before connect. On 2.2.0 the credential tokens() path still surfaces an expired token (expires_in clamped to 0) and relies on a reactive 401. That matches the gap this issue tracks (proactive refresh before expiry / without depending on a transport 401). Our case is the same gap with a harder failure mode: 429 instead of 401, so the reactive path never starts.
Ask
Please keep proactive refresh (check expires / refresh under single-flight before the first MCP POST) on the V2 roadmap. Optionally treat connect-time 429 with Retry-After as backoff rather than a hard failed that retries every hour with the same expired bearer.
Happy to provide redacted logs (401→refresh happy path vs. immediate 429 with no refresh) if useful.
We haven't heard back, so I'm closing this as not planned. From the details (tokens in ~/.local/share/opencode/mcp-auth.json and the offer of mcp debug output), this looks like OpenCode v1, which now only gets critical fixes. Re-authenticating or restarting works around the token refresh problem, so it doesn't qualify.
v2 (@opencode/cli) has separate MCP OAuth handling that refreshes tokens through the MCP SDK. After upgrading you'll need to authenticate the IBKR server once, because v1's mcp-auth.json isn't imported (#54879). If the 401s or lost auth state also happen on v2, please open a new issue with your opencode --version and a link back here, and we'll take a look. Thanks for the thorough report!
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).opencodejr commentedon Oct 28, 2026
Thanks for the detailed report!
From the details, this looks like OpenCode v1 (0.x/1.x). The tokens are in
~/.local/share/opencode/mcp-auth.jsonwithaccessToken/refreshToken/expiresAt, which is v1's format, andmcp debugis a v1 command. v1 now only gets fixes for critical bugs (data loss, security problems, breakage with no workaround), so this is unlikely to be fixed there.We'd encourage upgrading to v2 (
@opencode/cli, theopencodecommand). v2 is a separate implementation with its own MCP OAuth handling. It saves the refresh token and expiry, hands them to the MCP SDK so it refreshes on its own, and shares a single refresh request when several fire at once (refresh tokens rotate). Note that v2 doesn't import v1'smcp-auth.json, so you'll need to authenticate the IBKR server once after upgrading (see #54879).Related v2 issues, in case you see something similar there:
needs_authwhen a tool call is rejected with 401If this also happens on v2, or you think it's critical on v1, please reply here with your
opencode --versionand we'll take another look.sk-connectpoint commentedon Oct 28, 2026
Still relevant on OpenCode v2.2.0 (not v1). Adding a related failure mode against a remote GitLab MCP (
https://<instance>/api/v4/mcp, static OAuth app,scope=mcp).What we see
expiresIn=7200). OpenCode keeps a valid refresh token in thecredentialtable.Authorization: Bearer …and POSTs/api/v4/mcp.Retry laterinstead of 401.operation=refreshruns. The MCP staysfailedwithError POSTing to endpoint: Retry later (HTTP 429).POST /oauth/tokenduringopencode mcp auth(token exchange) and OAuth discovery (/.well-known/…).When the IP bucket clears, the normal path works again: MCP POST → 401 →
operation=refresh200 → connected with tools. Instance admins confirmed authenticated user rate limits are off; the failing traffic matches the unauthenticated IP threshold (expired/missing bearer and OAuth endpoints without a user session).Why this belongs here
#34582 claimed V2 refreshes expired tokens before connect. On 2.2.0 the credential
tokens()path still surfaces an expired token (expires_inclamped to0) and relies on a reactive 401. That matches the gap this issue tracks (proactive refresh before expiry / without depending on a transport 401). Our case is the same gap with a harder failure mode: 429 instead of 401, so the reactive path never starts.Ask
Please keep proactive refresh (check
expires/ refresh under single-flight before the first MCP POST) on the V2 roadmap. Optionally treat connect-time 429 withRetry-Afteras backoff rather than a hardfailedthat retries every hour with the same expired bearer.Happy to provide redacted logs (401→refresh happy path vs. immediate 429 with no refresh) if useful.
opencodejr commentedon Oct 29, 2026
We haven't heard back, so I'm closing this as not planned. From the details (tokens in
~/.local/share/opencode/mcp-auth.jsonand the offer ofmcp debugoutput), this looks like OpenCode v1, which now only gets critical fixes. Re-authenticating or restarting works around the token refresh problem, so it doesn't qualify.v2 (
@opencode/cli) has separate MCP OAuth handling that refreshes tokens through the MCP SDK. After upgrading you'll need to authenticate the IBKR server once, because v1'smcp-auth.jsonisn't imported (#54879). If the 401s or lost auth state also happen on v2, please open a new issue with youropencode --versionand a link back here, and we'll take a look. Thanks for the thorough report!