modelcontextprotocol / modelcontextprotocol/typescript-sdk

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

Open
#1,872 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug fix proposed P2 ready for work
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

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-#318 / #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

  • #436 — earlier fix for _commonHeaders that addressed a different aspect of the same area
  • #318 — PR that fixed _startOrAuth to honor requestInit.headers

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read src/client/sse.ts, focusing on _commonHeaders() and _startOrAuth(), then run the provided Node.js 22 reproduction with both requestInit.headers and eventSourceInit.fetch. Determine how the wrapper should handle this combination so the server receives one Authorization value, while preserving the intended custom-fetch behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, networking
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.