Skip to content

Commit 8499a4b

Browse files
fix: prevent duplicate Authorization header in SSE client
When both requestInit.headers and eventSourceInit.fetch provided the same Authorization header, the SDK's internal fetch wrapper passed a Headers instance (lowercase keys) to the user's custom fetch, which then merged it with its own closure headers (original case). The resulting plain object had both "authorization" and "Authorization" keys, producing "Bearer X, Bearer X" after Headers normalization. Fix: remove eventSourceInit.fetch from the fetchImpl resolution chain in _startOrAuth(). The SDK's wrapper already fully controls the fetch call to EventSource, so fetchImpl should be the raw transport (opts.fetch or global fetch), not the user's header-injecting wrapper. Closes #1872
1 parent b8886e7 commit 8499a4b

3 files changed

Lines changed: 53 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Fix duplicate Authorization header when using both `requestInit.headers` and `eventSourceInit.fetch`. The SDK's internal SSE fetch wrapper no longer delegates to `eventSourceInit.fetch`, preventing case-mismatch header duplication that caused `Bearer X, Bearer X` values and 401 rejections from strict servers.

packages/client/src/client/sse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export class SSEClientTransport implements Transport {
119119
}
120120

121121
private _startOrAuth(): Promise<void> {
122-
const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch;
122+
const fetchImpl = (this._fetch ?? fetch) as typeof fetch;
123123
return new Promise((resolve, reject) => {
124124
this._eventSource = new EventSource(this._url.href, {
125125
...this._eventSourceInit,

packages/client/test/client/sse.test.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -247,25 +247,61 @@ describe('SSEClientTransport', () => {
247247
});
248248

249249
describe('header handling', () => {
250-
it('uses custom fetch implementation from EventSourceInit to add auth headers', async () => {
251-
const authToken = 'Bearer test-token';
252-
253-
// Create a fetch wrapper that adds auth header
254-
const fetchWithAuth = (url: string | URL, init?: RequestInit) => {
255-
const headers = new Headers(init?.headers);
256-
headers.set('Authorization', authToken);
257-
return fetch(url.toString(), { ...init, headers });
258-
};
250+
it('ignores eventSourceInit.fetch for the internal SSE fetch wrapper', async () => {
251+
// eventSourceInit.fetch is no longer used as the HTTP transport inside the
252+
// SDK's internal SSE fetch wrapper (to avoid duplicate header injection).
253+
// Users should use the top-level 'fetch' option or requestInit.headers instead.
254+
const spyFetch = vi.fn(fetch);
259255

260256
transport = new SSEClientTransport(resourceBaseUrl, {
257+
requestInit: { headers: { Authorization: 'Bearer test-token' } },
261258
eventSourceInit: {
262-
fetch: fetchWithAuth
259+
fetch: spyFetch
263260
}
264261
});
265262

266263
await transport.start();
267264

268-
// Verify the auth header was received by the server
265+
// eventSourceInit.fetch should NOT be called as fetchImpl
266+
expect(spyFetch).not.toHaveBeenCalled();
267+
// The auth header from requestInit.headers should still reach the server
268+
expect(lastServerRequest.headers.authorization).toBe('Bearer test-token');
269+
});
270+
271+
it('does not duplicate Authorization header when both requestInit.headers and eventSourceInit.fetch provide it', async () => {
272+
const authToken = 'Bearer my-token';
273+
274+
// This pattern was common before the SDK fixed _commonHeaders to include requestInit.headers.
275+
// The user's custom fetch merges closure headers with init.headers using plain object spread,
276+
// which caused case-mismatch duplicates (authorization vs Authorization).
277+
function buildSseEventSourceFetch(closureHeaders: Record<string, string>) {
278+
return (url: string | URL, init?: RequestInit) => {
279+
const sdkHeaders: Record<string, string> = {};
280+
if (init?.headers) {
281+
if (init.headers instanceof Headers) {
282+
init.headers.forEach((value: string, key: string) => {
283+
sdkHeaders[key] = value;
284+
});
285+
} else {
286+
Object.assign(sdkHeaders, init.headers);
287+
}
288+
}
289+
return fetch(url.toString(), {
290+
...init,
291+
headers: { ...sdkHeaders, ...closureHeaders },
292+
});
293+
};
294+
}
295+
296+
const headers = { Authorization: authToken };
297+
transport = new SSEClientTransport(resourceBaseUrl, {
298+
requestInit: { headers },
299+
eventSourceInit: { fetch: buildSseEventSourceFetch(headers) },
300+
});
301+
302+
await transport.start();
303+
304+
// The server should receive exactly one Authorization value, not "Bearer my-token, Bearer my-token"
269305
expect(lastServerRequest.headers.authorization).toBe(authToken);
270306
});
271307

0 commit comments

Comments
 (0)