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
5 changes: 5 additions & 0 deletions .changeset/sep-2350-scope-union-step-up.md
Original file line number Diff line number Diff line change
@@ -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`.
28 changes: 28 additions & 0 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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
10 changes: 8 additions & 2 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.js';
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js';
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError, unionScopes } from './auth.js';

// Default reconnection options for StreamableHTTP connections
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
Expand Down Expand Up @@ -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) {
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.js';
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.js';
import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js';
Expand Down Expand Up @@ -4131,3 +4132,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');
});
});
93 changes: 93 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelco
import type { Mock, Mocked } from 'vitest';

import type { OAuthClientProvider } from '../../src/client/auth.js';
import * as authModule from '../../src/client/auth.js';
import { UnauthorizedError } from '../../src/client/auth.js';
import type { ReconnectionScheduler, StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js';
Expand Down Expand Up @@ -876,6 +877,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;

Expand Down
Loading