Files
oh-my-pi/packages/coding-agent/test/mcp-http-transport.test.ts
T
roboomp eaa5f46d32 fix(mcp): resumed interrupted HTTP response streams
Implemented 2025-11-25 Streamable HTTP polling semantics for POST SSE responses: retain event IDs and retry intervals, wait as instructed, and reconnect with GET plus Last-Event-ID until the originating JSON-RPC response arrives.

Extended the shared SSE parser to expose valid id/retry fields and control-only events so reconnecting consumers do not need to reparse raw lines.

Fixes #8264
2026-08-11 18:21:12 +00:00

217 lines
7.1 KiB
TypeScript

import { afterEach, describe, expect, it } from "bun:test";
import { HttpTransport } from "@oh-my-pi/pi-coding-agent/mcp/transports/http";
const encoder = new TextEncoder();
const REQUEST_TIMEOUT_MS = 50;
const GUARD_TIMEOUT_MS = 500;
let server: Bun.Server<undefined> | null = null;
type ToolList = {
tools: { name: string; inputSchema: { type: string } }[];
};
afterEach(() => {
server?.stop(true);
server = null;
});
async function connectedTransport(): Promise<HttpTransport> {
if (!server) throw new Error("Test server was not started");
const transport = new HttpTransport({
type: "http",
url: `http://127.0.0.1:${server.port}/mcp`,
timeout: REQUEST_TIMEOUT_MS,
});
await transport.connect();
return transport;
}
function stalledBodyResponse(bodyPrefix: string, init?: ResponseInit): Response {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(bodyPrefix));
},
}),
init,
);
}
// Real time is intentional: this exercises Bun fetch aborting a live HTTP body stream,
// which fake timers do not drive through the socket/readable-stream stack.
async function withPendingGuard<T>(promise: Promise<T>, label: string): Promise<T> {
return await Promise.race([
promise,
Bun.sleep(GUARD_TIMEOUT_MS).then(() => {
throw new Error(`${label} stayed pending past ${GUARD_TIMEOUT_MS}ms`);
}),
]);
}
describe("MCP Streamable HTTP transport timeouts", () => {
it("keeps the request timeout active until a JSON response body is fully read", async () => {
server = Bun.serve({
port: 0,
fetch() {
return stalledBodyResponse('{"jsonrpc":"2.0","id":"', {
headers: { "Content-Type": "application/json" },
});
},
});
const transport = await connectedTransport();
await expect(withPendingGuard(transport.request("tools/list"), "request")).rejects.toThrow(
`Request timeout after ${REQUEST_TIMEOUT_MS}ms`,
);
});
it("keeps the notify timeout active while reading HTTP error bodies", async () => {
server = Bun.serve({
port: 0,
fetch() {
return stalledBodyResponse("partial failure body", {
status: 500,
headers: { "Content-Type": "text/plain" },
});
},
});
const transport = await connectedTransport();
await expect(withPendingGuard(transport.notify("notifications/initialized"), "notify")).rejects.toThrow(
`Notify timeout after ${REQUEST_TIMEOUT_MS}ms`,
);
});
it("still resolves normal JSON response bodies", async () => {
server = Bun.serve({
port: 0,
fetch() {
return Response.json({
jsonrpc: "2.0",
id: 1,
result: { tools: [{ name: "fast", inputSchema: { type: "object" } }] },
});
},
});
const transport = await connectedTransport();
await expect(withPendingGuard(transport.request<ToolList>("tools/list"), "request")).resolves.toEqual({
tools: [{ name: "fast", inputSchema: { type: "object" } }],
});
});
});
describe("MCP Streamable HTTP protocol version header", () => {
it("omits MCP-Protocol-Version until the version is negotiated", async () => {
const seen: { version: string | null; present: boolean } = { version: null, present: true };
server = Bun.serve({
port: 0,
fetch(req) {
seen.present = req.headers.has("MCP-Protocol-Version");
seen.version = req.headers.get("MCP-Protocol-Version");
return Response.json({ jsonrpc: "2.0", id: 1, result: {} });
},
});
const transport = await connectedTransport();
// No setProtocolVersion yet: this stands in for the initialize request,
// which must not carry the header before negotiation completes.
await withPendingGuard(transport.request("initialize"), "request");
expect(seen.present).toBe(false);
expect(seen.version).toBeNull();
});
it("echoes the negotiated version on requests after setProtocolVersion", async () => {
const seen: { version: string | null } = { version: null };
server = Bun.serve({
port: 0,
fetch(req) {
seen.version = req.headers.get("MCP-Protocol-Version");
return Response.json({ jsonrpc: "2.0", id: 1, result: {} });
},
});
const transport = await connectedTransport();
transport.setProtocolVersion("2025-06-18");
await withPendingGuard(transport.request("tools/list"), "request");
expect(seen.version).toBe("2025-06-18");
});
it("never lets a configured MCP-Protocol-Version reach the server", async () => {
const seen: { pre: string | null; post: string | null } = { pre: null, post: null };
server = Bun.serve({
port: 0,
fetch(req) {
const body = req.headers.get("MCP-Protocol-Version");
return Response.json({ jsonrpc: "2.0", id: 1, result: { seen: body } });
},
});
if (!server) throw new Error("Test server was not started");
const transport = new HttpTransport({
type: "http",
url: `http://127.0.0.1:${server.port}/mcp`,
timeout: REQUEST_TIMEOUT_MS,
headers: { "MCP-Protocol-Version": "1999-01-01" },
});
await transport.connect();
// Pre-negotiation: configured header must be stripped, not leaked.
seen.pre = await withPendingGuard(transport.request<{ seen: string | null }>("initialize"), "request").then(
r => r.seen,
);
// Post-negotiation: the negotiated version wins over the configured one.
transport.setProtocolVersion("2025-11-25");
seen.post = await withPendingGuard(transport.request<{ seen: string | null }>("tools/list"), "request").then(
r => r.seen,
);
expect(seen.pre).toBeNull();
expect(seen.post).toBe("2025-11-25");
});
});
describe("MCP Streamable HTTP POST response resumption", () => {
it("resumes a closed response stream with Last-Event-ID after the requested retry delay", async () => {
const observed: {
lastEventId: string | null;
protocolVersion: string | null;
postClosedAt: number;
resumedAt: number;
} = { lastEventId: null, protocolVersion: null, postClosedAt: 0, resumedAt: 0 };
server = Bun.serve({
port: 0,
fetch(req) {
if (req.method === "POST") {
observed.postClosedAt = performance.now();
return new Response("id: stream-1\nretry: 20\ndata:\n\n", {
headers: { "Content-Type": "text/event-stream" },
});
}
observed.resumedAt = performance.now();
observed.lastEventId = req.headers.get("Last-Event-ID");
observed.protocolVersion = req.headers.get("MCP-Protocol-Version");
return new Response(
'id: stream-2\ndata: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"resumed","inputSchema":{"type":"object"}}]}}\n\n',
{ headers: { "Content-Type": "text/event-stream" } },
);
},
});
if (!server) throw new Error("Test server was not started");
const transport = new HttpTransport({
type: "http",
url: `http://127.0.0.1:${server.port}/mcp`,
timeout: GUARD_TIMEOUT_MS,
});
await transport.connect();
transport.setProtocolVersion("2025-11-25");
await expect(withPendingGuard(transport.request<ToolList>("tools/list"), "request")).resolves.toEqual({
tools: [{ name: "resumed", inputSchema: { type: "object" } }],
});
expect(observed.lastEventId).toBe("stream-1");
expect(observed.protocolVersion).toBe("2025-11-25");
expect(observed.resumedAt - observed.postClosedAt).toBeGreaterThanOrEqual(15);
});
});