Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
45c5a0c
client: persist interactive OAuth metadata across redirects
Mar 29, 2026
5276439
fix(stdio): always set windowsHide on Windows, not just in Electron (…
felixweinberger Mar 30, 2026
6711ed9
feat(client): add reconnectionScheduler to StreamableHTTPClientTransp…
felixweinberger Mar 30, 2026
9d08392
SEP-2207: Refresh token guidance (#1523)
wdawson Mar 30, 2026
9d924b1
docs: note type vs interface for structuredContent (#1784)
felixweinberger Mar 30, 2026
48aba0d
fix(core): add explicit | undefined to Transport interface optional p…
felixweinberger Mar 30, 2026
d0505c1
chore(v2): pnpm audit fix (#1789)
felixweinberger Mar 30, 2026
9efecc2
ci: format fetch-spec-types output with prettier (#1782)
felixweinberger Mar 30, 2026
4aec5f7
Private key jwt scopes (#1443)
NSeydoux Mar 30, 2026
045c62a
feat!: remove WebSocketClientTransport (#1783)
felixweinberger Mar 30, 2026
d99f3ee
fix: continue OAuth metadata discovery on 5xx responses (#1632)
matantsach Mar 30, 2026
a39a9eb
test: add compile-time key-parity assertions for spec type checks (#1…
rechedev9 Mar 30, 2026
d6a02c8
fix(core): ensure standardSchemaToJsonSchema emits type:object (#1796)
felixweinberger Mar 30, 2026
8822c96
fix(examples): return 404 for unknown session IDs, 400 for missing (#…
felixweinberger Mar 30, 2026
89fb094
fix(core): consolidate per-request cleanup in _requestWithSchema (#1790)
felixweinberger Mar 30, 2026
fcde488
chore: drop zod from peerDependencies (kept as direct dependency) (#1…
felixweinberger Mar 31, 2026
9bc9abc
Fix: Handle error responses in Streamable HTTP SSE streams (#1390)
DePasqualeOrg Mar 31, 2026
f73a5af
Add _meta support to registerPrompt (#1629)
chughtapan Mar 31, 2026
2fd7f5f
`v2`: Web standards Request object in ctx (#1822)
KKonstantinov Mar 31, 2026
5f32a90
fix(core): make fromJsonSchema() use runtime-aware default validator …
KKonstantinov Mar 31, 2026
81e4b2a
Adds Fastify Middleware for v2 (#1536)
andyfleming Mar 31, 2026
0fabc27
chore: enter alpha prerelease mode (#1823)
felixweinberger Mar 31, 2026
689148d
fix(server): propagate negotiated protocol version to transport (#1660)
rechedev9 Mar 31, 2026
babaa50
chore(ci): remove dead publish job from main.yml (#1829)
felixweinberger Mar 31, 2026
38d6cd2
chore(fastify): remove stray packageManager field from subpackage (#1…
felixweinberger Apr 1, 2026
54fa96e
Version Packages (alpha) (#1420)
github-actions[bot] Apr 1, 2026
53fb84b
fix(ci): split release.yml into version + publish jobs (#1836)
felixweinberger Apr 1, 2026
424cbae
v2 tsdown fix (#1840)
KKonstantinov Apr 1, 2026
0021561
Version Packages (alpha) (#1841)
github-actions[bot] Apr 1, 2026
866c08d
fix(core): allow additional JSON Schema properties in elicitInput req…
felixweinberger Apr 2, 2026
1eb3123
fix(client): preserve custom Accept headers in StreamableHTTPClientTr…
nielskaspers Apr 2, 2026
653c5d0
Rewrite `docs/server.md` as a code-heavy, prose-light how-to guide (#…
jonathanhefner Apr 2, 2026
df4b6cc
fix: prevent stack overflow in transport close with re-entrancy guard…
claygeo Apr 2, 2026
a408ca7
feat(client): add OAuthProvider for preserving discovery state across…
Apr 3, 2026
317fec5
merge: resolve streamableHttp.ts conflict, keep OAuth implementation
Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export class SSEClientTransport implements Transport {
private _fetchWithInit: FetchLike;
private _protocolVersion?: string;

private static readonly _INTERACTIVE_AUTH_STATE_PREFIX = 'mcp:oauth:interactive:sse:';

onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
Expand All @@ -96,6 +98,64 @@ export class SSEClientTransport implements Transport {
}
this._fetch = opts?.fetch;
this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit);

this._loadInteractiveAuthState();
}

private _interactiveAuthStateKey(): string {
return `${SSEClientTransport._INTERACTIVE_AUTH_STATE_PREFIX}${this._url.toString()}`;
}

private _saveInteractiveAuthState(): void {
if (typeof sessionStorage === 'undefined') {
return;
}

const state = {
resourceMetadataUrl: this._resourceMetadataUrl?.toString(),
scope: this._scope
};

try {
sessionStorage.setItem(this._interactiveAuthStateKey(), JSON.stringify(state));
} catch {
// Ignore storage failures (e.g. quota exceeded)
}
}

private _loadInteractiveAuthState(): void {
if (typeof sessionStorage === 'undefined') {
return;
}

try {
const raw = sessionStorage.getItem(this._interactiveAuthStateKey());
if (!raw) {
return;
}

const parsed = JSON.parse(raw) as { resourceMetadataUrl?: string; scope?: string };
if (parsed.resourceMetadataUrl) {
this._resourceMetadataUrl = new URL(parsed.resourceMetadataUrl);
}
if (parsed.scope) {
this._scope = parsed.scope;
}
} catch {
// Ignore malformed persisted state
}
}

private _clearInteractiveAuthState(): void {
if (typeof sessionStorage === 'undefined') {
return;
}

try {
sessionStorage.removeItem(this._interactiveAuthStateKey());
} catch {
// Ignore storage failures
}
}

private _last401Response?: Response;
Expand Down Expand Up @@ -137,6 +197,7 @@ export class SSEClientTransport implements Transport {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._saveInteractiveAuthState();
}
}

Expand Down Expand Up @@ -237,6 +298,8 @@ export class SSEClientTransport implements Transport {
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
}

this._clearInteractiveAuthState();
}

async close(): Promise<void> {
Expand Down Expand Up @@ -272,6 +335,7 @@ export class SSEClientTransport implements Transport {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._saveInteractiveAuthState();
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Expand Down
64 changes: 64 additions & 0 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export class StreamableHTTPClientTransport implements Transport {
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;

private static readonly _INTERACTIVE_AUTH_STATE_PREFIX = 'mcp:oauth:interactive:streamable-http:';

onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
Expand All @@ -172,6 +174,64 @@ export class StreamableHTTPClientTransport implements Transport {
this._sessionId = opts?.sessionId;
this._protocolVersion = opts?.protocolVersion;
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;

this._loadInteractiveAuthState();
}

private _interactiveAuthStateKey(): string {
return `${StreamableHTTPClientTransport._INTERACTIVE_AUTH_STATE_PREFIX}${this._url.toString()}`;
}

private _saveInteractiveAuthState(): void {
if (typeof sessionStorage === 'undefined') {
return;
}

const state = {
resourceMetadataUrl: this._resourceMetadataUrl?.toString(),
scope: this._scope
};

try {
sessionStorage.setItem(this._interactiveAuthStateKey(), JSON.stringify(state));
} catch {
// Ignore storage failures (e.g. quota exceeded)
}
}

private _loadInteractiveAuthState(): void {
if (typeof sessionStorage === 'undefined') {
return;
}

try {
const raw = sessionStorage.getItem(this._interactiveAuthStateKey());
if (!raw) {
return;
}

const parsed = JSON.parse(raw) as { resourceMetadataUrl?: string; scope?: string };
if (parsed.resourceMetadataUrl) {
this._resourceMetadataUrl = new URL(parsed.resourceMetadataUrl);
}
if (parsed.scope) {
this._scope = parsed.scope;
}
} catch {
// Ignore malformed persisted state
}
}

private _clearInteractiveAuthState(): void {
if (typeof sessionStorage === 'undefined') {
return;
}

try {
sessionStorage.removeItem(this._interactiveAuthStateKey());
} catch {
// Ignore storage failures
}
}

Comment on lines 205 to 210
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

  1. 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.
  2. Extra network calls: every post-redirect finishAuth() triggers redundant metadata discovery, adding latency and failure surface.
  3. 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

  1. Browser makes MCP request via StreamableHTTPClientTransport; server returns 401 with a WWW-Authenticate header containing the resource_metadata URL and scope.
  2. Transport stores _resourceMetadataUrl and _scope in memory. Without _saveInteractiveAuthState(), nothing is written to sessionStorage.
  3. App redirects the user to the OAuth provider. The original transport instance is destroyed (full page navigation).
  4. OAuth provider redirects back to the callback page. App creates a new StreamableHTTPClientTransport.
  5. Without _loadInteractiveAuthState(), _resourceMetadataUrl is undefined and _scope is undefined.
  6. finishAuth('auth-code') calls auth() with resourceMetadataUrl: undefined, forcing a full re-discovery network call.
  7. 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.

private async _commonHeaders(): Promise<Headers> {
Expand Down Expand Up @@ -223,6 +283,7 @@ export class StreamableHTTPClientTransport implements Transport {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._saveInteractiveAuthState();
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Expand Down Expand Up @@ -455,6 +516,8 @@ export class StreamableHTTPClientTransport implements Transport {
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
}

this._clearInteractiveAuthState();
}

async close(): Promise<void> {
Expand Down Expand Up @@ -516,6 +579,7 @@ export class StreamableHTTPClientTransport implements Transport {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._saveInteractiveAuthState();
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Expand Down
59 changes: 59 additions & 0 deletions packages/client/test/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ describe('SSEClientTransport', () => {
let sendServerMessage: ((message: string) => void) | null = null;

beforeEach(async () => {
const sessionStorageStore = new Map<string, string>();
Object.defineProperty(globalThis, 'sessionStorage', {
configurable: true,
value: {
getItem: (key: string) => sessionStorageStore.get(key) ?? null,
setItem: (key: string, value: string) => {
sessionStorageStore.set(key, value);
},
removeItem: (key: string) => {
sessionStorageStore.delete(key);
}
}
});

// Reset state
lastServerRequest = null as unknown as IncomingMessage;
sendServerMessage = null;
Expand Down Expand Up @@ -111,6 +125,7 @@ describe('SSEClientTransport', () => {
await authServer.close();

vi.clearAllMocks();
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
});

describe('connection handling', () => {
Expand Down Expand Up @@ -1527,6 +1542,50 @@ describe('SSEClientTransport', () => {
// Global fetch should never have been called
expect(globalFetchSpy).not.toHaveBeenCalled();
});

it('persists interactive auth metadata across transport recreation before finishAuth', async () => {
const authProviderWithCode = createMockAuthProvider({
clientRegistered: true,
authorizationCode: 'test-auth-code'
});

const unauthorizedResponse = new Response(null, {
status: 401,
headers: {
'WWW-Authenticate': `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource", scope="calendar.read"`
}
});

const firstAuthProvider = {
token: vi.fn(async () => undefined)
};

const firstTransport = new SSEClientTransport(resourceBaseUrl, {
authProvider: firstAuthProvider,
fetch: vi.fn().mockResolvedValue(unauthorizedResponse)
});

// Skip EventSource startup; directly exercise POST 401 path where metadata is captured.
(firstTransport as unknown as { _endpoint: URL })._endpoint = new URL(resourceBaseUrl.href);
await expect(firstTransport.send({ jsonrpc: '2.0', method: 'ping', params: {}, id: '1' })).rejects.toThrow(UnauthorizedError);
await firstTransport.close();

const secondTransport = new SSEClientTransport(resourceBaseUrl, {
authProvider: authProviderWithCode,
fetch: customFetch
});

await secondTransport.finishAuth('test-auth-code');

expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
});

await secondTransport.close();
});
});

describe('minimal AuthProvider (non-OAuth)', () => {
Expand Down
91 changes: 90 additions & 1 deletion packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import type { Mock, Mocked } from 'vitest';

import type { OAuthClientProvider } from '../../src/client/auth.js';
import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth.js';
import { UnauthorizedError } from '../../src/client/auth.js';
import type { StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js';
Expand All @@ -12,6 +12,20 @@ describe('StreamableHTTPClientTransport', () => {
let mockAuthProvider: Mocked<OAuthClientProvider>;

beforeEach(() => {
const sessionStorageStore = new Map<string, string>();
Object.defineProperty(globalThis, 'sessionStorage', {
configurable: true,
value: {
getItem: (key: string) => sessionStorageStore.get(key) ?? null,
setItem: (key: string, value: string) => {
sessionStorageStore.set(key, value);
},
removeItem: (key: string) => {
sessionStorageStore.delete(key);
}
}
});

mockAuthProvider = {
get redirectUrl() {
return 'http://localhost/callback';
Expand All @@ -34,6 +48,7 @@ describe('StreamableHTTPClientTransport', () => {
afterEach(async () => {
await transport.close().catch(() => {});
vi.clearAllMocks();
delete (globalThis as { sessionStorage?: Storage }).sessionStorage;
});

it('should send JSON-RPC messages via POST', async () => {
Expand Down Expand Up @@ -1441,6 +1456,80 @@ describe('StreamableHTTPClientTransport', () => {
// Global fetch should never have been called
expect(globalThis.fetch).not.toHaveBeenCalled();
});

it('persists interactive auth metadata across transport recreation before finishAuth', async () => {
const customFetch = vi
.fn()
// First transport send -> 401 with auth metadata
.mockResolvedValueOnce(
new Response(null, {
status: 401,
headers: {
'WWW-Authenticate':
'Bearer resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource", scope="calendar.read"'
}
})
)
// Second transport finishAuth -> resource metadata discovery
.mockResolvedValueOnce(
Response.json({
authorization_servers: ['http://localhost:1234'],
resource: 'http://localhost:1234/mcp'
})
)
// auth server metadata discovery
.mockResolvedValueOnce(
Response.json({
issuer: 'http://localhost:1234',
authorization_endpoint: 'http://localhost:1234/authorize',
token_endpoint: 'http://localhost:1234/token',
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
})
)
// authorization code exchange
.mockResolvedValueOnce(
Response.json({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600
})
);

const firstAuthProvider: AuthProvider = {
token: vi.fn(async () => undefined)
};

const firstTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
authProvider: firstAuthProvider,
fetch: customFetch
});

await firstTransport.start();

await expect(firstTransport.send({ jsonrpc: '2.0', method: 'ping', params: {}, id: '1' } as JSONRPCMessage)).rejects.toThrow(
UnauthorizedError
);

await firstTransport.close();

const secondTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
authProvider: mockAuthProvider,
fetch: customFetch
});

await secondTransport.finishAuth('auth-code-after-redirect');

expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
});

await secondTransport.close();
});
});

describe('SSE retry field handling', () => {
Expand Down
Loading