diff --git a/.changeset/fix-sse-duplicate-auth-header.md b/.changeset/fix-sse-duplicate-auth-header.md new file mode 100644 index 000000000..ebf768c81 --- /dev/null +++ b/.changeset/fix-sse-duplicate-auth-header.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix SSEClientTransport duplicate Authorization header when both `requestInit.headers` and `eventSourceInit.fetch` set it. The SDK's internal SSE fetch wrapper now uses `opts.fetch` (or global `fetch`) as the underlying transport instead of `eventSourceInit.fetch`, preventing header duplication caused by the user's fetch iterating the SDK-supplied `Headers` instance into a plain object and re-adding the same key with different casing. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index f441e9cdb..a3465d6bd 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -119,7 +119,7 @@ export class SSEClientTransport implements Transport { } private _startOrAuth(): Promise { - const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch; + const fetchImpl = (this._fetch ?? fetch) as typeof fetch; return new Promise((resolve, reject) => { this._eventSource = new EventSource(this._url.href, { ...this._eventSourceInit, diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index b0b9588f0..3b3e7ebe5 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -257,10 +257,10 @@ describe('SSEClientTransport', () => { return fetch(url.toString(), { ...init, headers }); }; + // NOTE: eventSourceInit.fetch is no longer called inside the SDK's internal SSE + // wrapper; use the top-level `fetch` option to customize network behavior instead. transport = new SSEClientTransport(resourceBaseUrl, { - eventSourceInit: { - fetch: fetchWithAuth - } + fetch: fetchWithAuth }); await transport.start(); @@ -269,6 +269,46 @@ describe('SSEClientTransport', () => { expect(lastServerRequest.headers.authorization).toBe(authToken); }); + it('does not duplicate Authorization header when requestInit.headers and eventSourceInit.fetch both set it', async () => { + // Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1872 + // + // Before the fix, eventSourceInit.fetch was used as `fetchImpl` inside the SDK's + // internal SSE wrapper. The wrapper passed a `Headers` instance (which lowercases + // keys) to fetchImpl. If the user's fetch iterated those headers into a plain object + // and then merged its own closure headers (with original casing), the result had both + // `authorization` and `Authorization` as separate keys. new Headers({ authorization: + // X, Authorization: X }) joins them as "X, X" per the HTTP multi-value spec. + const token = 'Bearer my-token'; + const closureHeaders = { Authorization: token }; + + // Simulates the common user pattern that caused the bug: + // spread SDK's `Headers` (lowercase keys) then overlay plain-object closure headers. + const wrappingFetch = (url: string | URL, init?: RequestInit) => { + const sdkHeaders: Record = {}; + if (init?.headers instanceof Headers) { + init.headers.forEach((value, key) => { + sdkHeaders[key] = value; + }); + } else if (init?.headers) { + Object.assign(sdkHeaders, init.headers); + } + return fetch(url.toString(), { + ...init, + headers: { ...sdkHeaders, ...closureHeaders } + }); + }; + + transport = new SSEClientTransport(resourceBaseUrl, { + requestInit: { headers: { Authorization: token } }, + eventSourceInit: { fetch: wrappingFetch } + }); + + await transport.start(); + + // Must be a single value, not "Bearer my-token, Bearer my-token" + expect(lastServerRequest.headers.authorization).toBe(token); + }); + it('uses custom fetch implementation from options', async () => { const authToken = 'Bearer custom-token';