Skip to content

SSE client: duplicate Authorization header when using eventSourceInit.fetch + requestInit.headers #1872

@carrotRakko

Description

@carrotRakko

Description

When using SSEClientTransport with both requestInit.headers and a custom eventSourceInit.fetch that wraps headers, the Authorization header sent to the server contains a comma-separated duplicate value (e.g. Bearer X, Bearer X). Servers that strictly parse the Bearer token format reject this with 401 Unauthorized.

Reproduction

import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

const headers = { Authorization: "Bearer my-token" };

function buildSseEventSourceFetch(headers: Record<string, string>) {
  return (url: string | URL, init?: RequestInit) => {
    const sdkHeaders: Record<string, string> = {};
    if (init?.headers) {
      if (init.headers instanceof Headers) {
        init.headers.forEach((value, key) => {
          sdkHeaders[key] = value;
        });
      } else {
        Object.assign(sdkHeaders, init.headers);
      }
    }
    return fetch(url, {
      ...init,
      headers: { ...sdkHeaders, ...headers },
    });
  };
}

const transport = new SSEClientTransport(new URL("https://mcp.example.com/sse"), {
  requestInit: { headers },
  eventSourceInit: { fetch: buildSseEventSourceFetch(headers) },
});

This pattern was a workaround for an older SDK version where _commonHeaders() did not include requestInit.headers. After the fix in #436 / #318 the workaround is no longer needed but is still in use in real codebases.

Expected behavior

The server receives a single Authorization: Bearer my-token header.

Actual behavior

The server receives Authorization: Bearer my-token, Bearer my-token (duplicate values comma-joined).

Verified with Node.js 22:

const headers = { authorization: 'Bearer abc', Authorization: 'Bearer abc' };
const req = new Request('https://example.com', { headers });
for (const [k, v] of req.headers.entries()) console.log(k, ':', v);
// authorization : Bearer abc, Bearer abc

Root cause

The interaction of three things:

  1. _commonHeaders() (src/client/sse.ts) returns a Headers instance which lowercases all keys (authorization).
  2. _startOrAuth() overrides the user's eventSourceInit.fetch with an internal wrapper that calls fetchImpl(url, { ...init, headers: <Headers instance> }).
  3. The user's custom fetch (buildSseEventSourceFetch above) iterates the Headers instance into a plain object (now with the lowercase key authorization), then merges its own closure headers (with the original capitalization Authorization). The result is a plain object with both authorization and Authorization as separate keys with the same value.

When that plain object is passed to fetch/Headers, the duplicate keys are merged into a single comma-separated value per HTTP/1.1 spec, producing Bearer X, Bearer X.

Real-world impact

Discovered while connecting to Notion's MCP server (https://mcp.notion.com/sse) which uses OAuth 2.1 with strict Bearer token parsing. The SDK appears to authenticate but every request receives 401. curl with the same token succeeds because curl doesn't produce duplicate headers.

The same pattern likely exists in other downstream projects that adopted the eventSourceInit.fetch workaround before the SDK was fixed.

Possible fixes

  1. Document that eventSourceInit.fetch is no longer necessary post-add custom headers on initial _startOrAuth call #318 / SSE client is not adding headers from requestInit? in _commonHeaders method #436 fix, and recommend using only requestInit.headers.
  2. Make the SDK's internal wrapper preserve the user's custom fetch behavior rather than passing a Headers instance that round-trips through user code.
  3. Add a warning when both requestInit.headers and eventSourceInit.fetch are provided.

Workaround for users

Either:

  • Drop eventSourceInit.fetch entirely and use only requestInit.headers
  • In your custom fetch, use new Headers(init?.headers) and .set() (which is case-insensitive) instead of plain object merging

Environment

  • @modelcontextprotocol/sdk v1.28.0 / v1.29.0 (same code path)
  • Node.js 22
  • eventsource v3.0.7

Related

✍️ Author: Claude Code with @carrotRakko (AI-written, human-approved)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions