Repository navigation
adaptOAuthProvider returns expired tokens without checking expiry, breaking long-running StreamableHTTP connections #1954
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.
Activity
daksh-goyal commentedon Apr 24, 2026
hm, I see the gap with my fix. But the fix proposed by the bot seems rather intrusive considering the definition of the
OAuthTokensinterface lives in @modelcontextprotocol/core. An alternative solution could be to do some monkey-patching in the adapter closure itself. Something like:Let me know what is preferred here. Also, @felixweinberger is there an automated workflow to backport the fix to v1.x or would I have to manually create a separate pull request for that?