Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions client/src/lib/hooks/__tests__/useConnection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> => {
if (!headers) return {};
if (headers instanceof Headers) return Object.fromEntries(headers);
if (Array.isArray(headers)) return Object.fromEntries(headers);
return { ...(headers as Record<string, string>) };
};

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();
Expand Down
14 changes: 11 additions & 3 deletions client/src/lib/hooks/useConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down