Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
9 changes: 9 additions & 0 deletions .changeset/fix-scope-overwrite-infinite-reauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/core': 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.
21 changes: 16 additions & 5 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 @@ -135,8 +142,10 @@ 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;
if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
}
this._scope = mergeScopes(this._scope, scope);
}
}

Expand Down Expand Up @@ -270,8 +279,10 @@ 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;
if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
}
this._scope = mergeScopes(this._scope, scope);
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Expand Down
15 changes: 10 additions & 5 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 @@ -221,8 +222,10 @@ 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;
if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
}
this._scope = mergeScopes(this._scope, scope);
}

if (this._authProvider.onUnauthorized && !isAuthRetry) {
Expand Down Expand Up @@ -514,8 +517,10 @@ 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;
if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
}
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 +559,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
154 changes: 154 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,160 @@ 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']);
});

it('preserves resource metadata URL across repeated 401 POST responses without resource_metadata', async () => {
resourceServer.close();

let postCallCount = 0;
const resourceMetadataUrl = 'http://example.com/.well-known/oauth-protected-resource';
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) {
res.writeHead(401, {
'WWW-Authenticate': `Bearer resource_metadata="${resourceMetadataUrl}", scope="read:op1"`
});
res.end();
} else if (postCallCount === 2) {
res.writeHead(401, {
'WWW-Authenticate': 'Bearer scope="read:op2"'
});
res.end();
} else {
res.writeHead(200);
res.end();
}
}
});

resourceBaseUrl = await listenOnRandomPort(resourceServer);

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: {}
};

await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect((transport as unknown as { _resourceMetadataUrl: URL | undefined })['_resourceMetadataUrl']).toEqual(
new URL(resourceMetadataUrl)
);

await expect(transport.send(message)).rejects.toThrow(UnauthorizedError);
expect((transport as unknown as { _resourceMetadataUrl: URL | undefined })['_resourceMetadataUrl']).toEqual(
new URL(resourceMetadataUrl)
);
});
});

describe('custom fetch in auth code paths', () => {
Expand Down
Loading
Loading