Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions .changeset/fix-scope-overwrite-infinite-reauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@modelcontextprotocol/client': patch
---

Fix: accumulate OAuth scopes on 401/403 instead of overwriting

When an HTTP transport receives a 401 or 403 with a `WWW-Authenticate` header containing new scopes, the scopes are now merged with any previously-acquired scopes rather than replacing them. The previous behaviour could cause an infinite re-auth loop where the client repeatedly
lost its original scopes each time it attempted to upscope.
Comment thread
claude[bot] marked this conversation as resolved.
13 changes: 10 additions & 3 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import {
createFetchWithInit,
JSONRPCMessageSchema,
mergeScopes,
normalizeHeaders,
SdkError,
SdkErrorCode
} from '@modelcontextprotocol/core';
import type { ErrorEvent, EventSourceInit } from 'eventsource';
import { EventSource } from 'eventsource';

Expand Down Expand Up @@ -136,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 = mergeScopes(this._scope, scope);
}
}

Expand Down Expand Up @@ -271,7 +278,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 = mergeScopes(this._scope, scope);
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Expand Down
7 changes: 4 additions & 3 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isJSONRPCRequest,
isJSONRPCResultResponse,
JSONRPCMessageSchema,
mergeScopes,
normalizeHeaders,
SdkError,
SdkErrorCode
Expand Down Expand Up @@ -222,7 +223,7 @@ export class StreamableHTTPClientTransport implements Transport {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._scope = mergeScopes(this._scope, scope);
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Expand Down Expand Up @@ -515,7 +516,7 @@ export class StreamableHTTPClientTransport implements Transport {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
this._scope = mergeScopes(this._scope, scope);
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Comment thread
rechedev9 marked this conversation as resolved.
Expand Down Expand Up @@ -554,7 +555,7 @@ export class StreamableHTTPClientTransport implements Transport {
}

if (scope) {
this._scope = scope;
this._scope = mergeScopes(this._scope, scope);
}

if (resourceMetadataUrl) {
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
81 changes: 81 additions & 0 deletions packages/client/test/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,87 @@ describe('SSEClientTransport', () => {
await expect(() => transport.start()).rejects.toMatchObject(expectedError);
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
});

it('accumulates scopes from sequential 401 responses in send()', async () => {
// Create server that accepts SSE connection but returns 401 on POST
// with different scopes on successive requests
resourceServer.close();

let postCallCount = 0;
resourceServer = createServer((req, res) => {
lastServerRequest = req;

if (req.method === 'GET') {
if (req.url !== '/') {
res.writeHead(404).end();
return;
}

res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
});
res.write('event: endpoint\n');
res.write(`data: ${resourceBaseUrl.href}\n\n`);
return;
}

if (req.method === 'POST') {
postCallCount++;
if (postCallCount === 1) {
// First POST: 401 with scope="read:op1"
res.writeHead(401, {
'WWW-Authenticate': 'Bearer scope="read:op1"'
});
res.end();
} else if (postCallCount === 2) {
// Second POST: 401 with scope="read:op2"
res.writeHead(401, {
'WWW-Authenticate': 'Bearer scope="read:op2"'
});
res.end();
} else {
res.writeHead(200);
res.end();
}
}
});

resourceBaseUrl = await listenOnRandomPort(resourceServer);

// Use a minimal AuthProvider (not OAuthClientProvider) so onUnauthorized
// is not set and 401 throws UnauthorizedError, letting us inspect _scope.
const minimalAuthProvider: AuthProvider = {
token: vi.fn().mockResolvedValue('test-token')
};

transport = new SSEClientTransport(resourceBaseUrl, {
authProvider: minimalAuthProvider
});

await transport.start();

const message: JSONRPCMessage = {
jsonrpc: '2.0',
id: '1',
method: 'test',
params: {}
};

// First send: 401 with scope="read:op1" — throws UnauthorizedError
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect((transport as unknown as { _scope: string | undefined })['_scope']).toBe('read:op1');

// Second send: 401 with scope="read:op2" — scope should accumulate
await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);

// Verify _scope has accumulated both tokens
const finalScope = (transport as unknown as { _scope: string | undefined })['_scope'];
expect(finalScope).toBeDefined();
const finalScopeTokens = String(finalScope).split(' ').toSorted();
expect(finalScopeTokens).toEqual(['read:op1', 'read:op2']);
});
});

describe('custom fetch in auth code paths', () => {
Expand Down
198 changes: 197 additions & 1 deletion packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core';
import { OAuthError, OAuthErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';
import type { Mock, Mocked } from 'vitest';
import type { Mock, Mocked, MockInstance } from 'vitest';

import type { OAuthClientProvider } from '../../src/client/auth.js';
import { UnauthorizedError } from '../../src/client/auth.js';
Expand Down Expand Up @@ -782,6 +782,202 @@ describe('StreamableHTTPClientTransport', () => {
authSpy.mockRestore();
});

describe('mergeScopes behavior (via transport)', () => {
const testMessage: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
};

const getScope = () => (transport as unknown as { _scope: string | undefined })._scope;
const setScope = (value: string) => {
(transport as unknown as { _scope: string | undefined })._scope = value;
};

/** Sorted scope tokens from a space-separated scope string. */
const sortedTokens = (scope: string | undefined) => String(scope).split(' ').toSorted();

let authSpy: MockInstance;

beforeEach(async () => {
const authModule = await import('../../src/client/auth.js');
authSpy = vi.spyOn(authModule, 'auth');
authSpy.mockResolvedValue('AUTHORIZED');
});

afterEach(() => {
authSpy.mockRestore();
});

it('accumulates scopes from sequential 403 responses with different scopes', async () => {
const fetchMock = globalThis.fetch as Mock;
fetchMock
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="read:op1"'
}),
text: () => Promise.resolve('Insufficient scope')
})
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="read:op2"'
}),
text: () => Promise.resolve('Insufficient scope')
})
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});

await transport.send(testMessage);

expect(authSpy).toHaveBeenCalledTimes(2);
expect(authSpy).toHaveBeenNthCalledWith(1, expect.anything(), expect.objectContaining({ scope: 'read:op1' }));
expect(authSpy).toHaveBeenNthCalledWith(
2,
expect.anything(),
expect.objectContaining({
scope: expect.stringContaining('read:op1')
})
);
expect(sortedTokens(getScope())).toEqual(['read:op1', 'read:op2']);
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it('deduplicates repeated scope tokens during accumulation', async () => {
setScope('read:op1 read:op2');

const fetchMock = globalThis.fetch as Mock;
fetchMock
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="read:op2"'
}),
text: () => Promise.resolve('Insufficient scope')
})
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});

await transport.send(testMessage);

expect(authSpy).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({
scope: expect.stringContaining('read:op1')
})
);
expect(sortedTokens(getScope())).toEqual(['read:op1', 'read:op2']);
});

it('preserves existing scope when 401 has no scope in WWW-Authenticate', async () => {
setScope('read:op1');

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers({ 'WWW-Authenticate': 'Bearer' }),
text: () => Promise.resolve('Unauthorized')
});

await expect(transport.send(testMessage)).rejects.toThrow(UnauthorizedError);
expect(getScope()).toBe('read:op1');
});

it('returns undefined scope when both existing and incoming are undefined', async () => {
(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers({ 'WWW-Authenticate': 'Bearer' }),
text: () => Promise.resolve('Unauthorized')
});

await expect(transport.send(testMessage)).rejects.toThrow(UnauthorizedError);
expect(getScope()).toBeUndefined();
});

it('sets scope from incoming when existing is undefined', async () => {
(globalThis.fetch as Mock)
.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="read:op1"'
}),
text: () => Promise.resolve('Insufficient scope')
})
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers()
});

await transport.send(testMessage);
expect(getScope()).toBe('read:op1');
});

it('does not lose existing scope when 401 has no scope parameter', async () => {
setScope('read:op1');

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers({ 'WWW-Authenticate': 'Bearer realm="example"' }),
text: () => Promise.resolve('Unauthorized')
});

await expect(transport.send(testMessage)).rejects.toThrow(UnauthorizedError);
expect(getScope()).toBe('read:op1');
});
});

it('circuit-breaker fires on repeated 403 with same WWW-Authenticate header', async () => {
const fetchMock = globalThis.fetch as Mock;
fetchMock.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers({
'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="read:op1"'
}),
text: () => Promise.resolve('Insufficient scope')
});

const authModule = await import('../../src/client/auth.js');
const authSpy = vi.spyOn(authModule, 'auth');
authSpy.mockResolvedValue('AUTHORIZED');

await expect(
transport.send({
jsonrpc: '2.0',
method: 'test',
params: {},
id: 'test-id'
})
).rejects.toThrow('Server returned 403 after trying upscoping');

expect(authSpy).toHaveBeenCalledTimes(1);
authSpy.mockRestore();
});

Comment thread
claude[bot] marked this conversation as resolved.
describe('Reconnection Logic', () => {
let transport: StreamableHTTPClientTransport;

Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/shared/authUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,18 @@ export function checkResourceAllowed({

return requestedPath.startsWith(configuredPath);
}

/**
* Merges two space-separated OAuth scope strings into a deduplicated union.
* Returns undefined when the resulting set is empty.
* Preserves insertion order of first occurrence for determinism.
*/
export function mergeScopes(existing: string | undefined, incoming: string | undefined): string | undefined {
const existingTokens = existing?.split(/\s+/).filter(Boolean) ?? [];
const incomingTokens = incoming?.split(/\s+/).filter(Boolean) ?? [];
const merged = new Set<string>([...existingTokens, ...incomingTokens]);
if (merged.size === 0) {
return undefined;
}
return [...merged].join(' ');
}
Loading
Loading