Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@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, protected resource metadata scopes, provider-configured default scopes, and the newly challenged scopes. Adds an exported `unionScopes` helper to `@modelcontextprotocol/client`.
36 changes: 34 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -598,6 +602,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>): string | undefined {
const seen = new Set<string>();
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,
{
Expand Down
40 changes: 25 additions & 15 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -142,8 +142,8 @@ export class SSEClientTransport implements Transport {
this._last401Response = response;
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
this._scope = unionScopes(this._scope, scope);
}
}
Comment thread
claude[bot] marked this conversation as resolved.

Expand All @@ -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();
Expand Down Expand Up @@ -277,15 +285,17 @@ 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._scope = scope;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
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
Expand Down
36 changes: 28 additions & 8 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -258,15 +258,17 @@ 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._scope = scope;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
this._scope = unionScopes(this._scope, scope);
}

Comment thread
claude[bot] marked this conversation as resolved.
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
Expand Down Expand Up @@ -567,15 +569,17 @@ 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._scope = scope;
this._resourceMetadataUrl = resourceMetadataUrl ?? this._resourceMetadataUrl;
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
Expand Down Expand Up @@ -609,7 +613,23 @@ 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, 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();
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
);
}
Comment thread
mattzcarey marked this conversation as resolved.

if (resourceMetadataUrl) {
Expand Down
1 change: 1 addition & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export {
selectResourceURL,
startAuthorization,
UnauthorizedError,
unionScopes,
validateClientMetadataUrl
} from './client/auth';
export type {
Expand Down
41 changes: 41 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
registerClient,
selectClientAuthMethod,
startAuthorization,
unionScopes,
validateClientMetadataUrl
} from '../../src/client/auth';
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions';
Expand Down Expand Up @@ -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');
});
});
33 changes: 32 additions & 1 deletion packages/client/test/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1531,12 +1531,14 @@ describe('SSEClientTransport', () => {

describe('minimal AuthProvider (non-OAuth)', () => {
let postResponses: number[];
let postHeaders: Record<string, string> | undefined;
let postCount: number;

async function setupServer(): Promise<void> {
await resourceServer.close();

postCount = 0;
postHeaders = undefined;
resourceServer = createServer((req, res) => {
lastServerRequest = req;

Expand All @@ -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;
}
});
Expand All @@ -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();
Expand Down
Loading
Loading