From 17e13498c82c8ec36f26aad762f982e5f18f9c1a Mon Sep 17 00:00:00 2001 From: kimnamu Date: Mon, 5 Oct 2026 21:53:11 +0900 Subject: [PATCH] [v1.x] fix(client): use resource_metadata from WWW-Authenticate on GET SSE 401 StreamableHTTPClientTransport._startOrAuthSse() went straight to _authThenStart() on a 401, so auth() and a later finishAuth() ran discovery without the resource_metadata URL the server advertised. When protected resource metadata is only reachable through that URL, discovery falls back to the MCP server origin as the authorization server, and the authorization redirect and token request go there. Store resourceMetadataUrl and scope first, as send() and SSEClientTransport already do on v1.x and as main does since #1710. Fixes #1450 Co-Authored-By: Claude --- ...reamable-http-get-401-resource-metadata.md | 7 ++++ src/client/streamableHttp.ts | 5 +++ test/client/streamableHttp.test.ts | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 .changeset/fix-streamable-http-get-401-resource-metadata.md 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',