Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
LATEST_PROTOCOL_VERSION,
|
|
type McpAuthRequiredError,
|
|
McpClient,
|
|
type McpFetch,
|
|
McpSessionExpiredError,
|
|
StreamableHttpTransport,
|
|
} from "../src/index.ts";
|
|
import { consumeSseStream, type SseEvent } from "../src/transports/streamable-http.ts";
|
|
import { closeServers, listen, readBody } from "./helpers.ts";
|
|
|
|
interface RecordedRequest {
|
|
method: string;
|
|
headers: IncomingMessage["headers"];
|
|
message?: Record<string, unknown>;
|
|
}
|
|
|
|
async function startServer(
|
|
handler: (request: IncomingMessage, response: ServerResponse, requests: RecordedRequest[]) => Promise<void>,
|
|
): Promise<{ url: string; requests: RecordedRequest[] }> {
|
|
const requests: RecordedRequest[] = [];
|
|
const origin = await listen((request, response) => handler(request, response, requests));
|
|
return { url: `${origin}/mcp`, requests };
|
|
}
|
|
|
|
async function protocolHandler(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
requests: RecordedRequest[],
|
|
body?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
if (request.method === "GET") {
|
|
requests.push({ method: "GET", headers: request.headers });
|
|
response.statusCode = 405;
|
|
response.end();
|
|
return;
|
|
}
|
|
if (request.method === "DELETE") {
|
|
requests.push({ method: "DELETE", headers: request.headers });
|
|
response.statusCode = 200;
|
|
response.end();
|
|
return;
|
|
}
|
|
const message = body ?? (JSON.parse(await readBody(request)) as Record<string, unknown>);
|
|
requests.push({ method: request.method ?? "", headers: request.headers, message });
|
|
if (!("id" in message)) {
|
|
response.statusCode = 202;
|
|
response.end();
|
|
return;
|
|
}
|
|
if (message.method === "initialize") {
|
|
response.writeHead(200, {
|
|
"content-type": "application/json",
|
|
"mcp-session-id": "session-1",
|
|
});
|
|
response.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: {
|
|
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "http-fixture", version: "1.0.0" },
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (message.method === "tools/list") {
|
|
response.setHeader("content-type", "application/json");
|
|
response.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
result: { tools: [{ name: "echo", inputSchema: { type: "object" } }] },
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
response.write("id: tool-result\n");
|
|
response.end(
|
|
`data: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: "hello" }] } })}\n\n`,
|
|
);
|
|
}
|
|
|
|
afterEach(closeServers);
|
|
|
|
describe("consumeSseStream", () => {
|
|
it("parses chunked CRLF events, comments, IDs, and multiline data", async () => {
|
|
const encoder = new TextEncoder();
|
|
const chunks = [': keepalive\r\nid: 7\r\ndata: {"one":\r\n', "data: 1}\r\n\r\n"];
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
controller.close();
|
|
},
|
|
});
|
|
const events: SseEvent[] = [];
|
|
await consumeSseStream(stream, { onEvent: (event) => events.push(event) });
|
|
expect(events).toEqual([{ id: "7", data: '{"one":\n1}' }]);
|
|
});
|
|
|
|
it("rejects events whose data lines exceed the limit without a blank line", async () => {
|
|
const encoder = new TextEncoder();
|
|
let sent = 0;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
// Never sends a blank line, so the event is never dispatched.
|
|
if (sent++ > 1000) controller.close();
|
|
else controller.enqueue(encoder.encode("data: xxxxxxxxxxxxxxxx\n"));
|
|
},
|
|
});
|
|
await expect(consumeSseStream(stream, { maxEventBytes: 256, onEvent: () => {} })).rejects.toThrow(
|
|
"MCP SSE event exceeds 256 bytes",
|
|
);
|
|
expect(sent).toBeLessThan(100);
|
|
});
|
|
});
|
|
|
|
describe("StreamableHttpTransport", () => {
|
|
it("handles JSON and SSE responses with session and protocol headers", async () => {
|
|
const { url, requests } = await startServer(protocolHandler);
|
|
const transport = new StreamableHttpTransport({ url });
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(transport);
|
|
expect(transport.sessionId).toBe("session-1");
|
|
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
|
|
expect(await client.callTool("echo", { text: "hello" })).toEqual({
|
|
content: [{ type: "text", text: "hello" }],
|
|
});
|
|
await client.close();
|
|
|
|
const listRequest = requests.find((entry) => entry.message?.method === "tools/list");
|
|
expect(listRequest?.headers["mcp-session-id"]).toBe("session-1");
|
|
expect(listRequest?.headers["mcp-protocol-version"]).toBe(LATEST_PROTOCOL_VERSION);
|
|
expect(requests.some((entry) => entry.method === "GET")).toBe(true);
|
|
expect(requests.some((entry) => entry.method === "DELETE")).toBe(true);
|
|
});
|
|
|
|
it("classifies authentication failures", async () => {
|
|
const { url } = await startServer(async (request, response) => {
|
|
await readBody(request);
|
|
response.writeHead(401, { "www-authenticate": 'Bearer resource_metadata="https://example.com/meta"' });
|
|
response.end("login required");
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await expect(client.connect(new StreamableHttpTransport({ url }))).rejects.toMatchObject({
|
|
name: "McpAuthRequiredError",
|
|
status: 401,
|
|
body: "login required",
|
|
wwwAuthenticate: 'Bearer resource_metadata="https://example.com/meta"',
|
|
} satisfies Partial<McpAuthRequiredError>);
|
|
});
|
|
|
|
it("fails only the request whose SSE stream breaks", async () => {
|
|
let releaseSlow = () => {};
|
|
const slowGate = new Promise<void>((resolve) => {
|
|
releaseSlow = resolve;
|
|
});
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
|
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
const name = (message.params as { name?: string } | undefined)?.name;
|
|
if (name === "broken") {
|
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
response.end("data: not json\n\n");
|
|
return;
|
|
}
|
|
if (name === "slow") await slowGate;
|
|
await protocolHandler(request, response, requests, message);
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
const errors: Error[] = [];
|
|
client.onError((error) => errors.push(error));
|
|
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
|
|
|
const slow = client.callTool("slow");
|
|
await expect(client.callTool("broken")).rejects.toThrow("MCP response stream failed");
|
|
releaseSlow();
|
|
expect(await slow).toEqual({ content: [{ type: "text", text: "hello" }] });
|
|
expect(errors).toHaveLength(1);
|
|
await client.close();
|
|
});
|
|
|
|
it("opens the GET stream after initialization and sends Last-Event-ID only when resuming", async () => {
|
|
const order: string[] = [];
|
|
const { url, requests } = await startServer(async (request, response, requests) => {
|
|
if (request.method === "POST") {
|
|
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
order.push(String(message.method));
|
|
return protocolHandler(request, response, requests, message);
|
|
}
|
|
order.push(request.method ?? "");
|
|
return protocolHandler(request, response, requests);
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(new StreamableHttpTransport({ url }));
|
|
await client.callTool("echo");
|
|
await client.listTools();
|
|
await client.close();
|
|
expect(order.indexOf("GET")).toBeGreaterThan(order.indexOf("notifications/initialized"));
|
|
expect(requests.every((entry) => entry.headers["last-event-id"] === undefined)).toBe(true);
|
|
});
|
|
|
|
it("resumes a response stream the server closed before answering", async () => {
|
|
const resumeHeaders: (string | undefined)[] = [];
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method === "GET" && request.headers["last-event-id"]) {
|
|
resumeHeaders.push(request.headers["last-event-id"] as string);
|
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
response.end(
|
|
`id: 2\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 2, result: { content: [{ type: "text", text: "resumed" }] } })}\n\n`,
|
|
);
|
|
return;
|
|
}
|
|
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
|
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
|
|
// Priming event (ID, no data) and a retry hint, then the server drops the stream.
|
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
response.end("id: 1\nretry: 5\ndata:\n\n");
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
const errors: Error[] = [];
|
|
client.onError((error) => errors.push(error));
|
|
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
|
expect(await client.callTool("echo")).toEqual({ content: [{ type: "text", text: "resumed" }] });
|
|
expect(resumeHeaders).toEqual(["1"]);
|
|
expect(errors).toEqual([]);
|
|
await client.close();
|
|
});
|
|
|
|
it("fails a request whose response stream ends without an answer", async () => {
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
|
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
|
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
response.end(": nothing here\n\n");
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
|
await expect(client.callTool("echo", {}, { timeoutMs: 5_000 })).rejects.toThrow(
|
|
"MCP response stream failed: stream ended without a response",
|
|
);
|
|
await client.close();
|
|
});
|
|
|
|
it("reconnects the GET stream after it drops", async () => {
|
|
let gets = 0;
|
|
const lastEventIds: (string | undefined)[] = [];
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method !== "GET") return protocolHandler(request, response, requests);
|
|
gets++;
|
|
lastEventIds.push(request.headers["last-event-id"] as string | undefined);
|
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
const notification = { jsonrpc: "2.0", method: "notifications/tools/list_changed" };
|
|
if (gets === 1) {
|
|
response.end(`id: g1\ndata: ${JSON.stringify(notification)}\n\n`);
|
|
return;
|
|
}
|
|
response.write(`id: g2\ndata: ${JSON.stringify(notification)}\n\n`);
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
let changes = 0;
|
|
const secondChange = new Promise<void>((resolve) => {
|
|
client.onNotification("notifications/tools/list_changed", () => {
|
|
if (++changes === 2) resolve();
|
|
});
|
|
});
|
|
await client.connect(new StreamableHttpTransport({ url, reconnect: { initialDelayMs: 1 } }));
|
|
await secondChange;
|
|
expect(lastEventIds).toEqual([undefined, "g1"]);
|
|
await client.close();
|
|
});
|
|
|
|
it("rejects a request the server accepts without a response", async () => {
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
|
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
if (message.method !== "tools/call") return protocolHandler(request, response, requests, message);
|
|
response.statusCode = 202;
|
|
response.end();
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
|
await expect(client.callTool("echo")).rejects.toThrow("without a response");
|
|
await client.close();
|
|
});
|
|
|
|
it("includes the response body in HTTP errors", async () => {
|
|
const { url } = await startServer(async (request, response) => {
|
|
await readBody(request);
|
|
response.statusCode = 400;
|
|
response.end("Invalid Accept header");
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await expect(client.connect(new StreamableHttpTransport({ url }))).rejects.toThrow(
|
|
"MCP HTTP request failed with status 400: Invalid Accept header",
|
|
);
|
|
});
|
|
|
|
it("hands 401 and insufficient-scope 403 responses to the auth provider with the rejected token", async () => {
|
|
const seen: { status: number; token?: string }[] = [];
|
|
let token = "old";
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
|
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
if (request.headers.authorization === "Bearer old") {
|
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
|
|
return;
|
|
}
|
|
if (message.method === "tools/call" && request.headers.authorization === "Bearer new") {
|
|
response.writeHead(403, { "www-authenticate": 'Bearer error="insufficient_scope", scope="admin"' }).end();
|
|
return;
|
|
}
|
|
return protocolHandler(request, response, requests, message);
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(
|
|
new StreamableHttpTransport({
|
|
url,
|
|
openGetStream: false,
|
|
authProvider: {
|
|
token: async () => token,
|
|
onUnauthorized: async ({ response, token: rejected }) => {
|
|
seen.push({ status: response.status, token: rejected });
|
|
if (response.status === 401) token = "new";
|
|
else token = "admin";
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(await client.callTool("echo")).toEqual({ content: [{ type: "text", text: "hello" }] });
|
|
expect(seen).toEqual([
|
|
{ status: 401, token: "old" },
|
|
{ status: 403, token: "new" },
|
|
]);
|
|
await client.close();
|
|
});
|
|
|
|
// #10598: a request cancelled or timed out while waiting for a shared refresh must not be retried.
|
|
it("does not retry requests that were cancelled while authorization was recovering", async () => {
|
|
let token = "old";
|
|
const executed: unknown[] = [];
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method !== "POST") return protocolHandler(request, response, requests);
|
|
const message = JSON.parse(await readBody(request)) as Record<string, unknown>;
|
|
if (message.method === "tools/call" && request.headers.authorization === "Bearer old") {
|
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
|
|
return;
|
|
}
|
|
if (message.method === "tools/call") executed.push((message.params as { name: string }).name);
|
|
return protocolHandler(request, response, requests, message);
|
|
});
|
|
let releaseRefresh = () => {};
|
|
const refreshGate = new Promise<void>((resolve) => {
|
|
releaseRefresh = resolve;
|
|
});
|
|
let refresh: Promise<void> | undefined;
|
|
let refreshes = 0;
|
|
let waiting = 0;
|
|
let allWaiting = () => {};
|
|
const waitingGate = new Promise<void>((resolve) => {
|
|
allWaiting = resolve;
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(
|
|
new StreamableHttpTransport({
|
|
url,
|
|
openGetStream: false,
|
|
authProvider: {
|
|
token: async () => token,
|
|
onUnauthorized: async () => {
|
|
if (!refresh) {
|
|
refreshes++;
|
|
refresh = refreshGate.then(() => {
|
|
token = "new";
|
|
});
|
|
}
|
|
if (++waiting === 3) allWaiting();
|
|
await refresh;
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
const controller = new AbortController();
|
|
const aborted = client.callTool("aborted", {}, { signal: controller.signal });
|
|
const timedOut = client.callTool("timed-out", {}, { timeoutMs: 200 });
|
|
const kept = client.callTool("kept");
|
|
await waitingGate;
|
|
controller.abort();
|
|
await expect(aborted).rejects.toMatchObject({ name: "AbortError" });
|
|
await expect(timedOut).rejects.toMatchObject({ name: "McpTimeoutError" });
|
|
releaseRefresh();
|
|
expect(await kept).toEqual({ content: [{ type: "text", text: "hello" }] });
|
|
expect(executed).toEqual(["kept"]);
|
|
expect(refreshes).toBe(1);
|
|
await client.close();
|
|
});
|
|
|
|
// #10565: the auth provider may refresh over the network, which closing must not wait for.
|
|
it("closes the session with the last request's token, without asking the auth provider", async () => {
|
|
const { url, requests } = await startServer(protocolHandler);
|
|
let calls = 0;
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(
|
|
new StreamableHttpTransport({
|
|
url,
|
|
openGetStream: false,
|
|
authProvider: { token: async () => `token-${++calls}` },
|
|
}),
|
|
);
|
|
const before = calls;
|
|
await client.close();
|
|
expect(calls).toBe(before);
|
|
const deletes = requests.filter((entry) => entry.method === "DELETE");
|
|
expect(deletes.map((entry) => entry.headers.authorization)).toEqual([`Bearer token-${before}`]);
|
|
expect(deletes[0]?.headers["mcp-session-id"]).toBe("session-1");
|
|
});
|
|
|
|
// #10188: Cloudflare Workers reject the platform fetch when called with a receiver other than globalThis.
|
|
it("calls fetch without a receiver", async () => {
|
|
const realFetch = globalThis.fetch;
|
|
const strictFetch = function (this: unknown, input: string | URL, init?: RequestInit) {
|
|
if (this !== undefined && this !== globalThis) throw new TypeError("Illegal invocation");
|
|
return realFetch(input, init);
|
|
} satisfies McpFetch;
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method === "POST" && request.headers.authorization === undefined) {
|
|
await readBody(request);
|
|
response.writeHead(401, { "www-authenticate": "Bearer" }).end();
|
|
return;
|
|
}
|
|
return protocolHandler(request, response, requests);
|
|
});
|
|
const connect = async (fetch: McpFetch | undefined) => {
|
|
let token: string | undefined;
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(
|
|
new StreamableHttpTransport({
|
|
url,
|
|
fetch,
|
|
openGetStream: false,
|
|
authProvider: {
|
|
token: async () => token,
|
|
onUnauthorized: async (context) => {
|
|
await (await context.fetch(url)).body?.cancel();
|
|
token = "token";
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(await client.listTools()).toEqual([{ name: "echo", inputSchema: { type: "object" } }]);
|
|
await client.close();
|
|
};
|
|
|
|
await connect(strictFetch);
|
|
vi.stubGlobal("fetch", strictFetch);
|
|
try {
|
|
await connect(undefined);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it("classifies an expired established session", async () => {
|
|
let posts = 0;
|
|
const { url } = await startServer(async (request, response, requests) => {
|
|
if (request.method === "POST" && posts++ >= 2) {
|
|
await readBody(request);
|
|
response.statusCode = 404;
|
|
response.end("gone");
|
|
return;
|
|
}
|
|
await protocolHandler(request, response, requests);
|
|
});
|
|
const client = new McpClient({ name: "http-test", version: "1.0.0" });
|
|
await client.connect(new StreamableHttpTransport({ url, openGetStream: false }));
|
|
await expect(client.listTools()).rejects.toBeInstanceOf(McpSessionExpiredError);
|
|
await client.close();
|
|
});
|
|
});
|