fix(mcp): lowercase SSE event-source header keys to prevent duplicate Authorization (401)

Co-authored-by: infocus13 <74890563+infocus13@users.noreply.github.com>

Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
clawsweeper
2026-06-09 22:19:53 +00:00
parent 9833f3ea9b
commit 0656d5d25f
2 changed files with 68 additions and 2 deletions
+60 -1
View File
@@ -8,10 +8,16 @@ type StreamableTransportOptions = {
authProvider?: unknown;
};
const { lookupMock, runtimeFetchMock, streamableTransportConstructorMock } = vi.hoisted(() => ({
const {
lookupMock,
runtimeFetchMock,
streamableTransportConstructorMock,
sseTransportConstructorMock,
} = vi.hoisted(() => ({
lookupMock: vi.fn(),
runtimeFetchMock: vi.fn(),
streamableTransportConstructorMock: vi.fn(),
sseTransportConstructorMock: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
@@ -37,6 +43,20 @@ vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
},
}));
type SseTransportOptions = {
eventSourceInit?: { fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> };
};
vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: function MockSSEClientTransport(
this: unknown,
url: URL,
options?: SseTransportOptions,
) {
sseTransportConstructorMock(url, options);
},
}));
function redirectResponse(location: string, status = 302): Response {
return new Response(null, {
status,
@@ -69,6 +89,18 @@ function latestStreamableFetch() {
return fetch;
}
function latestSseEventSourceFetch() {
const latestCall = sseTransportConstructorMock.mock.calls[
sseTransportConstructorMock.mock.calls.length - 1
] as unknown[] | undefined;
const options = latestCall?.[1] as SseTransportOptions | undefined;
const fetch = options?.eventSourceInit?.fetch;
if (typeof fetch !== "function") {
throw new Error("Expected SSE event-source fetch");
}
return fetch;
}
function runtimeFetchCall(index: number): [RequestInfo | URL, RequestInit | undefined] {
const call = runtimeFetchMock.mock.calls[index] as
| [RequestInfo | URL, RequestInit | undefined]
@@ -85,6 +117,7 @@ describe("resolveMcpTransport", () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
runtimeFetchMock.mockReset();
streamableTransportConstructorMock.mockClear();
sseTransportConstructorMock.mockClear();
});
it("scrubs custom headers when streamable HTTP follows a cross-origin redirect", async () => {
@@ -299,4 +332,30 @@ describe("resolveMcpTransport", () => {
expect(new Headers(runtimeFetchCall(0)?.[1]?.headers).get("x-tenant")).toBe("docs");
expect(new Headers(runtimeFetchCall(1)?.[1]?.headers).get("x-tenant")).toBeNull();
});
it("merges SSE event-source headers case-insensitively so auth is not duplicated", async () => {
// The SDK's EventSource can supply lowercase `authorization` while operator
// config uses `Authorization`; the runtime fetch should see one header.
runtimeFetchMock.mockResolvedValue(new Response("ok"));
resolveMcpTransport("probe", {
url: "https://mcp.example.com/sse",
transport: "sse",
headers: {
Authorization: "Bearer operator",
},
});
const sseFetch = latestSseEventSourceFetch();
await sseFetch("https://mcp.example.com/sse", {
headers: { authorization: "Bearer sdk" },
});
const sentHeaders = runtimeFetchCall(0)?.[1]?.headers as Record<string, string>;
const authKeys = Object.keys(sentHeaders).filter(
(key) => key.toLowerCase() === "authorization",
);
expect(authKeys).toEqual(["authorization"]);
expect(sentHeaders.authorization).toBe("Bearer operator");
});
});
+8 -1
View File
@@ -78,9 +78,16 @@ function buildSseEventSourceFetch(
Object.assign(sdkHeaders, init.headers);
}
}
// Header names are case-insensitive, but object spreads preserve case
// variants and can duplicate Authorization on the wire. Normalize before
// merging so operator headers override SDK headers as a single entry.
const mergedHeaders: Record<string, string> = {};
for (const [key, value] of [...Object.entries(sdkHeaders), ...Object.entries(headers)]) {
mergedHeaders[key.toLowerCase()] = value;
}
return baseFetch(url, {
...(init as RequestInit),
headers: { ...sdkHeaders, ...headers },
headers: mergedHeaders,
}) as ReturnType<SseEventSourceFetch>;
};
}