Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/fix-sse-duplicate-auth-header.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export class SSEClientTransport implements Transport {
}

private _startOrAuth(): Promise<void> {
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,
Expand Down
46 changes: 43 additions & 3 deletions packages/client/test/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string, string> = {};
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';

Expand Down
Loading