From e4285ae11ed047481d8c8ab343371535e9b64931 Mon Sep 17 00:00:00 2001 From: Tony Liu Date: Fri, 5 Jun 2026 11:09:29 +1200 Subject: [PATCH] fix: prevent malformed dual Content-Type on direct-transport token requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../hooks/__tests__/oauthHeaderMerge.test.ts | 99 +++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 27 ++--- 2 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 client/src/lib/hooks/__tests__/oauthHeaderMerge.test.ts diff --git a/client/src/lib/hooks/__tests__/oauthHeaderMerge.test.ts b/client/src/lib/hooks/__tests__/oauthHeaderMerge.test.ts new file mode 100644 index 000000000..2da204e24 --- /dev/null +++ b/client/src/lib/hooks/__tests__/oauthHeaderMerge.test.ts @@ -0,0 +1,99 @@ +/** + * Guards against a malformed dual `Content-Type` on the OAuth token request + * made by the direct SSE / Streamable HTTP transports. + * + * 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 SDK's token request (executeTokenRequest) builds its + * headers with `new Headers(...)`, which normalizes the key to lowercase + * `content-type`. + * + * If the Inspector also puts a (capital) `Content-Type` in `requestInit`, both + * keys survive the case-sensitive merge and `fetch` comma-joins them into + * `Content-Type: application/json, application/x-www-form-urlencoded` + * which strict authorization servers cannot body-parse, breaking token + * exchange and refresh. + * + * The Inspector therefore must not contribute `content-type` (or `accept`) via + * `requestInit` — the SDK already sets them per request. These tests drive the + * real SDK merge with the token request's exact shape to lock that in. + */ +import { createFetchWithInit } from "@modelcontextprotocol/sdk/shared/transport.js"; + +type CapturedRequest = { url: string; contentType: string | null }; + +/** + * A fetch that records the `Content-Type` the upstream server would actually + * receive, using real `Headers` semantics (which combine duplicates). + */ +function makeCapturingFetch(captured: CapturedRequest[]) { + return (async ( + input: string | URL | globalThis.Request, + init?: RequestInit, + ) => { + const headers = new Headers(init?.headers); + captured.push({ + url: typeof input === "string" ? input : input.toString(), + contentType: headers.get("content-type"), + }); + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; +} + +// Mirrors the SDK's executeTokenRequest: a Headers object, which lowercases the +// key to "content-type", plus a form-urlencoded body. +async function postToken(fetchFn: typeof fetch) { + await fetchFn("https://auth.example.com/token", { + method: "POST", + headers: new Headers({ + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "r", + }), + }); +} + +function tokenContentType(captured: CapturedRequest[]): string | null { + const req = captured.find((c) => c.url.endsWith("/token")); + expect(req).toBeDefined(); + return req!.contentType; +} + +describe("OAuth token request keeps a single Content-Type", () => { + it("does not duplicate Content-Type when requestInit carries only auth headers", async () => { + // The headers the Inspector now hands to the transport as + // `requestInit.headers`: auth / custom headers only, never content-type. + const captured: CapturedRequest[] = []; + const fetchFn = createFetchWithInit(makeCapturingFetch(captured), { + headers: { Authorization: "Bearer test-access-token" }, + }); + + await postToken(fetchFn); + + expect(tokenContentType(captured)).toBe( + "application/x-www-form-urlencoded", + ); + }); + + it("a Content-Type left in requestInit re-introduces the dual header", async () => { + // Documents the bug this fix removes: a capital "Content-Type" in + // requestInit collides with the token request's lowercase "content-type". + const captured: CapturedRequest[] = []; + const fetchFn = createFetchWithInit(makeCapturingFetch(captured), { + headers: { "Content-Type": "application/json" }, + }); + + await postToken(fetchFn); + + expect(tokenContentType(captured)).toBe( + "application/json, application/x-www-form-urlencoded", + ); + }); +}); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9694b891f..551e393db 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -580,18 +580,21 @@ export function useConnection({ } switch (transportType) { case "sse": - requestHeaders["Accept"] = "text/event-stream"; - requestHeaders["content-type"] = "application/json"; + // Do not set accept/content-type here. The SDK sets them itself on + // each request (SSE GET stream, message POST, and the OAuth token + // request). Injecting them via requestInit collides with the SDK's + // case-sensitive header merge: the token request builds its headers + // with `new Headers(...)`, normalizing to lowercase "content-type", + // so a capital "Content-Type" set here survives alongside it and + // `fetch` comma-joins them into a malformed dual value that strict + // servers cannot parse. See createFetchWithInit in shared/transport. transportOptions = { authProvider: serverAuthProvider, fetch: async ( url: string | URL | globalThis.Request, init?: RequestInit, ) => { - const response = await fetch(url, { - ...init, - headers: requestHeaders, - }); + const response = await fetch(url, init); // Capture protocol-related headers from response captureResponseHeaders(response); @@ -604,19 +607,17 @@ export function useConnection({ break; case "streamable-http": + // See the SSE note above: the SDK sets accept/content-type itself + // per request, so injecting them via requestInit collides with the + // SDK's case-sensitive merge and yields a malformed dual + // Content-Type on the OAuth token request. transportOptions = { authProvider: serverAuthProvider, fetch: async ( url: string | URL | globalThis.Request, init?: RequestInit, ) => { - requestHeaders["Accept"] = - "text/event-stream, application/json"; - requestHeaders["Content-Type"] = "application/json"; - const response = await fetch(url, { - headers: requestHeaders, - ...init, - }); + const response = await fetch(url, init); // Capture protocol-related headers from response captureResponseHeaders(response);