Files
rea/tests/boundary/mcp/streamableHttpMcp.test.ts
T
Edgar Arakelyanandgensokyo-dev 60bd646478 feat(mcp): add opt-in streamable HTTP transport (#4955)
Add an explicit Streamable HTTP entry for environment/gateway hosts while keeping stdio registrations and default behavior. Preserve the shared provider/session owner across DELETE and replacement; fence and drain tool/completion work before handover or teardown.

Bound HTTP input and plain-response admission, cancel refused bodies, join whole exchanges and asynchronous response cleanup before provider hooks, gate startup readiness, and reject queued HTTP/1.1 pipelining while retaining sequential keep-alive.

Validation: 8 static/build tasks, 83 adapter tests and registry metadata pass. A real installed gh binary produces seven equivalent CLI/stdio/HTTP reports with DELETE/replacement and clean signal shutdown. Broader local native-provider/ownership failures reproduce on clean main and are documented with limitations in the PR.

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-19 23:58:51 +08:00

86 lines
3.3 KiB
TypeScript

import {
Client,
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/client";
import { describe, expect } from "vitest";
import { expectForeignHostRefused } from "../../support/http/rawRequest.js";
import { startMcpHttpProcess } from "../../support/mcp/streamableHttpProcess.js";
import { processTest } from "../../support/process/processFixture.js";
import { assertAdvertisedMcpContracts } from "./mcpContractHarness.js";
const HTTP_BOUNDARY_TIMEOUT_MS = 90_000;
const openSession = async (url: URL, name: string) => {
const transport = new StreamableHTTPClientTransport(url);
const client = new Client({ name, version: "1" });
await client.connect(transport);
expect(transport.sessionId).toEqual(expect.any(String));
return { transport, client };
};
/** The shared target answers with its identity only while its providers are still open. */
const expectIdentity = async (client: Client): Promise<void> => {
const identity = await client.callTool({
name: "binary_session",
arguments: {},
});
expect(identity.isError).toBeFalsy();
expect(identity.structuredContent).toMatchObject({
result: {
server_identity: { package: { version: expect.any(String) } },
},
});
};
describe("rea mcp --http", () => {
processTest(
"serves initialize, tools/list, a tool call and DELETE, then keeps serving later clients",
async ({ processes }) => {
const started = await startMcpHttpProcess(processes);
expect(started.url.hostname).toBe("127.0.0.1");
expect(started.url.pathname).toBe("/mcp");
let stdout = "";
started.child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
const first = await openSession(started.url, "streamable-http-boundary");
const { tools } = await first.client.listTools();
assertAdvertisedMcpContracts(["binary_session"], tools);
await expectIdentity(first.client);
await first.transport.terminateSession();
expect(first.transport.sessionId).toBeUndefined();
await first.client.close();
// DELETE ended the session, not the server: the next client's tools still work.
const second = await openSession(started.url, "next-session");
await expectIdentity(second.client);
// A client that leaves without DELETE does not block the listener: the next initialize replaces it.
const third = await openSession(started.url, "replacing-session");
expect(third.transport.sessionId).not.toBe(second.transport.sessionId);
await expectIdentity(third.client);
await expect(
second.client.callTool({ name: "binary_session", arguments: {} }),
).rejects.toThrow();
await second.client.close();
await third.transport.terminateSession();
await third.client.close();
expect(stdout).toBe("");
expect(started.stderr()).toContain('"transport":"streamable-http"');
expect(started.stderr()).toContain(
"MCP HTTP session replaced by a new client",
);
},
HTTP_BOUNDARY_TIMEOUT_MS,
);
processTest(
"refuses a non-loopback Host header on the loopback bind",
async ({ processes }) => {
const { url } = await startMcpHttpProcess(processes);
const refused = await expectForeignHostRefused(url);
expect(refused.body).not.toContain("127.0.0.1");
},
HTTP_BOUNDARY_TIMEOUT_MS,
);
});