HTTP MCP transport: connectivity preflight omits configured headers, treats resulting 401 as fatal
Summary
When adding a Streamable-HTTP MCP server with a static Authorization header via claude mcp add --transport http <name> <url> --header "Authorization: Bearer <token>", the connection fails even though the token is valid and correctly stored. Debug logging shows the client performs a separate low-level "basic HTTP connectivity" preflight request that does not include the configured headers, and treats the resulting 401 as a fatal connection failure — it never proceeds to attempt the real, authenticated MCP handshake.
This makes it impossible to connect the CLI to any spec-compliant Streamable-HTTP MCP server that correctly requires auth on every request (per the MCP Authorization spec, 2025-06-18 — returning 401 + WWW-Authenticate to an unauthenticated request is the expected/correct behavior).
Environment
- Claude Code version: 2.1.220 (confirmed latest via
claude update) - Platform: macOS (darwin), Node v26.3.0
- Transport:
http, added with--header "Authorization: Bearer ..."
Steps to reproduce
- Stand up (or use) a Streamable-HTTP MCP server that returns
401 {"error": "unauthorized", "detail": "missing bearer token"}(with aWWW-Authenticate: Bearer resource_metadata="..."header) for any request lacking a validAuthorization: Bearer <token>header, and returns normal MCP responses for a request that has one. claude mcp add --transport http my-server https://example.internal/mcp --header "Authorization: Bearer <valid-token>"claude mcp list
Observed
claude mcp list reports:
my-server: https://example.internal/mcp (HTTP) - ✘ Failed to connect — Server rejected the configured Authorization header (HTTP 401). Check that the token is valid for this MCP endpoint — OAuth fallback is disabled when headers.Authorization is set. Error detail: Streamable HTTP error: Error POSTing to endpoint: {"error": "unauthorized", "detail": "missing bearer token"}
Running with claude --debug=mcp --debug-file /tmp/debug.log mcp list shows the header genuinely is configured (non-empty, just redacted in the log), and that the request which fails is a distinct preflight step, not the real handshake attempt:
MCP server "my-server": HTTP transport options: {"url":"https://example.internal/mcp","headers":{"User-Agent":"claude-code/2.1.220 (claude-vscode, agent-sdk/0.3.220)","Accept-Encoding":"identity","Authorization":"[REDACTED]"},"hasAuthProvider":false,"timeoutMs":60000}
MCP server "my-server": HTTP transport created successfully
MCP server "my-server": Client created, setting up request handler
MCP server "my-server": Starting connection with timeout of 30000ms
MCP server "my-server": Testing basic HTTP connectivity to https://example.internal/mcp
MCP server "my-server": Parsed URL: host=example.internal, port=default, protocol=https:
MCP server "my-server": HTTP Connection failed after 95ms: Streamable HTTP error: Error POSTing to endpoint: {"error": "unauthorized", "detail": "missing bearer token"} (code: 401, errno: none)
I independently confirmed the server itself is behaving correctly: curl-ing the same URL with -H "Authorization: Bearer <valid-token>" succeeds, and with a deliberately wrong token returns a different error (unknown token vs missing bearer token), proving the server does parse and honor the header when it's actually sent. The CLI's own "Testing basic HTTP connectivity" step is the one making the unauthenticated request.
Expected
Either:
- The "basic HTTP connectivity" preflight should include the configured headers (so it isn't rejected by a server that legitimately requires auth on every request), or
- A 401 from that preflight should not be treated as a fatal, unrecoverable connection failure — the client should proceed to the real authenticated handshake (which does have the right headers) rather than aborting first.
Impact
Blocks connecting Claude Code to any correctly-implemented, auth-gated Streamable-HTTP MCP server using the static bearer-token (--header) configuration path. (The OAuth discovery path, which is designed around expecting an initial 401, may not hit this same code path — untested here since our server's OAuth mode isn't fully wired up yet.)