fix: don't corrupt refresh_token Content-Type on direct connections#1696
Open
justinedelson wants to merge 2 commits into
Open
fix: don't corrupt refresh_token Content-Type on direct connections#1696justinedelson wants to merge 2 commits into
justinedelson 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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