Skip to main content

Draft GitHub issue for openclaw/openclaw

Instructions: This is a paste-ready issue draft. File at https://github.com/openclaw/openclaw/issues/new — title goes in the title field, body goes in the body field. Tag with bug, mcp, sse-transport if those labels exist.


Title​

bundle-mcp SSE transport: case-distinct duplicate authorization headers cause 401 against compliant MCP servers

Body​

Summary​

When OpenClaw connects to a remote MCP server via the SSE transport, the helper buildSseEventSourceFetch in src/.../content-blocks-k-DyCOGS.js merges user-supplied headers over the SDK-supplied headers using a plain JavaScript object spread:

return fetchWithUndici(url, {
...init,
headers: { ...sdkHeaders, ...headers }
});

sdkHeaders is built by iterating a Headers instance returned by SSEClientTransport._commonHeaders(). Per the Fetch / WHATWG spec, iterating a Headers instance yields lowercase keys, so sdkHeaders ends up with authorization as the key.

If the user's mcp.servers[name].headers config uses the conventional capital-A spelling Authorization, the spread produces an object with two distinct keys that point to the same bearer value:

{
accept: "text/event-stream",
authorization: "Bearer eyJ...", // from sdkHeaders (lowercased)
Authorization: "Bearer eyJ..." // from user config (capital)
}

When this plain object is then passed to undici.fetch() as a HeadersInit, undici constructs a Headers list using append semantics — and per the Fetch spec, Headers.append comma-joins duplicate values rather than replacing them. The wire ends up looking like:

Authorization: Bearer eyJ..., Bearer eyJ...

Standards-compliant bearer-token parsers (including Go's net/http middleware ecosystem) reject this as malformed and return 401, even though the underlying token is valid.

Reproduction​

  1. Run any MCP server that requires Authorization: Bearer <jwt> (we hit this against our own server helmdeck at /api/v1/mcp/sse, but any compliant server should reproduce — Heptabase, Linear, etc).
  2. Register it in OpenClaw with the conventional capital-A header key:
    openclaw mcp set example '{
    "url": "https://example.com/mcp/sse",
    "headers": {"Authorization": "Bearer <jwt>"}
    }'
  3. Drive an agent prompt that requires the MCP server. OpenClaw logs:
    [bundle-mcp] failed to start server "example": Error: SSE error: Non-200 status code (401)
  4. Server-side logs show a single GET to the SSE endpoint with status 401, despite the bearer being correct.

Root cause​

buildSseEventSourceFetch in content-blocks-k-DyCOGS.js:

function buildSseEventSourceFetch(headers) {
return (url, init) => {
const sdkHeaders = {};
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => { sdkHeaders[key] = value; });
// ↑ key is always lowercase per Headers spec
} else {
Object.assign(sdkHeaders, init.headers);
}
}
return fetchWithUndici(url, {
...init,
headers: { ...sdkHeaders, ...headers } // ← case-distinct duplicates survive
});
};
}

Plain JS objects are case-sensitive on keys; Headers is case-insensitive. Mixing them via spread without normalization preserves both spellings, then delegates the dedup decision to undici, which (correctly per spec) appends rather than replaces.

Workaround​

Use lowercase authorization as the key in the user config:

openclaw mcp set example '{
"url": "https://example.com/mcp/sse",
"headers": {"authorization": "Bearer <jwt>"}
}'

This makes the spread collapse to a single authorization entry. Confirmed working against helmdeck v0.6.0 — tools/list succeeds and returns the full catalog.

Proposed fix​

Construct a Headers instance and use .set() (case-insensitive replace) instead of plain-object spread:

function buildSseEventSourceFetch(headers) {
return (url, init) => {
const merged = new Headers(init?.headers ?? {});
for (const [k, v] of Object.entries(headers)) merged.set(k, v);
return fetchWithUndici(url, { ...init, headers: merged });
};
}

Headers.set() is case-insensitive and replaces, so this works regardless of whether the user wrote Authorization, authorization, or AUTHORIZATION. Same fix should apply to any other place in bundle-mcp that merges headers this way (e.g. the streamable-http path if it has the same pattern).

Versions​

  • OpenClaw: 2026.4.10 (CLI banner version, image tag openclaw:local built via ./scripts/docker/setup.sh)
  • @modelcontextprotocol/sdk: 1.29.0 (path: /app/node_modules/@modelcontextprotocol/sdk)
  • eventsource: 3.0.7 (path: /app/node_modules/eventsource)
  • undici: bundled at whatever version Node 24 ships
  • Node: 24.x (per OpenClaw's image)

The MCP TypeScript SDK has a separate but related bug that drops requestInit.headers from the SSE GET handshake — see modelcontextprotocol/typescript-sdk#436. OpenClaw's buildSseEventSourceFetch is the documented workaround for #436; this issue is a bug in the workaround.

Attribution​

Discovered while validating helmdeck (https://github.com/tosin2013/helmdeck) as a sidecar MCP server for OpenClaw. Full investigation log: https://github.com/tosin2013/helmdeck/blob/main/docs/integrations/openclaw-sidecar-research.md