client: preserve OAuth discovery metadata across browser redirects#1816
client: preserve OAuth discovery metadata across browser redirects#1816windro-xdd wants to merge 35 commits intomodelcontextprotocol:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 317fec5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…odelcontextprotocol#1772) Co-authored-by: jnMetaCode <147776183+jnMetaCode@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
…ort (modelcontextprotocol#1763) Co-authored-by: CHOIJEWON <alsrn6040@kakao.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
…protocol#1632) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Paul Carleton <paulcarletonjr@gmail.com>
…delcontextprotocol#1652) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
…delcontextprotocol#1824) Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
…extprotocol#1390) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
modelcontextprotocol#1825) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
pcarleton
left a comment
There was a problem hiding this comment.
Hi, you should be able to do this with loadDiscoveryState / saveDiscoveryState, could be in an example implementation of OAuthProvider
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
…lcontextprotocol#1660) Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
|
@claude review |
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…uestedSchema (modelcontextprotocol#1768) Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
…ansport (modelcontextprotocol#1655) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
…odelcontextprotocol#1552) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…modelcontextprotocol#1788) Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
… redirects Add OAuthProvider class to enable stateful OAuth flows in browser-based applications. The provider persists discovery state (resource metadata URL, scopes) across OAuth redirects using sessionStorage with automatic 15-minute expiry, graceful degradation when storage is unavailable, and comprehensive error handling. Key features: - saveDiscoveryState/loadDiscoveryState for state persistence - Automatic timestamp-based expiry validation - getAuthorizationUrl for RFC 6749 OAuth 2.0 compatibility - Createable client transport with restored state - Graceful handling of missing sessionStorage (private browsing) - Try-catch wrapped storage operations Includes: - Full implementation with JSDoc documentation - Comprehensive test suite (state persistence, expiry, error handling) - OAuth_REDIRECT_EXAMPLE.md with usage patterns and security notes Fixes the issue of losing discovery state during browser redirects in interactive OAuth flows when implementing MCP-compatible web applications.
|
@claude review |
| * ``` | ||
| */ | ||
|
|
||
| import { StreamableHTTPClientTransport } from "../client/index.js"; |
There was a problem hiding this comment.
🔴 The new OAuthProvider class in packages/client/src/providers/oauthProvider.ts has two critical bugs that make it non-functional. Line 19 imports from "../client/index.js", but no index.ts barrel exists in packages/client/src/client/ — this is a compilation error that prevents the file from building. Additionally, createTransportWithRestoredState() accepts resourceMetadataUrl and scope options but silently ignores them: it creates a bare StreamableHTTPClientTransport with empty options {} and only emits a console.debug message, leaving the restored state completely unapplied. Since _resourceMetadataUrl and _scope are private fields on StreamableHTTPClientTransport with no public setter, there is also no architectural path to inject the state. OAUTH_REDIRECT_EXAMPLE.md documents createTransportWithRestoredState() as the recommended redirect-recovery pattern, so users following the docs will silently lose all recovered state.
Extended reasoning...
Bug 1: Broken import path (compilation error)
oauthProvider.ts line 19 imports:
import { StreamableHTTPClientTransport } from "../client/index.js";
The file lives at packages/client/src/providers/oauthProvider.ts, so ../client/ resolves to packages/client/src/client/. That directory contains auth.ts, sse.ts, streamableHttp.ts, stdio.ts, etc. — but no index.ts barrel file. TypeScript's module resolution cannot find ../client/index.js and will emit a TS2307 error at compile time.
The correct import is either "../client/streamableHttp.js" (direct file) or "../index.js" (the package-level barrel at packages/client/src/index.ts, which does export StreamableHTTPClientTransport). The tsconfig.json for the client package uses "include": ["./"] which recursively includes all TypeScript files — so oauthProvider.ts will be compiled. The test at packages/client/test/providers/oauthProvider.test.ts directly imports from oauthProvider.js, so both pnpm typecheck and pnpm test would fail.
Step-by-step proof:
- TypeScript resolves "../client/index.js" to packages/client/src/client/index.ts (or .tsx, .d.ts).
- No such file exists in the directory.
- Build fails with TS2307 before any runtime code executes.
- Fix: change to "../client/streamableHttp.js".
Bug 2: createTransportWithRestoredState() is a no-op
createTransportWithRestoredState() is the entire point of the OAuthProvider class — it is called after an OAuth redirect to recreate a transport with previously-discovered metadata. The implementation:
const transport = new StreamableHTTPClientTransport(new URL(url), {});
// If we have restored state, we could apply it to the transport
// or use it to configure subsequent discovery requests
if (options?.resourceMetadataUrl) {
console.debug("[OAuthProvider] Restored discovery state...", options.resourceMetadataUrl);
}
return transport;
The comment "we could apply it to the transport" is an explicit marker of a deferred/incomplete placeholder. The options parameter is accepted but never used: the transport is constructed with empty options {}, and the only action taken when resourceMetadataUrl is present is a console.debug call.
Why this cannot be fixed externally: StreamableHTTPClientTransport stores _resourceMetadataUrl and _scope as private fields (lines 173-174 of streamableHttp.ts). There is no public setter for either field, so createTransportWithRestoredState() cannot inject the restored state from outside the class.
Contrast with SSEClientTransport: This PR correctly implements sessionStorage persistence for SSEClientTransport (_loadInteractiveAuthState called in constructor, _saveInteractiveAuthState called on 401). StreamableHTTPClientTransport receives NO equivalent sessionStorage code, making createTransportWithRestoredState() the only intended mechanism for state restoration after a redirect — and that mechanism is broken.
Step-by-step proof of the broken flow:
- User follows OAUTH_REDIRECT_EXAMPLE.md: calls oauthProvider.saveDiscoveryState({ resourceMetadataUrl: 'https://...', scope: 'read' }) before redirect.
- After redirect, calls oauthProvider.loadDiscoveryState() which returns { resourceMetadataUrl: 'https://...', scope: 'read', timestamp: ... }.
- Calls oauthProvider.createTransportWithRestoredState('https://api.example.com', { resourceMetadataUrl: 'https://...', scope: 'read' }).
- Method executes: new StreamableHTTPClientTransport(new URL(url), {}) — options are ignored entirely.
- Returns transport with _resourceMetadataUrl = undefined and _scope = undefined.
- Subsequent finishAuth() call re-runs full discovery from scratch, losing the restored metadata URL.
- Token exchange may fail or use the wrong discovery path — the redirect recovery is silently broken.
| this._sessionId = opts?.sessionId; | ||
| this._protocolVersion = opts?.protocolVersion; | ||
| this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; | ||
| this._reconnectionScheduler = opts?.reconnectionScheduler; | ||
| } | ||
|
|
There was a problem hiding this comment.
🔴 StreamableHTTPClientTransport is missing the sessionStorage persistence that this PR claims to implement for it. The PR description states "persist interactive OAuth metadata (resource_metadata URL and scope) in browser sessionStorage for both Streamable HTTP and SSE client transports", but only SSEClientTransport received the implementation. In a real browser redirect flow, the new StreamableHTTPClientTransport instance will start with undefined _resourceMetadataUrl and _scope and must redo full OAuth discovery on every post-redirect finishAuth() call — unlike SSEClientTransport, which restores from sessionStorage. The test named "persists interactive auth metadata across transport recreation before finishAuth" passes only because all discovery responses are mocked; the sessionStorage mock set up in beforeEach is never exercised by StreamableHTTPClientTransport.
Extended reasoning...
What the bug is
The PR title and description explicitly state it persists OAuth discovery state "for both Streamable HTTP and SSE client transports", but StreamableHTTPClientTransport has zero sessionStorage implementation. A grep for "sessionStorage", "_saveInteractiveAuthState", "_loadInteractiveAuthState", or "_clearInteractiveAuthState" in packages/client/src/client/streamableHttp.ts returns no results in this PR. The feature is only half-implemented.
The specific code path that triggers it
SSEClientTransport (sse.ts) gained all three lifecycle methods:
- Constructor: calls
_loadInteractiveAuthState()to restore persisted metadata - On 401 response: calls
_saveInteractiveAuthState()after extracting WWW-Authenticate params - After finishAuth(): calls
_clearInteractiveAuthState()to prevent stale state
streamableHttp.ts received none of these. The file was changed in this PR for reconnection, Accept header merging, and error-response handling — but the OAuth state persistence that is the core stated goal is absent.
Why existing code does not prevent it
The test "persists interactive auth metadata across transport recreation before finishAuth" in streamableHttp.test.ts is misleadingly named. The test sets up a sessionStorage mock in beforeEach (matching the SSE test), but StreamableHTTPClientTransport never reads from or writes to sessionStorage. The test works because the mock fetch provides four successive responses: 401 (first transport), resource metadata, auth server metadata, and token exchange (second transport). The second transport finishAuth() succeeds by doing full fresh discovery from the mocked data — not by restoring persisted state. This gives a false impression that the feature was implemented and tested.
Impact
In production browser redirect flows without mocked network calls:
- Server temporarily unreachable during callback: finishAuth() will fail even though the original 401 response captured the resource metadata URL. SSEClientTransport would succeed because it has the URL in sessionStorage.
- Extra network calls: every post-redirect finishAuth() triggers redundant metadata discovery, adding latency and failure surface.
- Inconsistency between transports: the stated PR goal of consistent behavior across both transports is not achieved.
How to fix it
StreamableHTTPClientTransport needs the same three private methods that SSEClientTransport has, with an appropriate storage key prefix (e.g. "mcp:oauth:interactive:streamablehttp:"). The constructor should call _loadInteractiveAuthState(), the 401-handling path should call _saveInteractiveAuthState() after extracting WWW-Authenticate params, and finishAuth() should call _clearInteractiveAuthState() after successful authorization.
Step-by-step proof
- Browser makes MCP request via StreamableHTTPClientTransport; server returns 401 with a WWW-Authenticate header containing the resource_metadata URL and scope.
- Transport stores
_resourceMetadataUrland_scopein memory. Without_saveInteractiveAuthState(), nothing is written to sessionStorage. - App redirects the user to the OAuth provider. The original transport instance is destroyed (full page navigation).
- OAuth provider redirects back to the callback page. App creates a new StreamableHTTPClientTransport.
- Without
_loadInteractiveAuthState(),_resourceMetadataUrlis undefined and_scopeis undefined. finishAuth('auth-code')callsauth()withresourceMetadataUrl: undefined, forcing a full re-discovery network call.- If the discovery endpoint is temporarily unreachable during the callback, finishAuth() throws — even though the metadata URL was successfully captured before the redirect. An SSEClientTransport in the same scenario would succeed using the sessionStorage-cached URL.
Summary
resource_metadataURL andscope) in browsersessionStoragefor both Streamable HTTP and SSE client transportsfinishAuth()works after full-page redirectsfinishAuth()on both transportsWhy
finishAuth()can fail in browser redirect flows when the original transport instance is lost and theWWW-Authenticatemetadata extracted before redirect is no longer in memory. Persisting and restoring this metadata keeps token exchange on the same discovered authorization path.Validation
pnpm --filter @modelcontextprotocol/client typecheckpnpm --filter @modelcontextprotocol/client lintpnpm --filter @modelcontextprotocol/client test -- test/client/streamableHttp.test.ts test/client/sse.test.tsCloses #1234