From d115217744199762c1f99a3f64bc1f0c98ff3947 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Tue, 9 Jun 2026 21:33:33 +0100 Subject: [PATCH 1/7] fix(client): accumulate scopes (union) on step-up authorization challenges (SEP-2350) Per the draft authorization spec's step-up authorization flow, scope accumulation is a client-side responsibility: when re-authorizing after a 403 insufficient_scope challenge, the client SHOULD request the union of previously requested/granted scopes and the newly challenged scopes. Previously the StreamableHTTP transport replaced the requested scope with the challenged scopes only, dropping previously granted permissions. - Add exported unionScopes() helper (opaque-string, order-preserving, deduped union of space-delimited scope strings) - Union stored token scope + previously requested scope + challenged scope in the 403 insufficient_scope retry path - Tests for the step-up union, dedup, and no-prior-scope cases plus unionScopes unit tests Closes #2200 --- .changeset/sep-2350-scope-union-step-up.md | 5 + packages/client/src/client/auth.ts | 28 ++++++ packages/client/src/client/streamableHttp.ts | 10 +- packages/client/src/index.ts | 1 + packages/client/test/client/auth.test.ts | 41 ++++++++ .../client/test/client/streamableHttp.test.ts | 93 +++++++++++++++++++ 6 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 .changeset/sep-2350-scope-union-step-up.md diff --git a/.changeset/sep-2350-scope-union-step-up.md b/.changeset/sep-2350-scope-union-step-up.md new file mode 100644 index 0000000000..24f4465ffa --- /dev/null +++ b/.changeset/sep-2350-scope-union-step-up.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of previously granted scopes (from stored tokens), previously requested scopes, and the newly challenged scopes. Adds an exported `unionScopes` helper to `@modelcontextprotocol/client`. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9e47a38203..8eaea64223 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -598,6 +598,34 @@ export function determineScope(options: { return effectiveScope; } +/** + * Computes the union of one or more space-delimited OAuth scope strings (SEP-2350). + * + * Per the MCP authorization spec's step-up authorization flow, scope accumulation is a + * client-side responsibility: when re-authorizing after an `insufficient_scope` challenge, + * clients SHOULD request the union of previously requested/granted scopes and the newly + * challenged scopes, so that per-operation challenges don't drop previously granted + * permissions. + * + * Scopes are treated as opaque strings (no hierarchy-aware deduplication). The result + * preserves first-seen order and removes exact duplicates. Returns `undefined` when no + * scopes are present in any input. + */ +export function unionScopes(...scopeStrings: Array): string | undefined { + const seen = new Set(); + for (const scopeString of scopeStrings) { + if (!scopeString) { + continue; + } + for (const scope of scopeString.split(/\s+/)) { + if (scope) { + seen.add(scope); + } + } + } + return seen.size > 0 ? [...seen].join(' ') : undefined; +} + async function authInternal( provider: OAuthClientProvider, { diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 8962cf5639..eab7dba4c8 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -16,7 +16,7 @@ import { import { EventSourceParserStream } from 'eventsource-parser/stream'; import type { AuthProvider, OAuthClientProvider } from './auth'; -import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth'; +import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError, unionScopes } from './auth'; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -609,7 +609,13 @@ export class StreamableHTTPClientTransport implements Transport { } if (scope) { - this._scope = scope; + // SEP-2350: scope accumulation is a client-side responsibility. When + // re-authorizing after a scope challenge, request the union of + // previously granted scopes (from the stored tokens), previously + // requested scopes, and the newly challenged scopes, so per-operation + // challenges don't drop previously granted permissions. + const grantedTokens = await this._oauthProvider.tokens(); + this._scope = unionScopes(grantedTokens?.scope, this._scope, scope); } if (resourceMetadataUrl) { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b48d7cd0e8..2265dde08b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -35,6 +35,7 @@ export { selectResourceURL, startAuthorization, UnauthorizedError, + unionScopes, validateClientMetadataUrl } from './client/auth'; export type { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a4b22e0c44..3589505974 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -19,6 +19,7 @@ import { registerClient, selectClientAuthMethod, startAuthorization, + unionScopes, validateClientMetadataUrl } from '../../src/client/auth'; import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions'; @@ -4149,3 +4150,43 @@ describe('OAuth Authorization', () => { }); }); }); + +describe('unionScopes (SEP-2350)', () => { + it('returns undefined when called with no arguments', () => { + expect(unionScopes()).toBeUndefined(); + }); + + it('returns undefined when all inputs are undefined or empty', () => { + expect(unionScopes(undefined, undefined)).toBeUndefined(); + expect(unionScopes('', undefined, '')).toBeUndefined(); + expect(unionScopes(' ')).toBeUndefined(); + }); + + it('returns a single scope string unchanged', () => { + expect(unionScopes('read')).toBe('read'); + expect(unionScopes('read write')).toBe('read write'); + }); + + it('unions multiple scope strings preserving first-seen order', () => { + expect(unionScopes('read write', 'admin')).toBe('read write admin'); + expect(unionScopes('admin', 'read write')).toBe('admin read write'); + }); + + it('deduplicates repeated scopes across inputs', () => { + expect(unionScopes('read write', 'write admin')).toBe('read write admin'); + expect(unionScopes('read', 'read', 'read')).toBe('read'); + }); + + it('skips undefined and empty entries between scope strings', () => { + expect(unionScopes(undefined, 'read', undefined, 'write')).toBe('read write'); + expect(unionScopes('', 'admin')).toBe('admin'); + }); + + it('normalizes extra whitespace between scopes', () => { + expect(unionScopes('read write', ' admin ')).toBe('read write admin'); + }); + + it('does not perform hierarchy-aware deduplication (scopes are opaque strings)', () => { + expect(unionScopes('repo', 'repo:read')).toBe('repo repo:read'); + }); +}); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 42709717e4..4a9d3de33a 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -3,6 +3,7 @@ import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelco import type { Mock, Mocked } from 'vitest'; import type { OAuthClientProvider } from '../../src/client/auth'; +import * as authModule from '../../src/client/auth'; import { UnauthorizedError } from '../../src/client/auth'; import type { ReconnectionScheduler, StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp'; import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; @@ -975,6 +976,98 @@ describe('StreamableHTTPClientTransport', () => { authSpy.mockRestore(); }); + describe('scope accumulation on step-up authorization (SEP-2350)', () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + const mock403Then202 = (challengeScope: string) => { + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${challengeScope}"` + }), + text: () => Promise.resolve('Insufficient scope') + }) + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + }; + + it('requests the union of previously granted scopes and the challenged scope', async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + scope: 'read write' + }); + mock403Then202('admin'); + + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'read write admin' + }) + ); + + authSpy.mockRestore(); + }); + + it('deduplicates when the challenge repeats an already-granted scope', async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + scope: 'read write' + }); + mock403Then202('write admin'); + + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'read write admin' + }) + ); + + authSpy.mockRestore(); + }); + + it('uses only the challenged scope when there is no prior scope', async () => { + mockAuthProvider.tokens.mockResolvedValue(undefined); + mock403Then202('admin'); + + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'admin' + }) + ); + + authSpy.mockRestore(); + }); + }); + describe('Reconnection Logic', () => { let transport: StreamableHTTPClientTransport; From 4a89a930ffc2d5b85d40198006ce391dd12a9f36 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 10:45:37 +0200 Subject: [PATCH 2/7] test(conformance): union scopes during OAuth retry --- test/conformance/src/helpers/withOAuthRetry.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts index 8ebebdb63c..7f68bf5c08 100644 --- a/test/conformance/src/helpers/withOAuthRetry.ts +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -1,5 +1,5 @@ import type { FetchLike, Middleware } from '@modelcontextprotocol/client'; -import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError, unionScopes } from '@modelcontextprotocol/client'; import { ConformanceOAuthProvider } from './conformanceOAuthProvider'; @@ -9,7 +9,9 @@ export const handle401 = async ( next: FetchLike, serverUrl: string | URL ): Promise => { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + const tokens = await provider.tokens(); + const { resourceMetadataUrl, scope: challengedScope } = extractWWWAuthenticateParams(response); + const scope = unionScopes(tokens?.scope, challengedScope); let result = await auth(provider, { serverUrl, resourceMetadataUrl, From bba9f9c3ba6d3b38b01d44863638505ea9915ce2 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 11:48:26 +0200 Subject: [PATCH 3/7] fix(client): preserve scopes across auth retries --- packages/client/src/client/auth.ts | 8 +- packages/client/src/client/sse.ts | 36 +++++---- packages/client/src/client/streamableHttp.ts | 28 +++++-- .../client/test/client/streamableHttp.test.ts | 77 ++++++++++++++++++- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 8eaea64223..f29f861358 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -46,6 +46,10 @@ export interface UnauthorizedContext { serverUrl: URL; /** Fetch function configured with the transport's `requestInit`, for making auth requests. */ fetchFn: FetchLike; + /** Accumulated OAuth scope from previous challenges, if the transport has one. */ + scope?: string; + /** Resource metadata URL from previous challenges, if the transport has one. */ + resourceMetadataUrl?: URL; } /** @@ -104,8 +108,8 @@ export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response); const result = await auth(provider, { serverUrl: ctx.serverUrl, - resourceMetadataUrl, - scope, + resourceMetadataUrl: ctx.resourceMetadataUrl ?? resourceMetadataUrl, + scope: ctx.scope ?? scope, fetchFn: ctx.fetchFn }); if (result !== 'AUTHORIZED') { diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 3038ebfd82..335b7a410f 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -11,7 +11,7 @@ import type { ErrorEvent, EventSourceInit } from 'eventsource'; import { EventSource } from 'eventsource'; import type { AuthProvider, OAuthClientProvider } from './auth'; -import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth'; +import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError, unionScopes } from './auth'; export class SseError extends Error { constructor( @@ -143,7 +143,7 @@ export class SSEClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + this._scope = unionScopes(this._scope, scope); } } @@ -158,15 +158,23 @@ export class SSEClientTransport implements Transport { const response = this._last401Response; this._last401Response = undefined; this._eventSource?.close(); - this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( - // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. - () => this._startOrAuth().then(resolve, reject), - // onUnauthorized failed → not yet reported. - error => { - this.onerror?.(error); - reject(error); - } - ); + this._authProvider + .onUnauthorized({ + response, + serverUrl: this._url, + fetchFn: this._fetchWithInit, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope + }) + .then( + // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. + () => this._startOrAuth().then(resolve, reject), + // onUnauthorized failed → not yet reported. + error => { + this.onerror?.(error); + reject(error); + } + ); return; } const error = new UnauthorizedError(); @@ -278,14 +286,16 @@ export class SSEClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + this._scope = unionScopes(this._scope, scope); } if (this._authProvider.onUnauthorized && !isAuthRetry) { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index eab7dba4c8..e6d3febd2a 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -259,14 +259,16 @@ export class StreamableHTTPClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + this._scope = unionScopes(this._scope, scope); } if (this._authProvider.onUnauthorized && !isAuthRetry) { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice @@ -568,14 +570,16 @@ export class StreamableHTTPClientTransport implements Transport { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; + this._scope = unionScopes(this._scope, scope); } if (this._authProvider.onUnauthorized && !isAuthRetry) { await this._authProvider.onUnauthorized({ response, serverUrl: this._url, - fetchFn: this._fetchWithInit + fetchFn: this._fetchWithInit, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope }); await response.text?.().catch(() => {}); // Purposely _not_ awaited, so we don't call onerror twice @@ -612,10 +616,20 @@ export class StreamableHTTPClientTransport implements Transport { // SEP-2350: scope accumulation is a client-side responsibility. When // re-authorizing after a scope challenge, request the union of // previously granted scopes (from the stored tokens), previously - // requested scopes, and the newly challenged scopes, so per-operation - // challenges don't drop previously granted permissions. + // requested scopes, the protected resource's default scope, the + // provider's configured default scope, and the newly challenged + // scopes, so per-operation challenges don't drop previously granted + // permissions when the token response omits `scope`. const grantedTokens = await this._oauthProvider.tokens(); - this._scope = unionScopes(grantedTokens?.scope, this._scope, scope); + const discoveryState = await this._oauthProvider.discoveryState?.(); + const resourceScope = discoveryState?.resourceMetadata?.scopes_supported?.join(' '); + this._scope = unionScopes( + grantedTokens?.scope, + this._scope, + resourceScope, + this._oauthProvider.clientMetadata.scope, + scope + ); } if (resourceMetadataUrl) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 4a9d3de33a..45fe9fe3f3 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -2,7 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest, OAuthTokens } from '@modelcontextp import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; import type { Mock, Mocked } from 'vitest'; -import type { OAuthClientProvider } from '../../src/client/auth'; +import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth'; import * as authModule from '../../src/client/auth'; import { UnauthorizedError } from '../../src/client/auth'; import type { ReconnectionScheduler, StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp'; @@ -1066,6 +1066,81 @@ describe('StreamableHTTPClientTransport', () => { authSpy.mockRestore(); }); + + it('preserves client metadata scope when the token response omits scope', async () => { + vi.spyOn(mockAuthProvider, 'clientMetadata', 'get').mockReturnValue({ + redirect_uris: ['http://localhost/callback'], + scope: 'read write' + }); + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer' + }); + mock403Then202('admin'); + + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'read write admin' + }) + ); + + authSpy.mockRestore(); + }); + + it('preserves protected resource metadata scope when the token response omits scope', async () => { + mockAuthProvider.discoveryState = vi.fn().mockResolvedValue({ + authorizationServerUrl: 'http://localhost:1234', + resourceMetadata: { + resource: 'http://localhost:1234/mcp', + scopes_supported: ['read', 'write'] + } + }); + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer' + }); + mock403Then202('admin'); + + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'read write admin' + }) + ); + + authSpy.mockRestore(); + }); + + it('keeps accumulated scope after a 401 challenge without scope', async () => { + const authProvider: AuthProvider = { token: vi.fn(async () => undefined) }; + const challengeTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + (challengeTransport as unknown as { _scope?: string })._scope = 'read write'; + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer realm="test"' + }), + text: () => Promise.resolve('Unauthorized') + }); + + await expect(challengeTransport.send(message)).rejects.toThrow(UnauthorizedError); + expect((challengeTransport as unknown as { _scope?: string })._scope).toBe('read write'); + + await challengeTransport.close().catch(() => {}); + }); }); describe('Reconnection Logic', () => { From 897320c5769d0713a6ccda946758f4c3d5a70bc1 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 12:06:17 +0200 Subject: [PATCH 4/7] docs(changeset): clarify step-up scope union --- .changeset/sep-2350-scope-union-step-up.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/sep-2350-scope-union-step-up.md b/.changeset/sep-2350-scope-union-step-up.md index 24f4465ffa..5328d16d55 100644 --- a/.changeset/sep-2350-scope-union-step-up.md +++ b/.changeset/sep-2350-scope-union-step-up.md @@ -2,4 +2,5 @@ '@modelcontextprotocol/client': patch --- -Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of previously granted scopes (from stored tokens), previously requested scopes, and the newly challenged scopes. Adds an exported `unionScopes` helper to `@modelcontextprotocol/client`. +Accumulate scopes (union) when re-authorizing after a `403 insufficient_scope` step-up challenge (SEP-2350). Previously the challenged scopes replaced the requested scope, so per-operation challenges dropped previously granted permissions. The client now requests the union of +previously granted scopes (from stored tokens), previously requested scopes, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes. Adds an exported `unionScopes` helper to `@modelcontextprotocol/client`. From 5ba29d2c2e5a038866cef50f5ea550fb91fb5221 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 12:21:31 +0200 Subject: [PATCH 5/7] fix(client): preserve auth challenge context --- packages/client/src/client/sse.ts | 4 +-- packages/client/src/client/streamableHttp.ts | 4 +-- packages/client/test/client/sse.test.ts | 33 ++++++++++++++++++- .../client/test/client/streamableHttp.test.ts | 3 ++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 335b7a410f..b45cee77c7 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -142,7 +142,7 @@ export class SSEClientTransport implements Transport { this._last401Response = response; if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; + this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl; this._scope = unionScopes(this._scope, scope); } } @@ -285,7 +285,7 @@ export class SSEClientTransport implements Transport { if (response.status === 401 && this._authProvider) { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; + this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl; this._scope = unionScopes(this._scope, scope); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index e6d3febd2a..3621a231c4 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -258,7 +258,7 @@ export class StreamableHTTPClientTransport implements Transport { if (response.status === 401 && this._authProvider) { if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; + this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl; this._scope = unionScopes(this._scope, scope); } @@ -569,7 +569,7 @@ export class StreamableHTTPClientTransport implements Transport { // Store WWW-Authenticate params for interactive finishAuth() path if (response.headers.has('www-authenticate')) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; + this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl; this._scope = unionScopes(this._scope, scope); } diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index a5e79f6c99..39e31295bb 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1531,12 +1531,14 @@ describe('SSEClientTransport', () => { describe('minimal AuthProvider (non-OAuth)', () => { let postResponses: number[]; + let postHeaders: Record | undefined; let postCount: number; async function setupServer(): Promise { await resourceServer.close(); postCount = 0; + postHeaders = undefined; resourceServer = createServer((req, res) => { lastServerRequest = req; @@ -1554,7 +1556,11 @@ describe('SSEClientTransport', () => { if (req.method === 'POST') { const status = postResponses[postCount] ?? 200; postCount++; - res.writeHead(status).end(); + if (status === 401 && postHeaders) { + res.writeHead(status, postHeaders).end(); + } else { + res.writeHead(status).end(); + } return; } }); @@ -1575,6 +1581,31 @@ describe('SSEClientTransport', () => { await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); }); + it('keeps accumulated scope and resource metadata after a POST 401 challenge without params', async () => { + postResponses = [401, 200]; + await setupServer(); + postHeaders = { 'WWW-Authenticate': 'Bearer realm="test"' }; + + const resourceMetadataUrl = new URL('http://localhost:1234/.well-known/oauth-protected-resource/mcp'); + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized: vi.fn(async ctx => { + expect(ctx.scope).toBe('read write'); + expect(ctx.resourceMetadataUrl).toBe(resourceMetadataUrl); + }) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + await transport.start(); + (transport as unknown as { _scope?: string })._scope = 'read write'; + (transport as unknown as { _resourceMetadataUrl?: URL })._resourceMetadataUrl = resourceMetadataUrl; + + await transport.send(message); + + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect((transport as unknown as { _scope?: string })._scope).toBe('read write'); + expect((transport as unknown as { _resourceMetadataUrl?: URL })._resourceMetadataUrl).toBe(resourceMetadataUrl); + }); + it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkHttpError', async () => { postResponses = [401, 401]; await setupServer(); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 45fe9fe3f3..7da4219430 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1125,7 +1125,9 @@ describe('StreamableHTTPClientTransport', () => { it('keeps accumulated scope after a 401 challenge without scope', async () => { const authProvider: AuthProvider = { token: vi.fn(async () => undefined) }; const challengeTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + const resourceMetadataUrl = new URL('http://localhost:1234/.well-known/oauth-protected-resource/mcp'); (challengeTransport as unknown as { _scope?: string })._scope = 'read write'; + (challengeTransport as unknown as { _resourceMetadataUrl?: URL })._resourceMetadataUrl = resourceMetadataUrl; (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: false, status: 401, @@ -1138,6 +1140,7 @@ describe('StreamableHTTPClientTransport', () => { await expect(challengeTransport.send(message)).rejects.toThrow(UnauthorizedError); expect((challengeTransport as unknown as { _scope?: string })._scope).toBe('read write'); + expect((challengeTransport as unknown as { _resourceMetadataUrl?: URL })._resourceMetadataUrl).toBe(resourceMetadataUrl); await challengeTransport.close().catch(() => {}); }); From 0999f1b6589fbe831039eb2bcdcf2586e95b90be Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 12:32:03 +0200 Subject: [PATCH 6/7] test(conformance): remove passing scope step-up baseline --- test/conformance/expected-failures.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index abfb3751d3..25eb477060 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -49,10 +49,6 @@ client: - auth/offline-access-not-supported # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- - # SEP-2350 (scope step-up): WARNING-only — client does not compute the union of - # previously requested and newly challenged scopes on re-authorization; the - # expected-failures evaluator counts WARNINGs as failures. - - auth/scope-step-up # SEP-990 (enterprise-managed authorization extension): no fixture handler / # client support for the token-exchange + JWT bearer flow. - auth/enterprise-managed-authorization From ff752ef8663b1a4ed5c57248f2c7152ecdb577ef Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Thu, 25 Jun 2026 23:41:00 +0200 Subject: [PATCH 7/7] chore(codemod): update generated package versions --- packages/codemod/src/generated/versions.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a87..196a367508 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' };