Skip to content

fix: don't corrupt refresh_token Content-Type on direct connections#1696

Open
justinedelson wants to merge 2 commits into
modelcontextprotocol:mainfrom
justinedelson:fix/oauth-refresh-content-type
Open

fix: don't corrupt refresh_token Content-Type on direct connections#1696
justinedelson wants to merge 2 commits into
modelcontextprotocol:mainfrom
justinedelson:fix/oauth-refresh-content-type

Conversation

@justinedelson

@justinedelson justinedelson commented Jul 16, 2026

Copy link
Copy Markdown

Summary

The initial OAuth token exchange (grant_type=authorization_code) correctly sends:

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

But the refresh exchange (grant_type=refresh_token) — and only that one — sends a malformed, comma-joined value:

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

That value fails RFC 9110 media-type syntax (it's two media types, not one). A strict server may reject it with 415 Unsupported Media Type before reaching any OAuth-specific handling — e.g. Fastify validates Content-Type syntax in the framework layer, ahead of any registered content-type parser. When that happens, the SDK's auth() helper receives a non-OAuthError failure from the refresh attempt and falls through to a full interactive re-authorization, forcing the user to log in again on every token expiry instead of refreshing silently.

Reproduced directly against a real server:

curl -X POST https://.../mcp-oauth/token \
  -H "Content-Type: application/json, application/x-www-form-urlencoded" \
  --data "grant_type=refresh_token&refresh_token=..."
→ HTTP 415 {"code":"FST_ERR_CTP_INVALID_MEDIA_TYPE",...}

Note: Inspector V2 is under development. This is a V1 bug fix for MCP spec compliance.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Refactoring (no functional changes)
  • Test updates
  • Build/CI improvements

Changes Made

Root cause

This is an Inspector bug, not an SDK bug — the SDK routes both grant types through the same executeTokenRequest, which sets exactly Content-Type: application/x-www-form-urlencoded.

The streamable-http direct-connection custom fetch wrapper in useConnection.ts mutated the shared requestHeaders object:

requestHeaders["Content-Type"] = "application/json"; // capitalized

That same object is also passed as requestInit.headers. The SDK reuses requestInit for OAuth token requests via createFetchWithInit, which merges the connect-time headers with the token request's own headers using plain object spread:

headers: { ...normalizeHeaders(baseInit.headers), ...normalizeHeaders(init.headers) }
//          { "Content-Type": "application/json" }   { "content-type": "application/x-www-form-urlencoded" }

Because the keys differ only by case, both survive the spread. The resulting two-entry object is then handed to fetch(), whose Headers constructor combines same-name headers into a single comma-joined value → application/json, application/x-www-form-urlencoded.

(The authorization_code exchange happens at connect time, before the wrapper ever mutates requestHeaders, which is why only the refresh exchange is affected.)

Fix

Stop mutating the shared requestHeaders in the streamable-http custom fetch. requestInit.headers no longer carries a capitalized Content-Type, so it can no longer pollute token requests.

Why dropping these headers is safe for MCP requests

The content-negotiation headers the wrapper used to set were already dead for the actual MCP requests — the SDK sets them itself on every request it routes through the custom fetch, and the wrapper forwards them via the ...init spread (fetch(url, { headers: requestHeaders, ...init }), so init.headers wins). Verified in @modelcontextprotocol/sdk (1.26.0) client/streamableHttp.js:

SDK method Request Headers the SDK sets before calling the custom fetch
send() POST (JSON-RPC message) content-type: application/json and accept: application/json, text/event-stream
_startOrAuthSse() GET (event stream) Accept: text/event-stream
terminate DELETE _commonHeaders() only

Content-Type is (correctly) only set on the body-bearing POST; the GET/DELETE requests have no body and therefore no Content-Type. In every case the SDK builds these on a Headers object it passes as init.headers, so ...init overrides the wrapper's requestHeaders for MCP requests — which is exactly why the wrapper's assignment never reached the wire and only leaked into requestInit. Dropping it therefore does not change the Content-Type/Accept sent on any MCP request.

Independent of the stale-token refresh fix (#1434).

Related Issues

Fixes #1160

Testing

  • Tested in UI mode
  • Tested in CLI mode
  • Tested with STDIO transport
  • Tested with SSE transport
  • Tested with Streamable HTTP transport
  • Added/updated automated tests
  • Manual testing performed

Test Results and/or Instructions

Added a useConnection.test.tsx regression test that faithfully reproduces the token-request path: it issues an ordinary MCP request through the transport's custom fetch, then replays the refresh_token request through the same createFetchWithInit(customFetch, requestInit) composition the SDK uses, and asserts the Content-Type on the wire is exactly application/x-www-form-urlencoded.

Verified the test fails on main (Received: "application/json, application/x-www-form-urlencoded") and passes with the fix. npm run prettier-fix, client npm run lint, tsc --noEmit, and the full useConnection.test.tsx suite (47 tests) pass.

Checklist

  • Code follows the style guidelines (ran npm run prettier-fix)
  • Self-review completed
  • Code is commented where necessary
  • Documentation updated (README, comments, etc.)

AI Generated, Human reviewed

The refresh_token grant sent a malformed, comma-joined
'Content-Type: application/json, application/x-www-form-urlencoded'
header (only the refresh exchange; the initial authorization_code
exchange was correct). Strict servers reject that per RFC 9110 with 415
before OAuth handling runs, so the SDK falls back to a full interactive
re-authorization on every token expiry instead of refreshing silently.

Root cause: the streamable-http direct-connection custom fetch mutated
the shared requestHeaders object with a capitalized
'Content-Type: application/json'. That same object is passed as
requestInit.headers, which the SDK reuses for OAuth token requests via
createFetchWithInit. The token request sets its own lowercase
'content-type: application/x-www-form-urlencoded'; the two are merged by
plain object spread, and the case mismatch leaves both keys, which fetch
then joins with a comma.

Stop mutating the shared requestHeaders. The SDK already sets the correct
Accept/Content-Type on each MCP request, so the actual message requests
are unaffected, and requestInit no longer pollutes token requests.

Fixes modelcontextprotocol#1160
@justinedelson justinedelson marked this pull request as ready for review July 16, 2026 21:01
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.

Requests to oauth the token endpoint with refresh_token grant type set invalid content-type header

1 participant