Skip to content

adaptOAuthProvider returns expired tokens without checking expiry, breaking long-running StreamableHTTP connections #1954

@daksh-goyal

Description

@daksh-goyal

Describe the bug
adaptOAuthProvider() in auth.ts does not check token expiry before returning the access token. The token: adapter calls provider.tokens() and returns access_token regardless of whether it has expired. OAuthClientProvider.tokens() calculates expires_in: Math.max(0, expiresAt - now), so expired tokens come back with expires_in: 0 and are sent to the server as-is.

The refresh logic in auth() (which checks token validity and attempts refresh) only runs via onUnauthorized when the server returns HTTP 401. However, many MCP servers — particularly those acting as proxies to upstream APIs (see #1294) — wrap upstream auth errors in HTTP 200 JSON-RPC responses rather than returning 401. In these cases, the expired token causes a server-side failure that is never surfaced as a 401, so the client's retry/refresh path never fires.

To Reproduce
Steps to reproduce the behavior:

  1. Connect to an HTTP MCP server using StreamableHTTPClientTransport with OAuth
  2. Authenticate successfully — connection works
  3. Wait for the access token to expire
  4. Make a tool call through the MCP connection
  5. Request fails — expired token is sent, server uses it against an upstream API, upstream rejects it, error is wrapped in a 200 JSON-RPC response
  6. Only recovery is manual re-authentication or process restart

Expected behavior
adaptOAuthProvider().token() should check expires_in before returning the access token. If the token is expired or near-expiry (≤60 seconds remaining, matching the buffer already used in hasValidTokens()), it should return undefined so the transport sends the request without an Authorization header, triggering a 401 from the server, which invokes onUnauthorized → auth() → refresh flow.

Suggested Fix

packages/client/src/client/auth.ts:

 // Current:
 export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
     return {
         token: async () => {
             const tokens = await provider.tokens();
             return tokens?.access_token;
         },
         onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx)
     };
 }
 
 // Fixed — check expiry before returning:
 export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider {
     return {
         token: async () => {
             const tokens = await provider.tokens();
             if (!tokens?.access_token) return undefined;
             if (tokens.expires_in !== undefined && tokens.expires_in <= 60) {
                 return undefined;
             }
             return tokens.access_token;
         },
         onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx)
     };
 }

Note: the 60-second buffer matches the existing hasValidTokens() logic which uses the same hardcoded value.

Logs

 # Successful OAuth connection:
 21:02:21.240Z [ERROR] MCP client for [Redacted] connected, took 97ms
 21:02:21.241Z [ERROR] Started MCP client for remote server [Redacted] with OAuth
 
 # 1h42m later, expired token sent:
 22:44:53.036Z [ERROR] MCP client for [Redacted] errored [Redacted]: The resource parameter provided in the request doesn't match with the requested scopes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featureauthIssues and PRs related to Authentication / OAuthbugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions