fix: don't corrupt refresh_token Content-Type on direct connections - #1696
fix: don't corrupt refresh_token Content-Type on direct connections#1696justinedelson wants to merge 2 commits into
Conversation
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
|
Closing: v1 is deprecated. Thank you for this contribution, and apologies for the long wait for a response. v1 will receive security fixes only. We reviewed every open v1 PR for security impact before closing — see the backlog triage in #1819 — and a small number were retained for a final If the underlying problem still exists in v2, we'd genuinely like to know. Please open an issue describing it against v2. Note that we accept external contributions as issues rather than pull requests — maintainers handle design and implementation through a prompt-driven workflow. See Thanks again for taking the time to contribute to the Inspector. |
Summary
The initial OAuth token exchange (
grant_type=authorization_code) correctly sends:But the refresh exchange (
grant_type=refresh_token) — and only that one — sends a malformed, comma-joined value: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 Typebefore reaching any OAuth-specific handling — e.g. Fastify validatesContent-Typesyntax in the framework layer, ahead of any registered content-type parser. When that happens, the SDK'sauth()helper receives a non-OAuthErrorfailure 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:
Type of Change
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 exactlyContent-Type: application/x-www-form-urlencoded.The streamable-http direct-connection custom
fetchwrapper inuseConnection.tsmutated the sharedrequestHeadersobject:That same object is also passed as
requestInit.headers. The SDK reusesrequestInitfor OAuth token requests viacreateFetchWithInit, which merges the connect-time headers with the token request's own headers using plain object spread:Because the keys differ only by case, both survive the spread. The resulting two-entry object is then handed to
fetch(), whoseHeadersconstructor combines same-name headers into a single comma-joined value →application/json, application/x-www-form-urlencoded.(The
authorization_codeexchange happens at connect time, before the wrapper ever mutatesrequestHeaders, which is why only the refresh exchange is affected.)Fix
Stop mutating the shared
requestHeadersin the streamable-http customfetch.requestInit.headersno longer carries a capitalizedContent-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...initspread (fetch(url, { headers: requestHeaders, ...init }), soinit.headerswins). Verified in@modelcontextprotocol/sdk(1.26.0)client/streamableHttp.js:fetchsend()POST(JSON-RPC message)content-type: application/jsonandaccept: application/json, text/event-stream_startOrAuthSse()GET(event stream)Accept: text/event-streamDELETE_commonHeaders()onlyContent-Typeis (correctly) only set on the body-bearingPOST; theGET/DELETErequests have no body and therefore noContent-Type. In every case the SDK builds these on aHeadersobject it passes asinit.headers, so...initoverrides the wrapper'srequestHeadersfor MCP requests — which is exactly why the wrapper's assignment never reached the wire and only leaked intorequestInit. Dropping it therefore does not change theContent-Type/Acceptsent on any MCP request.Independent of the stale-token refresh fix (#1434).
Related Issues
Fixes #1160
Testing
Test Results and/or Instructions
Added a
useConnection.test.tsxregression test that faithfully reproduces the token-request path: it issues an ordinary MCP request through the transport's customfetch, then replays the refresh_token request through the samecreateFetchWithInit(customFetch, requestInit)composition the SDK uses, and asserts theContent-Typeon the wire is exactlyapplication/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, clientnpm run lint,tsc --noEmit, and the fulluseConnection.test.tsxsuite (47 tests) pass.Checklist
npm run prettier-fix)AI Generated, Human reviewed