Skip to content

fix: prevent malformed dual Content-Type on direct-transport token requests#1433

Open
buptliuhs wants to merge 1 commit into
modelcontextprotocol:mainfrom
buptliuhs:fix-dual-content-types
Open

fix: prevent malformed dual Content-Type on direct-transport token requests#1433
buptliuhs wants to merge 1 commit into
modelcontextprotocol:mainfrom
buptliuhs:fix-dual-content-types

Conversation

@buptliuhs
Copy link
Copy Markdown

@buptliuhs buptliuhs commented Jun 4, 2026

Summary

Direct (non-proxy) SSE and Streamable HTTP connections send a malformed dual Content-Type on the OAuth token request:

Content-Type: application/json, application/x-www-form-urlencoded

Strict authorization servers can't body-parse this, so token exchange and refresh fail (e.g. Keycloak: "Failed to parse media type..."). It surfaces on token refresh once an access token expires.

Root cause

For direct connections the Inspector passes a requestInit to the transport. The SDK wraps it with createFetchWithInit, which merges the base requestInit.headers with each request's own headers using a case-sensitive object spread.

The Inspector was injecting a capital Content-Type (and Accept) into requestInit. On the token request the SDK builds its headers with new Headers(...), which normalizes the key to lowercase content-type. Because the merge is case-sensitive, the capital Content-Type and the lowercase content-type both survive, and fetch then comma-joins them into the malformed value above.

Fix

Stop contributing content-type/accept via requestInit, and simplify the direct-transport custom fetch to a pass-through fetch(url, init). This is safe and complete because the SDK already sets those headers itself per request:

  • MCP POST and the SSE GET stream — headers.set('content-type'/'accept', ...) on a Headers object (case-insensitive)
  • the token request — sets its own Content-Type

Auth/custom headers (e.g. Authorization) still reach every request through the SDK's _commonHeaders(), which folds in requestInit.headers. The change also removes a pre-existing inconsistency where the SSE wrapper let the base headers clobber per-request headers while the Streamable HTTP wrapper did the opposite.

Tests

Added oauthHeaderMerge.test.ts, which drives the real SDK createFetchWithInit with the token request's exact shape:

  • single Content-Type on the token request when requestInit carries only auth headers (the fix)
  • a case demonstrating that leaving a Content-Type in requestInit re-introduces the dual header (the bug)

All existing useConnection tests (46) still pass; lint and prettier clean.

Also verified end-to-end against a live OAuth-protected MCP server over an ngrok tunnel: the token request now goes out with a single Content-Type: application/x-www-form-urlencoded, and token exchange/refresh succeed.

Scope

Affects direct SSE / Streamable HTTP connections only (proxy connections were unaffected).

…quests

Direct SSE / Streamable HTTP connections pass a `requestInit` to the
transport, which the SDK wraps with `createFetchWithInit`. That wrapper
merges the base `requestInit.headers` with each request's own headers
using a case-sensitive object spread.

The Inspector injected a capital `Content-Type` (and `Accept`) into
`requestInit`. On the OAuth token request the SDK builds its headers with
`new Headers(...)`, which normalizes the key to lowercase `content-type`.
The two case-variants both survived the merge and `fetch` comma-joined
them into `Content-Type: application/json, application/x-www-form-urlencoded`,
which strict authorization servers cannot body-parse — breaking token
exchange and refresh.

Stop contributing `content-type`/`accept` via `requestInit` and simplify
the custom fetch to a pass-through. The SDK already sets these headers
itself per request, and auth/custom headers still reach every request
through the SDK's `_commonHeaders()` base. This also removes a
pre-existing inconsistency between the two branches' fetch wrappers.

Add tests that drive the real SDK merge to assert a single Content-Type
on the token request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@buptliuhs buptliuhs force-pushed the fix-dual-content-types branch from a586291 to e4285ae Compare June 4, 2026 23:18
@buptliuhs buptliuhs changed the title fix: prevent malformed dual Content-Type on direct-transport OAuth requests fix: prevent malformed dual Content-Type on direct-transport token requests Jun 4, 2026
@buptliuhs
Copy link
Copy Markdown
Author

@cliffhall can you please review? thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant