Files
rea/src/server/http/serveStreamableHttp.test.ts
T
Edgar ArakelyanandClaude Fable 5.1 b0ec63cc03 fix(mcp): harden the streamable HTTP listener's session replacement and shutdown
Addresses the automated review findings on the PR: refuse requests once the
session slot is closed and join an arming initialize during shutdown; retry
failed close stages instead of replaying the rejection; classify loopback
binds by address class and add the bind's own hostname to the Host and Origin
allowlist; let only a POST with the streamable headers replace an open
session; announce the bound URL at every log level; route `mcp -h --http` to
the HTTP entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 08:14:01 -07:00

324 lines
11 KiB
TypeScript

import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import pino from "pino";
import { afterEach, expect, it } from "vitest";
import { z } from "zod";
import {
expectForeignHostRefused,
rawHttpRequest,
} from "../../../tests/support/http/rawRequest.js";
import {
hostHeaderAllowlist,
McpSessionSlot,
serveStreamableHttp,
type StreamableHttpServer,
} from "./serveStreamableHttp.js";
import { StreamableHttpListenError } from "./streamableHttpListenError.js";
const servers: StreamableHttpServer[] = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => server.close()));
});
/** An echo server whose protocol onclose hook counts as a shutdown, as rea's provider cleanup does. */
const createEchoServer = (onShutdown: () => void): McpServer => {
const server = new McpServer({ name: "echo-fixture", version: "1" });
server.registerTool(
"echo",
{ description: "Echo text", inputSchema: { text: z.string() } },
({ text }) => ({ content: [{ type: "text", text }] }),
);
server.server.onclose = onShutdown;
return server;
};
const start = async (
overrides: Partial<Parameters<typeof serveStreamableHttp>[0]> = {},
) => {
const logs: string[] = [];
const logger = pino({ level: "debug" }, { write: (line) => logs.push(line) });
let factoryCalls = 0;
let shutdownHooks = 0;
const served = await serveStreamableHttp({
host: "127.0.0.1",
port: 0,
createMcpServer: () => {
factoryCalls += 1;
return createEchoServer(() => {
shutdownHooks += 1;
});
},
logger,
...overrides,
});
servers.push(served);
return {
served,
messages: () =>
logs.map((line) => (JSON.parse(line) as { msg: string }).msg),
entries: () =>
logs.map((line) => JSON.parse(line) as Record<string, unknown>),
factoryCalls: () => factoryCalls,
shutdownHooks: () => shutdownHooks,
};
};
const connect = async (url: string) => {
const transport = new StreamableHTTPClientTransport(new URL(url));
const client = new Client({ name: "streamable-http-adapter", version: "1" });
await client.connect(transport);
return { transport, client };
};
const echo = async (client: Client, text: string): Promise<unknown> => {
const result = await client.callTool({ name: "echo", arguments: { text } });
return result.content;
};
/** A new client's initialize as the bridge hands it to the slot: no session id yet. */
const initializeRequest = (): Request =>
new Request("http://127.0.0.1/mcp", {
method: "POST",
headers: {
accept: "application/json, text/event-stream",
"content-type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "racing-client", version: "1" },
},
}),
});
it("announces the bound URL and serves one session at a time on one live instance", async () => {
const { served, entries, messages, factoryCalls, shutdownHooks } =
await start();
expect(new URL(served.url).pathname).toBe("/mcp");
expect(entries()).toContainEqual(
expect.objectContaining({
transport: "streamable-http",
url: served.url,
msg: "MCP transport listening",
}),
);
const first = await connect(served.url);
const sessionId = first.transport.sessionId;
expect(sessionId).toEqual(expect.any(String));
await expect(echo(first.client, "hi")).resolves.toEqual([
{ type: "text", text: "hi" },
]);
// A new client's initialize replaces the open session; the earlier client is refused.
const second = await connect(served.url);
expect(second.transport.sessionId).toEqual(expect.any(String));
expect(second.transport.sessionId).not.toBe(sessionId);
await expect(echo(first.client, "stale")).rejects.toThrow();
await expect(echo(second.client, "live")).resolves.toEqual([
{ type: "text", text: "live" },
]);
await first.client.close();
// DELETE ends the session, not the server: the instance keeps serving.
await second.transport.terminateSession();
expect(second.transport.sessionId).toBeUndefined();
await second.client.close();
const third = await connect(served.url);
await expect(echo(third.client, "again")).resolves.toEqual([
{ type: "text", text: "again" },
]);
expect(factoryCalls()).toBe(1);
expect(shutdownHooks()).toBe(0);
expect(
messages().filter((msg) => msg === "MCP HTTP session opened"),
).toHaveLength(3);
expect(messages()).toContain("MCP HTTP session replaced by a new client");
// Closing the listener with a session open is the shutdown the instance's hook waits for.
await served.close();
expect(shutdownHooks()).toBe(1);
await third.client.close();
});
it("keeps the instance's shutdown hook when two initialize requests race to replace an open session", async () => {
let shutdownHooks = 0;
const slot = new McpSessionSlot(
createEchoServer(() => {
shutdownHooks += 1;
}),
undefined,
);
const opened = await slot.handle(initializeRequest());
expect(opened.status).toBe(200);
// Both requests see the same open session and end it in lockstep.
const raced = await Promise.all([
slot.handle(initializeRequest()),
slot.handle(initializeRequest()),
]);
expect(raced.map((response) => response.status).sort()).toEqual([200, 400]);
const winner = raced.find((response) => response.status === 200);
expect(winner?.headers.get("mcp-session-id")).toEqual(expect.any(String));
expect(shutdownHooks).toBe(0);
// Only the slot's own close is the shutdown the instance's hook waits for.
await slot.close();
expect(shutdownHooks).toBe(1);
});
it("serves plain routes beside the MCP endpoint and answers unknown paths with 404", async () => {
const { served } = await start({
routes: [
{
method: "GET",
path: "/.well-known/probe",
handle: () => Response.json({ probe: true }),
},
],
});
const probe = await fetch(new URL("/.well-known/probe", served.url));
await expect(probe.json()).resolves.toEqual({ probe: true });
const missing = await fetch(new URL("/missing", served.url));
expect(missing.status).toBe(404);
await expect(missing.json()).resolves.toMatchObject({
error: { code: -32601 },
});
const wrongMethod = await fetch(new URL("/.well-known/probe", served.url), {
method: "POST",
body: "{}",
});
expect(wrongMethod.status).toBe(404);
});
it("refuses foreign Host headers on a loopback bind and frees the port on close", async () => {
const { served } = await start();
const url = new URL(served.url);
await expectForeignHostRefused(url);
await served.close();
await served.close();
await expect(rawHttpRequest(url)).rejects.toMatchObject({
code: "ECONNREFUSED",
});
});
it("reports a port that cannot be bound as a typed listen error naming the cause", async () => {
const { served } = await start();
const failed = serveStreamableHttp({
host: "127.0.0.1",
port: Number(new URL(served.url).port),
createMcpServer: () => createEchoServer(() => undefined),
});
await expect(failed).rejects.toBeInstanceOf(StreamableHttpListenError);
await expect(failed).rejects.toThrow(/EADDRINUSE/u);
});
it("keeps the SDK loopback allowlist, plus the bind's own hostname, for every loopback spelling", () => {
const sdk = ["localhost", "127.0.0.1", "[::1]"];
expect(hostHeaderAllowlist("127.0.0.1")).toEqual(sdk);
expect(hostHeaderAllowlist("localhost")).toEqual(sdk);
expect(hostHeaderAllowlist("LOCALHOST")).toEqual(sdk);
expect(hostHeaderAllowlist("::1")).toEqual(sdk);
expect(hostHeaderAllowlist("0:0:0:0:0:0:0:1")).toEqual(sdk);
expect(hostHeaderAllowlist("127.0.0.2")).toEqual([...sdk, "127.0.0.2"]);
expect(hostHeaderAllowlist("localhost.")).toEqual([...sdk, "localhost."]);
expect(hostHeaderAllowlist("::ffff:127.0.0.1")).toEqual([
...sdk,
"[::ffff:7f00:1]",
]);
expect(hostHeaderAllowlist("192.0.2.10")).toBeUndefined();
expect(hostHeaderAllowlist("0.0.0.0")).toBeUndefined();
expect(hostHeaderAllowlist("::")).toBeUndefined();
expect(hostHeaderAllowlist("example.com")).toBeUndefined();
expect(hostHeaderAllowlist("not a host")).toBeUndefined();
});
it("refuses requests once the slot is closed and joins an initialize that is still arming", async () => {
let shutdowns = 0;
const slot = new McpSessionSlot(
createEchoServer(() => {
shutdowns += 1;
}),
undefined,
);
const arming = slot.handle(initializeRequest());
await slot.close();
expect((await arming).status).toBe(503);
expect(shutdowns).toBe(1);
expect((await slot.handle(initializeRequest())).status).toBe(503);
});
it("retries unfinished cleanup after a close stage fails instead of replaying the failure", async () => {
let failures = 1;
const { served } = await start({
createMcpServer: () => {
const server = createEchoServer(() => undefined);
const original = server.close.bind(server);
server.close = async () => {
if (failures > 0) {
failures -= 1;
throw new Error("provider cleanup failed");
}
await original();
};
return server;
},
});
await expect(served.close()).rejects.toThrow("provider cleanup failed");
await expect(served.close()).resolves.toBeUndefined();
await expect(rawHttpRequest(new URL(served.url))).rejects.toMatchObject({
code: "ECONNREFUSED",
});
});
it("leaves a sessionless POST without the streamable headers or with a foreign Origin to refusal, keeping the session", async () => {
const { served } = await start();
const { client } = await connect(served.url);
const url = new URL(served.url);
const body = await initializeRequest().text();
const host = `127.0.0.1:${url.port}`;
const plain = await rawHttpRequest(url, {
method: "POST",
headers: {
host,
"content-type": "text/plain",
accept: "application/json, text/event-stream",
},
body,
});
expect(plain.status).toBeGreaterThanOrEqual(400);
expect(plain.status).toBeLessThan(500);
const foreign = await rawHttpRequest(url, {
method: "POST",
headers: {
host,
origin: "http://attacker.example",
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body,
});
expect(foreign.status).toBe(403);
expect(await echo(client, "still here")).toEqual([
{ type: "text", text: "still here" },
]);
await client.close();
});
it("announces the bound URL even when the log level hides info lines", async () => {
const logs: string[] = [];
const logger = pino({ level: "warn" }, { write: (line) => logs.push(line) });
const { served } = await start({ logger });
expect(
logs.map((line) => JSON.parse(line) as Record<string, unknown>),
).toContainEqual(
expect.objectContaining({
msg: "MCP transport listening",
url: served.url,
}),
);
});