modelcontextprotocol / modelcontextprotocol/typescript-sdk

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

Open Beginner friendly
#1,954 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

auth bug P2 ready for work
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/client/src/client/auth.ts at adaptOAuthProvider(), then read hasValidTokens() and the existing onUnauthorized flow to confirm the expiry buffer. Reproduce the expired-token case with a StreamableHTTPClientTransport connection and verify that near-expiry tokens no longer produce an Authorization header, allowing the server's 401 refresh path to run.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
authentication
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
74/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.