diff --git a/.changeset/fix-streamable-http-get-401-resource-metadata.md b/.changeset/fix-streamable-http-get-401-resource-metadata.md new file mode 100644 index 0000000000..162859ab2d --- /dev/null +++ b/.changeset/fix-streamable-http-get-401-resource-metadata.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Read `resource_metadata` and `scope` from the `WWW-Authenticate` header when the Streamable HTTP client's GET SSE request gets a 401, before starting authorization. Previously only the POST path did this, so when the first 401 came from the GET path (`resumeStream()`, `send()` +with a `resumptionToken`, the standalone stream opened after `notifications/initialized`, SSE reconnects), discovery ran without the advertised metadata URL and could fall back to the MCP server's origin as the authorization server. Matches `SSEClientTransport` and the v2 change +in #1710. Fixes #1450. diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 8512418b7d..e7891a763e 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -264,6 +264,11 @@ export class StreamableHTTPClientTransport implements Transport { if (response.status === 401 && this._authProvider) { // Need to authenticate + if (response.headers.has('www-authenticate')) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; + } return await this._authThenStart(); } diff --git a/test/client/streamableHttp.test.ts b/test/client/streamableHttp.test.ts index 3c7a2ff964..02ca11eaea 100644 --- a/test/client/streamableHttp.test.ts +++ b/test/client/streamableHttp.test.ts @@ -623,6 +623,44 @@ describe('StreamableHTTPClientTransport', () => { expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); + it('passes resource_metadata from WWW-Authenticate to auth on 401 during GET SSE request', async () => { + const fetchMock = global.fetch as Mock; + fetchMock + // First call: GET returns 401 with resource_metadata + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer resource_metadata="http://example.com/resource", scope="new_scope"' + }) + }) + // Second call: GET after auth, server has no standalone SSE stream + .mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: 'Method Not Allowed', + headers: new Headers() + }); + + const authModule = await import('../../src/client/auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.resumeStream('last-event-id'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'new_scope', + resourceMetadataUrl: new URL('http://example.com/resource') + }) + ); + + authSpy.mockRestore(); + }); + it('attempts upscoping on 403 with WWW-Authenticate header', async () => { const message: JSONRPCMessage = { jsonrpc: '2.0',