mirror of
https://github.com/morluto/rea.git
synced 2026-11-03 05:16:16 +00:00
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>
92 lines
2.5 KiB
TypeScript
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 };
|
|
};
|