From 572cb4a3d17af5040497238f8c021fd5b9a2a75c Mon Sep 17 00:00:00 2001 From: Justin Edelson <65906+justinedelson@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:03:02 -0400 Subject: [PATCH 1/2] fix: don't corrupt refresh_token Content-Type on direct connections 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 #1160 --- .../hooks/__tests__/useConnection.test.tsx | 85 +++++++++++++++++++ client/src/lib/hooks/useConnection.ts | 14 ++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 875c9e387..2f885941f 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -1387,6 +1387,91 @@ describe("useConnection", () => { }); }); + describe("Direct connection OAuth token Content-Type (#1160)", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStreamableHTTPTransport.options = undefined; + }); + + // Mirrors the SDK's shared `createFetchWithInit`, which wraps the custom + // `fetch` with the connect-time `requestInit` and is what the SDK uses for + // OAuth token requests. See @modelcontextprotocol/sdk shared/transport.ts. + const normalizeHeaders = ( + headers: HeadersInit | undefined, + ): Record => { + if (!headers) return {}; + if (headers instanceof Headers) return Object.fromEntries(headers); + if (Array.isArray(headers)) return Object.fromEntries(headers); + return { ...(headers as Record) }; + }; + + test("refresh_token request keeps a single application/x-www-form-urlencoded Content-Type", async () => { + // Regression test for #1160: the streamable-http custom fetch must not + // mutate the shared requestInit headers with a capitalized + // `Content-Type: application/json`. If it does, the SDK's token request + // (lowercase `content-type: application/x-www-form-urlencoded`) merges + // with it by object spread, and the case mismatch yields a malformed + // comma-joined `Content-Type` that strict servers reject with 415. + const directProps = { + ...defaultProps, + transportType: "streamable-http" as const, + connectionType: "direct" as const, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + const customFetch = mockStreamableHTTPTransport.options?.fetch; + const requestInit = mockStreamableHTTPTransport.options?.requestInit; + expect(customFetch).toBeDefined(); + + // 1) An ordinary MCP request runs first (as the SDK would issue it). In + // the buggy version this mutated the shared requestInit headers. + await customFetch?.("http://test.com/mcp", { + method: "POST", + headers: new Headers({ + "content-type": "application/json", + accept: "application/json, text/event-stream", + }), + }); + + // 2) The SDK then issues the refresh_token request through + // createFetchWithInit(customFetch, requestInit). + const tokenInit: RequestInit = { + method: "POST", + body: "grant_type=refresh_token&refresh_token=abc", + headers: new Headers({ + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }), + }; + const mergedInit = { + ...requestInit, + ...tokenInit, + headers: { + ...normalizeHeaders(requestInit?.headers), + ...normalizeHeaders(tokenInit.headers), + }, + }; + await customFetch?.( + "http://test.com/mcp-oauth/token", + mergedInit as RequestInit, + ); + + // Inspect the Content-Type actually put on the wire for the token request. + const lastCall = (global.fetch as jest.Mock).mock.calls.at(-1); + const sentHeaders = new Headers( + lastCall?.[1]?.headers as HeadersInit | undefined, + ); + expect(sentHeaders.get("content-type")).toBe( + "application/x-www-form-urlencoded", + ); + }); + }); + describe("Connection URL Verification", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9694b891f..455a8cc36 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -610,9 +610,17 @@ export function useConnection({ url: string | URL | globalThis.Request, init?: RequestInit, ) => { - requestHeaders["Accept"] = - "text/event-stream, application/json"; - requestHeaders["Content-Type"] = "application/json"; + // NOTE: do not mutate the shared `requestHeaders` object here. + // It is also passed as `requestInit.headers`, and the SDK reuses + // that same object for OAuth token requests via + // `createFetchWithInit`. Injecting a capitalized + // `Content-Type: application/json` would then collide (by case) + // with the token request's own lowercase + // `content-type: application/x-www-form-urlencoded`, producing a + // malformed comma-joined value that a strict server rejects with + // 415 before OAuth handling runs (#1160). The SDK already sets + // the correct Accept/Content-Type on each MCP request itself, so + // `...init` supplies them for the actual message requests. const response = await fetch(url, { headers: requestHeaders, ...init, From 2632f0535a8166d9243f49542163363259b63139 Mon Sep 17 00:00:00 2001 From: Justin Edelson <65906+justinedelson@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:58:49 -0400 Subject: [PATCH 2/2] review: add observable-invariant test; drop issue ref from describe title --- .../hooks/__tests__/useConnection.test.tsx | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 2f885941f..4e4201c28 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -1387,12 +1387,55 @@ describe("useConnection", () => { }); }); - describe("Direct connection OAuth token Content-Type (#1160)", () => { + describe("Direct connection OAuth token Content-Type", () => { beforeEach(() => { jest.clearAllMocks(); + mockSSETransport.options = undefined; mockStreamableHTTPTransport.options = undefined; }); + test("does not mutate the shared requestInit headers with a Content-Type", async () => { + // Regression test for #1160. The streamable-http custom fetch must not + // write a capitalized `Content-Type: application/json` onto the shared + // `requestHeaders` object, because that object is also passed as + // `requestInit.headers`, which the SDK reuses for OAuth token requests. + // If it is mutated here, the token request's own lowercase + // `content-type: application/x-www-form-urlencoded` collides (by case) + // with it and produces a malformed comma-joined value that strict + // servers reject with 415. + const directProps = { + ...defaultProps, + transportType: "streamable-http" as const, + connectionType: "direct" as const, + }; + + const { result } = renderHook(() => useConnection(directProps)); + + await act(async () => { + await result.current.connect(); + }); + + const customFetch = mockStreamableHTTPTransport.options?.fetch; + expect(customFetch).toBeDefined(); + + // Issue an ordinary MCP request through the wrapper, exactly as the SDK + // would. In the buggy version this mutated the shared requestInit + // headers. + await customFetch?.("http://test.com/mcp", { + method: "POST", + headers: new Headers({ + "content-type": "application/json", + accept: "application/json, text/event-stream", + }), + }); + + // The connect-time headers baked into requestInit must remain free of a + // Content-Type so they cannot corrupt the SDK's token request. + expect( + mockStreamableHTTPTransport.options?.requestInit?.headers, + ).not.toHaveProperty("Content-Type"); + }); + // Mirrors the SDK's shared `createFetchWithInit`, which wraps the custom // `fetch` with the connect-time `requestInit` and is what the SDK uses for // OAuth token requests. See @modelcontextprotocol/sdk shared/transport.ts. @@ -1406,12 +1449,9 @@ describe("useConnection", () => { }; test("refresh_token request keeps a single application/x-www-form-urlencoded Content-Type", async () => { - // Regression test for #1160: the streamable-http custom fetch must not - // mutate the shared requestInit headers with a capitalized - // `Content-Type: application/json`. If it does, the SDK's token request - // (lowercase `content-type: application/x-www-form-urlencoded`) merges - // with it by object spread, and the case mismatch yields a malformed - // comma-joined `Content-Type` that strict servers reject with 415. + // End-to-end evidence for #1160: replays the exact SDK token-request + // composition and asserts the Content-Type that ends up on the wire is + // not the malformed comma-joined value. const directProps = { ...defaultProps, transportType: "streamable-http" as const, @@ -1462,9 +1502,9 @@ describe("useConnection", () => { ); // Inspect the Content-Type actually put on the wire for the token request. - const lastCall = (global.fetch as jest.Mock).mock.calls.at(-1); + const calls = (global.fetch as jest.Mock).mock.calls; const sentHeaders = new Headers( - lastCall?.[1]?.headers as HeadersInit | undefined, + calls[calls.length - 1]?.[1]?.headers as HeadersInit | undefined, ); expect(sentHeaders.get("content-type")).toBe( "application/x-www-form-urlencoded",