Skip to content

fix: use auth provider's current token instead of a connect-time snapshot#1695

Open
justinedelson wants to merge 2 commits into
modelcontextprotocol:mainfrom
justinedelson:fix/oauth-token-refresh-snapshot
Open

fix: use auth provider's current token instead of a connect-time snapshot#1695
justinedelson wants to merge 2 commits into
modelcontextprotocol:mainfrom
justinedelson:fix/oauth-token-refresh-snapshot

Conversation

@justinedelson

@justinedelson justinedelson commented Jul 16, 2026

Copy link
Copy Markdown

Summary

After a successful OAuth token refresh, direct (non-proxy) connections keep sending the old access token, so the session enters a 401 loop it can't recover from. Once the initial access token expires:

  1. POST /mcp returns 401, the SDK refreshes, POST …/token (grant_type=refresh_token) returns 200, and the provider stores the new token.
  2. The retried POST /mcp still carries the old Authorization: Bearer …, gets another 401.
  3. The SDK's circuit breaker throws "Server returned 401 after successful authentication" straight to the UI — no further refresh, no fallback to a fresh login. The session is stuck until the user manually reconnects.

This is rarely hit with a long token TTL, but a short TTL breaks the session at the first expiry. Confirmed against a real OAuth server by hashing (SHA-256, never raw tokens) the token issued by a successful refresh vs. the bearer token sent on the very next request — the retry token was byte-identical to the pre-refresh token and different from the one just issued.

Note: Inspector V2 is under development. This is a V1 bug fix for MCP spec compliance.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Refactoring (no functional changes)
  • Test updates
  • Build/CI improvements

Changes Made

Root cause

When relying on OAuth, useConnection read the access token from the provider once at connection setup and baked Authorization: Bearer <token> into requestInit.headers (and the custom fetch wrapper's header override).

The transports already receive that same provider as their authProvider, so the SDK adds a fresh Authorization from it on every request (and refreshes it on 401). But the SDK's _commonHeaders() spreads requestInit.headers after the provider token:

const headers = {};
if (this._authProvider) {
  const tokens = await this._authProvider.tokens();       // fresh token (post-refresh)
  headers['Authorization'] = `Bearer ${tokens.access_token}`;
}
const extraHeaders = normalizeHeaders(this._requestInit?.headers); // connect-time snapshot
return new Headers({ ...headers, ...extraHeaders });      // ← snapshot wins

So the connect-time snapshot always shadowed the provider's current token. The provider does store the refreshed token — it just never got used.

Fix

For direct connections, stop snapshotting the OAuth-provider token into requestInit. The provider is the single, always-current source of the token, and the SDK injects and refreshes it per request. Specifically:

  • Track whether the Authorization header was injected from the OAuth provider (vs. supplied by the user) and, for direct connections, strip that provider-injected header from requestHeaders so it isn't baked into requestInit.
  • A user-supplied static Authorization custom header still flows through and intentionally overrides the provider.
  • Reorder the SSE custom fetch wrapper so the SDK-provided init (which already carries the fresh, provider-injected token) wins over the connect-time header snapshot, matching the streamable-http wrapper.

The proxy-connection path is intentionally left unchanged in this PR.

This affects connections that use the SDK's OAuth authProvider and is independent of the malformed-Content-Type fix (#1160).

Related Issues

Fixes #1434

Testing

  • Tested in UI mode
  • Tested in CLI mode
  • Tested with STDIO transport
  • Tested with SSE transport
  • Tested with Streamable HTTP transport
  • Added/updated automated tests
  • Manual testing performed

Test Results and/or Instructions

Added useConnection.test.tsx cases asserting that, for direct connections:

  • the OAuth provider token is not baked into requestInit.headers (and authProvider is wired up so the SDK injects/refreshes it), and
  • a user-supplied static Authorization header still flows through unchanged.

npm run prettier-fix, client npm run lint, tsc --noEmit, and the full useConnection.test.tsx suite (48 tests) pass.

Checklist

  • Code follows the style guidelines (ran npm run prettier-fix)
  • Self-review completed
  • Code is commented where necessary
  • Documentation updated (README, comments, etc.)

AI Generated, Human reviewed

…shot

After a successful OAuth token refresh, direct (non-proxy) connections kept
sending the old access token, so the session entered an unrecoverable 401
loop once the initial access token expired.

useConnection read the access token from the provider once at connection
setup and baked "Authorization: Bearer <token>" into requestInit.headers
(and the custom fetch's header override). The transports already receive
that same provider as authProvider and add a fresh Authorization from it on
every request (refreshing on 401), but the SDK's _commonHeaders() spreads
requestInit.headers after the provider token, so the connect-time snapshot
always shadowed the refreshed token.

Stop snapshotting the OAuth token for direct connections and rely on the
authProvider as the single, always-current source. A user-supplied static
Authorization header still flows through and intentionally overrides the
provider. The SSE custom fetch is also reordered so the SDK-provided init
(carrying the fresh token) wins over the connect-time header snapshot.

Fixes modelcontextprotocol#1434
@justinedelson
justinedelson marked this pull request as ready for review July 16, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant