Skip to content

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

Closed
buptliuhs wants to merge 1 commit into
modelcontextprotocol:v1/mainfrom
buptliuhs:fix-stale-token-after-refresh
Closed

fix: use the auth provider's current token instead of a connect-time snapshot#1434
buptliuhs wants to merge 1 commit into
modelcontextprotocol:v1/mainfrom
buptliuhs:fix-stale-token-after-refresh

Conversation

@buptliuhs

@buptliuhsbuptliuhs commented Jun 4, 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 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 oldAuthorization: Bearer …, gets another 401.
  3. The SDK's circuit breaker throws "Server returned 401 after successful authentication".

This is rarely hit with a long token TTL, but a short TTL breaks the session at first expiry.

Note: Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on bug fixes and MCP spec compliance. See CONTRIBUTING.md for more details.

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.

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.headersafter the provider token:

constheaders={};if(this._authProvider){consttokens=awaitthis._authProvider.tokens();// fresh token (post-refresh)headers['Authorization']=`Bearer ${tokens.access_token}`;}constextraHeaders=normalizeHeaders(this._requestInit?.headers);// connect-time snapshotreturnnewHeaders({ ...headers, ...extraHeaders});// ← snapshot wins

So the connect-time snapshot always shadows the provider's current token. The provider does store the refreshed token (saveTokens/tokens() round-trip the same sessionStorage key) — it just never gets used.

Fix

Stop snapshotting the OAuth token into requestInit. The provider is the single, always-current source of the token, and the SDK injects and refreshes it per request. A user-supplied staticAuthorization header still flows through the custom-headers path and intentionally overrides the provider.

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

Related Issues

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

  • Updated the test that asserted the old baking behavior to assert the token is no longer baked into requestInit and that authProvider is set.
  • Added direct SSE / Streamable HTTP tests covering the same.
  • The existing "preserves server Authorization header" test still passes, confirming static user-supplied tokens are unaffected.

All useConnection tests pass; lint and prettier clean; client build compiles.

Checklist

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

Breaking Changes

None. Non-breaking bug fix.

Additional Context

…snapshot
When relying on OAuth, useConnection read the access token from the
provider once at connection setup and baked `Authorization: Bearer
<token>` into `requestInit.headers`. The SDK transports already receive
the same provider as their `authProvider` and add a fresh `Authorization`
from it on every request, refreshing it on 401. But the SDK's
`_commonHeaders()` spreads `requestInit.headers` after the provider
token, so the connect-time snapshot always won.
The effect: after the access token expires, the SDK refreshes
successfully (token endpoint returns 200 and the provider stores the new
token), but the retried request still carries the stale snapshot token,
gets another 401, and the SDK's circuit breaker throws "Server returned
401 after successful authentication" — a 401 loop the session can't
recover from.
Stop snapshotting the OAuth token into requestInit and let the provider
be the single source of truth. A user-supplied static Authorization
header still flows through the custom-headers path and intentionally
overrides the provider.
Update the related test to assert the token is no longer baked in, and
add direct SSE / Streamable HTTP tests covering it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@buptliuhs

Copy link
Copy Markdown
Author

@cliffhall can you please review? thanks!

@cliffhall

cliffhall commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closing: v1 is deprecated.

Thank you for this contribution, and apologies for the long wait for a response.

v1 will receive security fixes only. We reviewed every open v1 PR for security impact before closing — see the backlog triage in #1819 — and a small number were retained for a final 1.0.5 patch release. This one is a functionality, compatibility, or cleanup change rather than a vulnerability fix, so it is being closed unmerged. This is not a judgment on the quality of the work — it's a consequence of the branch it targets being frozen.

If the underlying problem still exists in v2, we'd genuinely like to know. Please open an issue describing it against v2. Note that we accept external contributions as issues rather than pull requests — maintainers handle design and implementation through a prompt-driven workflow. See CONTRIBUTORS.md.

Thanks again for taking the time to contribute to the Inspector.

@cliffhallcliffhall added the closed-v1-deprecated Closed: v1 is deprecated and accepting security fixes only label Jul 31, 2026
@buptliuhs
buptliuhs deleted the fix-stale-token-after-refresh branch August 28, 2026 01:53
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

closed-v1-deprecatedClosed: v1 is deprecated and accepting security fixes only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@buptliuhs@cliffhall