Skip to content

MCP: refresh OAuth tokens proactively before expiry #49773

Closed as not planned
Closed as not planned

Description

@luca-derosa

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.

Related (same symptom family)

Happy to pull redacted mcp debug output if useful — token values stay with me.

Activity

  1. kgleason commented on Sep 28, 2026

    @kgleason

    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

    1. Drata_listVendors started returning 401 Unauthorized (code: 10120) on every call.
    2. 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.
    3. Decoded the JWT: 24h lifetime, expired. So the symptom is an expired access token, not a revoked grant.
    4. 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.
    5. 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.
    6. Wrote the fresh token (new accessToken + expiresAt) back into mcp-auth.json, keeping the refresh token and client info unchanged.
    7. Retried Drata_listVendors in 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).
    8. 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.)

    Two distinct failures visible in one repro

    Environment: OpenCode v2.0.18, Linux, Drata MCP remote (Auth0 OIDC), 24h access-token lifetime, offline_access scope. Not posting tokens or credentials here; happy to provide redacted logs on request.

  2. holny commented on Sep 28, 2026

    @holny
    Contributor

    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.

    Fixes #49773

  3. manubhai04 commented on Oct 6, 2026

    @manubhai04

    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.

  4. atwright147 commented on Oct 17, 2026

    @atwright147

    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:

      # no/invalid Authorization header
      401 {"error":"Missing Authorization Header"}
      
      # expired (well-formed) bearer token
      200 {"result":{"content":[{"type":"text","text":"The access token provided has expired."}],"isError":true},"jsonrpc":"2.0","id":1}
      
    • 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).

  5. 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
  6. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    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.

  7. kgleason commented on Oct 20, 2026

    @kgleason

    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.

    Experiment (Drata MCP, Auth0 OIDC, 24h access tokens):

    1. Exchanged the stored refresh token manually against the Auth0 token endpoint → HTTP 200, fresh 24h access token, no rotation.
    2. 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.)
    3. 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.

    Environment: opencode 2.1.0, Linux x64, Drata MCP (https://mcp.drata.com/mcp/, Auth0, 24h access-token lifetime, offline_access scope).

  8. opencodejr commented on Oct 28, 2026

    @opencodejr

    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.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:

    If this also happens on v2, or you think it's critical on v1, please reply here with your opencode --version and we'll take another look.

  9. sk-connectpoint commented on Oct 28, 2026

    @sk-connectpoint

    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

    1. Access tokens expire after ~2h (expiresIn=7200). OpenCode keeps a valid refresh token in the credential table.
    2. On reconnect, the client attaches the expired access token as Authorization: Bearer … and POSTs /api/v4/mcp.
    3. 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.
    4. Because there is no 401, no operation=refresh runs. The MCP stays failed with Error POSTing to endpoint: Retry later (HTTP 429).
    5. 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.

  10. opencodejr commented on Oct 29, 2026

    @opencodejr

    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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions