
























@@ -0,0 +1,216 @@
1+import { beforeEach, describe, expect, it, vi } from "vitest";
2+import { resolveMcpTransport } from "./mcp-transport.js";
3+4+type StreamableTransportOptions = {
5+requestInit?: RequestInit;
6+fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
7+};
8+9+const { runtimeFetchMock, streamableTransportConstructorMock } = vi.hoisted(() => ({
10+runtimeFetchMock: vi.fn(),
11+streamableTransportConstructorMock: vi.fn(),
12+}));
13+14+vi.mock("../infra/net/undici-runtime.js", () => ({
15+loadUndiciRuntimeDeps: () => ({
16+fetch: runtimeFetchMock,
17+}),
18+}));
19+20+vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
21+StreamableHTTPClientTransport: function MockStreamableHTTPClientTransport(
22+this: unknown,
23+url: URL,
24+options?: StreamableTransportOptions,
25+) {
26+streamableTransportConstructorMock(url, options);
27+},
28+}));
29+30+function redirectResponse(location: string, status = 302): Response {
31+return new Response(null, {
32+ status,
33+headers: { location },
34+});
35+}
36+37+function redirectWithoutLocationResponse(status = 302): Response {
38+return new Response(null, { status });
39+}
40+41+function latestStreamableTransportOptions(): StreamableTransportOptions {
42+const options = streamableTransportConstructorMock.mock.calls.at(-1)?.[1];
43+if (!options || typeof options !== "object") {
44+throw new Error("Expected streamable HTTP transport options");
45+}
46+return options as StreamableTransportOptions;
47+}
48+49+function latestStreamableFetch() {
50+const fetch = latestStreamableTransportOptions().fetch;
51+if (typeof fetch !== "function") {
52+throw new Error("Expected streamable HTTP transport fetch");
53+}
54+return fetch;
55+}
56+57+describe("resolveMcpTransport", () => {
58+beforeEach(() => {
59+runtimeFetchMock.mockReset();
60+streamableTransportConstructorMock.mockClear();
61+});
62+63+it("scrubs custom headers when streamable HTTP follows a cross-origin redirect", async () => {
64+runtimeFetchMock
65+.mockResolvedValueOnce(redirectResponse("https://redirect.example/next"))
66+.mockResolvedValueOnce(new Response("ok"));
67+68+resolveMcpTransport("probe", {
69+url: "https://mcp.example.com/mcp",
70+transport: "streamable-http",
71+headers: {
72+"X-Api-Key": "secret",
73+},
74+});
75+76+const options = latestStreamableTransportOptions();
77+expect(options.requestInit).toEqual({
78+headers: {
79+"X-Api-Key": "secret",
80+},
81+});
82+expect(options.fetch).toBeTypeOf("function");
83+84+await options.fetch?.("https://mcp.example.com/mcp", {
85+method: "GET",
86+headers: {
87+accept: "application/json, text/event-stream",
88+"user-agent": "node",
89+"x-api-key": "secret",
90+},
91+});
92+93+expect(runtimeFetchMock).toHaveBeenCalledTimes(2);
94+expect(runtimeFetchMock.mock.calls[0]?.[0]).toBe("https://mcp.example.com/mcp");
95+expect(runtimeFetchMock.mock.calls[0]?.[1]?.redirect).toBe("manual");
96+expect(runtimeFetchMock.mock.calls[1]?.[0]).toBe("https://redirect.example/next");
97+expect(runtimeFetchMock.mock.calls[1]?.[1]?.redirect).toBe("manual");
98+99+const redirectedHeaders = new Headers(runtimeFetchMock.mock.calls[1]?.[1]?.headers);
100+expect(redirectedHeaders.get("x-api-key")).toBeNull();
101+expect(redirectedHeaders.get("accept")).toBe("application/json, text/event-stream");
102+expect(redirectedHeaders.get("user-agent")).toBe("node");
103+});
104+105+it("preserves replayable request bodies for cross-origin streamable HTTP redirects", async () => {
106+runtimeFetchMock
107+.mockResolvedValueOnce(redirectResponse("https://redirect.example/mcp", 307))
108+.mockResolvedValueOnce(new Response("ok"));
109+110+resolveMcpTransport("probe", {
111+url: "https://mcp.example.com/mcp",
112+transport: "streamable-http",
113+headers: {
114+"X-Api-Key": "secret",
115+},
116+});
117+118+const options = latestStreamableTransportOptions();
119+const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" });
120+121+await options.fetch?.("https://mcp.example.com/mcp", {
122+method: "POST",
123+headers: {
124+"content-type": "application/json",
125+"x-api-key": "secret",
126+},
127+ body,
128+});
129+130+expect(runtimeFetchMock).toHaveBeenCalledTimes(2);
131+expect(runtimeFetchMock.mock.calls[1]?.[0]).toBe("https://redirect.example/mcp");
132+expect(runtimeFetchMock.mock.calls[1]?.[1]?.method).toBe("POST");
133+expect(runtimeFetchMock.mock.calls[1]?.[1]?.body).toBe(body);
134+135+const redirectedHeaders = new Headers(runtimeFetchMock.mock.calls[1]?.[1]?.headers);
136+expect(redirectedHeaders.get("x-api-key")).toBeNull();
137+expect(redirectedHeaders.get("content-type")).toBe("application/json");
138+});
139+140+it("allows same-url redirects when the request method changes", async () => {
141+runtimeFetchMock
142+.mockResolvedValueOnce(redirectResponse("https://mcp.example.com/mcp", 303))
143+.mockResolvedValueOnce(new Response("ok"));
144+145+resolveMcpTransport("probe", {
146+url: "https://mcp.example.com/mcp",
147+transport: "streamable-http",
148+});
149+150+const options = latestStreamableTransportOptions();
151+152+await options.fetch?.("https://mcp.example.com/mcp", {
153+method: "POST",
154+headers: {
155+"content-type": "application/json",
156+},
157+body: "{}",
158+});
159+160+expect(runtimeFetchMock).toHaveBeenCalledTimes(2);
161+expect(runtimeFetchMock.mock.calls[1]?.[0]).toBe("https://mcp.example.com/mcp");
162+expect(runtimeFetchMock.mock.calls[1]?.[1]?.method).toBe("GET");
163+expect(runtimeFetchMock.mock.calls[1]?.[1]?.body).toBeUndefined();
164+165+const redirectedHeaders = new Headers(runtimeFetchMock.mock.calls[1]?.[1]?.headers);
166+expect(redirectedHeaders.get("content-type")).toBeNull();
167+});
168+169+it("rejects streamable HTTP redirect loops", async () => {
170+runtimeFetchMock.mockResolvedValueOnce(redirectResponse("https://mcp.example.com/mcp"));
171+172+resolveMcpTransport("probe", {
173+url: "https://mcp.example.com/mcp",
174+transport: "streamable-http",
175+});
176+177+await expect(latestStreamableFetch()("https://mcp.example.com/mcp")).rejects.toThrow(
178+"Redirect loop detected",
179+);
180+181+expect(runtimeFetchMock).toHaveBeenCalledTimes(1);
182+});
183+184+it("rejects streamable HTTP redirect chains beyond the limit", async () => {
185+for (let index = 0; index <= 20; index += 1) {
186+runtimeFetchMock.mockResolvedValueOnce(
187+redirectResponse(`https://mcp.example.com/redirect-${index}`),
188+);
189+}
190+191+resolveMcpTransport("probe", {
192+url: "https://mcp.example.com/mcp",
193+transport: "streamable-http",
194+});
195+196+await expect(latestStreamableFetch()("https://mcp.example.com/mcp")).rejects.toThrow(
197+"Too many redirects (limit: 20)",
198+);
199+200+expect(runtimeFetchMock).toHaveBeenCalledTimes(21);
201+});
202+203+it("returns streamable HTTP redirect responses that do not include a location", async () => {
204+const response = redirectWithoutLocationResponse();
205+runtimeFetchMock.mockResolvedValueOnce(response);
206+207+resolveMcpTransport("probe", {
208+url: "https://mcp.example.com/mcp",
209+transport: "streamable-http",
210+});
211+212+await expect(latestStreamableFetch()("https://mcp.example.com/mcp")).resolves.toBe(response);
213+214+expect(runtimeFetchMock).toHaveBeenCalledTimes(1);
215+});
216+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。