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
212 changes: 212 additions & 0 deletions client/src/lib/hooks/__tests__/useConnection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,218 @@ describe("useConnection", () => {
});
});

describe("Mcp-Session-Id header propagation (issue #905)", () => {
beforeEach(() => {
jest.clearAllMocks();
mockSSETransport.url = undefined;
mockSSETransport.options = undefined;
mockStreamableHTTPTransport.url = undefined;
mockStreamableHTTPTransport.options = undefined;
});

test("proxy streamable-http preserves SDK-supplied Mcp-Session-Id in fetch closure", async () => {
// Per MCP spec, the SDK threads Mcp-Session-Id into init.headers on
// subsequent fetches after the init response. The proxy-mode closure
// previously overwrote init.headers with { ...headers, ...proxyHeaders },
// dropping the session ID. Regression test for the spread-order fix.
const propsProxyStreamableHttp = {
...defaultProps,
transportType: "streamable-http" as const,
connectionType: "proxy" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};

const { result } = renderHook(() =>
useConnection(propsProxyStreamableHttp),
);

await act(async () => {
await result.current.connect();
});

const closureFetch =
mockStreamableHTTPTransport.options?.eventSourceInit?.fetch;
expect(closureFetch).toBeDefined();

const beforeCalls = (global.fetch as jest.Mock).mock.calls.length;

const testUrl = "http://test.example/mcp";
await closureFetch?.(testUrl, {
headers: {
Accept: "text/event-stream",
"mcp-session-id": "test-sid",
},
cache: "no-store",
mode: "cors",
signal: new AbortController().signal,
redirect: "follow",
});

const closureCall = (global.fetch as jest.Mock).mock.calls[beforeCalls];
expect(closureCall[0]).toBe(testUrl);
// SDK-supplied session id must survive the closure (this is the bug fix)
expect(closureCall[1].headers).toHaveProperty(
"mcp-session-id",
"test-sid",
);
// SDK-supplied Accept header must survive too
expect(closureCall[1].headers).toHaveProperty(
"Accept",
"text/event-stream",
);
});

test("proxy streamable-http preserves X-MCP-Proxy-Auth when SDK-supplied headers are present", async () => {
// Regression guard for the precedence concern: the spread-order fix
// adds `...(init?.headers || {})` last, but proxy auth (different key)
// must still propagate. If it didn't, proxy-to-inspector auth would break.
const propsProxyStreamableHttp = {
...defaultProps,
transportType: "streamable-http" as const,
connectionType: "proxy" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};

const { result } = renderHook(() =>
useConnection(propsProxyStreamableHttp),
);

await act(async () => {
await result.current.connect();
});

const closureFetch =
mockStreamableHTTPTransport.options?.eventSourceInit?.fetch;
expect(closureFetch).toBeDefined();

const beforeCalls = (global.fetch as jest.Mock).mock.calls.length;

await closureFetch?.("http://test.example/mcp", {
headers: {
Accept: "text/event-stream",
"mcp-session-id": "test-sid",
},
cache: "no-store",
mode: "cors",
signal: new AbortController().signal,
redirect: "follow",
});

const closureCall = (global.fetch as jest.Mock).mock.calls[beforeCalls];
expect(closureCall[1].headers).toHaveProperty(
"X-MCP-Proxy-Auth",
"Bearer test-proxy-token",
);
expect(closureCall[1].headers).toHaveProperty(
"mcp-session-id",
"test-sid",
);
});

test("proxy SSE fetch closure preserves SDK-supplied headers", async () => {
// Same spread-order fix applies to SSE proxy mode. Even though SSE
// doesn't use Mcp-Session-Id, the SDK can pass other headers
// (Mcp-Protocol-Version, custom) via init.headers — those must survive.
const propsProxySse = {
...defaultProps,
transportType: "sse" as const,
connectionType: "proxy" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};

const { result } = renderHook(() => useConnection(propsProxySse));

await act(async () => {
await result.current.connect();
});

const closureFetch = mockSSETransport.options?.eventSourceInit?.fetch;
expect(closureFetch).toBeDefined();

const beforeCalls = (global.fetch as jest.Mock).mock.calls.length;

await closureFetch?.("http://test.example/sse", {
headers: {
Accept: "text/event-stream",
"mcp-protocol-version": "2025-11-25",
"x-sdk-supplied": "preserved",
},
cache: "no-store",
mode: "cors",
signal: new AbortController().signal,
redirect: "follow",
});

const closureCall = (global.fetch as jest.Mock).mock.calls[beforeCalls];
expect(closureCall[1].headers).toHaveProperty(
"mcp-protocol-version",
"2025-11-25",
);
expect(closureCall[1].headers).toHaveProperty(
"x-sdk-supplied",
"preserved",
);
expect(closureCall[1].headers).toHaveProperty(
"X-MCP-Proxy-Auth",
"Bearer test-proxy-token",
);
});

test("direct streamable-http fetch closure still propagates SDK-supplied headers (regression guard)", async () => {
// Direct mode was already correct (headers first then ...init).
// Verify that path was not regressed.
const propsDirectStreamableHttp = {
...defaultProps,
transportType: "streamable-http" as const,
connectionType: "direct" as const,
};

const { result } = renderHook(() =>
useConnection(propsDirectStreamableHttp),
);

await act(async () => {
await result.current.connect();
});

const closureFetch = mockStreamableHTTPTransport.options?.fetch;
expect(closureFetch).toBeDefined();

const beforeCalls = (global.fetch as jest.Mock).mock.calls.length;

await closureFetch?.("http://test.example/mcp", {
headers: {
Accept: "text/event-stream",
"mcp-session-id": "direct-sid",
},
});

const closureCall = (global.fetch as jest.Mock).mock.calls[beforeCalls];
expect(closureCall[1].headers).toHaveProperty(
"mcp-session-id",
"direct-sid",
);
});
});

describe("Custom Headers", () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
32 changes: 27 additions & 5 deletions client/src/lib/hooks/useConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,11 @@ export function useConnection({
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
headers: {
...headers,
...proxyHeaders,
...(init?.headers || {}),
},
}),
},
requestInit: {
Expand Down Expand Up @@ -698,7 +702,11 @@ export function useConnection({
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
headers: {
...headers,
...proxyHeaders,
...(init?.headers || {}),
},
}),
},
requestInit: {
Expand All @@ -708,9 +716,19 @@ export function useConnection({
break;
}

case "streamable-http":
case "streamable-http": {
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
// Mirror the direct-mode pre-seed at L573-576: the SDK captures
// Mcp-Session-Id from the init response and includes it on
// subsequent requests via `init.headers`. Belt-and-braces seeding
// the React-state-tracked session id into requestInit.headers
// guards client-to-proxy session continuity across re-renders.
// See MCP spec `docs/specification/2025-11-25/basic/transports.mdx`.
const proxyRequestHeaders = { ...headers, ...proxyHeaders };
if (mcpSessionId) {
proxyRequestHeaders["mcp-session-id"] = mcpSessionId;
}
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
Expand All @@ -720,11 +738,14 @@ export function useConnection({
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
headers: {
...proxyRequestHeaders,
...(init?.headers || {}),
},
}),
},
requestInit: {
headers: { ...headers, ...proxyHeaders },
headers: proxyRequestHeaders,
},
// TODO these should be configurable...
reconnectionOptions: {
Expand All @@ -735,6 +756,7 @@ export function useConnection({
},
};
break;
}
}
serverUrl = mcpProxyServerUrl as URL;
serverUrl.searchParams.append("transportType", transportType);
Expand Down