diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index 875c9e387..4e4201c28 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -1387,6 +1387,131 @@ describe("useConnection", () => { }); }); + 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. + 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 () => { + // 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, + 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 calls = (global.fetch as jest.Mock).mock.calls; + const sentHeaders = new Headers( + calls[calls.length - 1]?.[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,