modelcontextprotocol / modelcontextprotocol/typescript-sdk
adaptOAuthProvider returns expired tokens without checking expiry, breaking long-running StreamableHTTP connections
Nobody has claimed this yet.
- 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:
- Connect to an HTTP MCP server using StreamableHTTPClientTransport with OAuth
- Authenticate successfully — connection works
- Wait for the access token to expire
- Make a tool call through the MCP connection
- 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
- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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