Files
reverse-engineering-anything/tests/support/mcp/streamableHttpProcess.ts
T
Edgar ArakelyanandClaude Fable 5.1 46dd8f0e2b feat(mcp): serve the MCP server over streamable HTTP
Add rea mcp --http [--host] [--port]: the same server rea speaks over stdio,
served over MCP Streamable HTTP on a loopback listener by default, using the
SDK's web-standard transport already in the pinned @modelcontextprotocol/server
behind a small node:http bridge. One client and one session at a time; DELETE
ends the session without running provider teardown, a new client's initialize
replaces a session left open, foreign Host headers are refused on loopback
binds, and shutdown is signals-only. Stdio stays the default and registered
transport; server.json and client registrations are unchanged.

Refs morluto/rea#4933

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-17 21:05:10 -07:00

92 lines
2.5 KiB
TypeScript

import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { resolve as resolvePath } from "node:path";
import type { TestProcesses } from "../process/processFixture.js";
const LISTENING_MESSAGE = "MCP transport listening";
const STARTUP_TIMEOUT_MS = 30_000;
/** A compiled `rea mcp --http` child and the URL it announced. */
export interface McpHttpProcess {
readonly child: ChildProcessWithoutNullStreams;
readonly url: URL;
/** Everything the child has written to stderr so far. */
readonly stderr: () => string;
}
/** Find the bound URL in the pino JSON lines the listener writes to stderr. */
const listeningUrl = (stderr: string): URL | undefined => {
for (const line of stderr.split("\n")) {
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (
typeof entry === "object" &&
entry !== null &&
"msg" in entry &&
entry.msg === LISTENING_MESSAGE &&
"url" in entry &&
typeof entry.url === "string"
)
return new URL(entry.url);
}
return undefined;
};
/** Spawn `rea mcp --http --port 0` with stdin closed and resolve once it names its URL. */
export const startMcpHttpProcess = async (
processes: TestProcesses,
arguments_: readonly string[] = [],
): Promise<McpHttpProcess> => {
const child = processes.spawn(
process.execPath,
[
resolvePath("scripts/rea.mjs"),
"mcp",
"--http",
"--port",
"0",
...arguments_,
],
{
env: {
PATH: process.env.PATH ?? "",
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
},
},
);
// A closed stdin must not end the HTTP listener; only signals do.
child.stdin.end();
let stderr = "";
const url = await new Promise<URL>((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`No listening line within ${String(STARTUP_TIMEOUT_MS)} ms:\n${stderr}`,
),
),
STARTUP_TIMEOUT_MS,
);
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
const found = listeningUrl(stderr);
if (found === undefined) return;
clearTimeout(timer);
resolve(found);
});
child.once("exit", (code, signal) => {
clearTimeout(timer);
reject(
new Error(
`Listener exited early (${String(code)}, ${String(signal)}):\n${stderr}`,
),
);
});
});
return { child, url, stderr: () => stderr };
};