Skip to content

MCP: refresh OAuth tokens proactively before expiry #49773

Open

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

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions