mirror of
https://github.com/morluto/rea.git
synced 2026-10-31 20:06:14 +00:00
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>
This commit is contained in:
1 parent
46dd8f0e2b
commit
b0ec63cc03
8 files changed
+283
-61
No files matched your search
+2
-2
@@ -322,8 +322,8 @@ rea mcp --http --port 8765
|
||||
|
||||
`--host` and `--port` default to `MCP_HOST` and `MCP_PORT` from the environment,
|
||||
then to `127.0.0.1` and an ephemeral port. The bound URL is written to stderr as
|
||||
one JSON log line (`"msg":"MCP transport listening"`); nothing is written to
|
||||
stdout. `rea mcp --http --help` lists the accepted options. The listener serves
|
||||
one JSON log line (`"msg":"MCP transport listening"`) whatever `REA_LOG_LEVEL`
|
||||
is set to; nothing is written to stdout. `rea mcp --http --help` lists the accepted options. The listener serves
|
||||
one client at a time: a session ends with the client's `DELETE`, and a client
|
||||
that connects while another session is open takes it over, so a client that
|
||||
exited without ending its session needs no restart. The listener runs until
|
||||
|
||||
+15
-6
@@ -427,7 +427,8 @@ stdio is the default transport and the only one that client registrations,
|
||||
streamable HTTP transport at `/mcp`; it replaces stdio for that process rather
|
||||
than running beside it. Host and port come from the flags, then `MCP_HOST` and
|
||||
`MCP_PORT`, then `127.0.0.1` and an ephemeral port. The bound URL is announced
|
||||
as one JSON log line on stderr (`"msg":"MCP transport listening"` with `url`);
|
||||
as one JSON log line on stderr (`"msg":"MCP transport listening"` with `url`)
|
||||
at every `REA_LOG_LEVEL`, because an ephemeral port is otherwise unreachable;
|
||||
stdout stays unused.
|
||||
|
||||
The HTTP server is stateful and serves one client at a time on one server
|
||||
@@ -439,11 +440,19 @@ and the shared active target, and the next client opens a fresh session against
|
||||
them. The newest client replaces an open session: an `initialize` that arrives
|
||||
while a session is open ends that session instead of being refused, because a
|
||||
client that exits without `DELETE` must not wedge the listener; the replaced
|
||||
client's open streams are closed and its later requests are refused. Requests
|
||||
that carry no valid session id are refused with `400` or `404` as the SDK
|
||||
transport defines. On a loopback bind, requests whose `Host` header is outside
|
||||
the SDK loopback allowlist are refused with `403`; a non-loopback bind relaxes
|
||||
that check and logs the relaxation. Request bodies are bounded by the SDK's
|
||||
client's open streams are closed and its later requests are refused. Only an
|
||||
`initialize` that carries the streamable headers (`Content-Type:
|
||||
application/json` and an `Accept` naming both `application/json` and
|
||||
`text/event-stream`) and passes the Host and Origin checks can replace a
|
||||
session; any other sessionless POST goes to the live transport, which refuses
|
||||
it without disturbing the session. Requests that carry no valid session id are
|
||||
refused with `400` or `404` as the SDK transport defines. A bind is loopback by
|
||||
address class, not spelling: any `127.0.0.0/8` address, `::1`, an IPv4-mapped
|
||||
loopback and `localhost` names. On such a bind, requests whose `Host` header is
|
||||
outside the SDK loopback allowlist plus the bind's own hostname are refused with
|
||||
`403`, and a browser `Origin` outside that allowlist is refused the same way (a
|
||||
missing `Origin` passes); a non-loopback bind relaxes both checks and logs the
|
||||
relaxation. Request bodies are bounded by the SDK's
|
||||
4 MiB limit, and the response budget keeps its stdio-derived floor. The
|
||||
listener ends on SIGINT or SIGTERM only, not on stdin EOF, and exits `130` or
|
||||
`143` after cleanup settles.
|
||||
|
||||
@@ -16,7 +16,7 @@ client registration keep stdio.
|
||||
dispatcher without changing bare `mcp`, `mcp doctor` or `mcp add`.
|
||||
- Resolve host and port from the flags, then `MCP_HOST`/`MCP_PORT` from the
|
||||
environment snapshot, then `127.0.0.1` and port `0`; announce the bound URL as
|
||||
one pino JSON line on stderr and leave stdout unused.
|
||||
one pino JSON line on stderr at every log level and leave stdout unused.
|
||||
- Use `WebStandardStreamableHTTPServerTransport` in stateful mode around one
|
||||
server instance built once. `DELETE` ends the session on its transport without
|
||||
running the instance's shutdown hooks, so the same instance keeps its
|
||||
@@ -29,8 +29,13 @@ client registration keep stdio.
|
||||
- Bridge node:http to web Request/Response in `src/server/http/`, streaming
|
||||
response chunks as they are produced and turning a client disconnect into a
|
||||
request abort and body cancellation.
|
||||
- Validate the `Host` header with the SDK loopback allowlist for loopback binds;
|
||||
relax it for other binds and log that relaxation.
|
||||
- Classify a loopback bind by address class (`127.0.0.0/8`, `::1`, IPv4-mapped
|
||||
loopback, `localhost` names), validate the `Host` and `Origin` headers with
|
||||
the SDK loopback allowlist plus the bind's own hostname on such binds, and
|
||||
relax both for other binds and log that relaxation.
|
||||
- Let only an `initialize` that carries the streamable headers and passes those
|
||||
checks replace an open session; refuse requests once the slot is closed, join
|
||||
an arming session during shutdown, and keep close stages retryable.
|
||||
- End the HTTP listener on SIGINT/SIGTERM only, never on stdin EOF, and exit with
|
||||
the signal status (`130`/`143`) after cleanup settles.
|
||||
- Export `serveStreamableHttp({ host, port, createMcpServer, routes, logger })`
|
||||
|
||||
@@ -155,3 +155,44 @@ re-runs, not by repeating the full projects on an idle host. The error-level
|
||||
log line that names the bind cause was observed in earlier runs, not asserted
|
||||
by `mainErrors.test.ts`. Response budgets keep the stdio-derived floor and were
|
||||
not re-measured over HTTP. Windows was not run.
|
||||
|
||||
## Automated review findings on the PR (2026-10-18)
|
||||
|
||||
Codex and Greptile left eight inline findings on morluto/rea#4955 at
|
||||
46dd8f0e; each was checked against the code and addressed in one follow-up
|
||||
commit, with a regression test where the behaviour is observable:
|
||||
|
||||
- A late `initialize` could arm a fresh transport after shutdown (Codex, P1):
|
||||
the slot now records that it is closed, `close()` joins an arming session
|
||||
before ending it, and every request after that is refused with `503`. Test:
|
||||
an initialize racing `close()` gets `503`, the shutdown hook fires once, and
|
||||
the next request gets `503`.
|
||||
- A failed `close()` was memoized forever (both reviewers, P1): the close runs
|
||||
three stages once each and a failed stage is retried by the next call. Test:
|
||||
the instance's first `close()` rejects, the second call resolves and the port
|
||||
is released.
|
||||
- Loopback aliases skipped Host validation (both reviewers, P2 and P1): a bind
|
||||
is now loopback by address class (`127.0.0.0/8`, `::1`, IPv4-mapped loopback,
|
||||
`localhost` names) after the same normalization the SDK applies to the Host
|
||||
header, and the allowlist adds the bind's own hostname so requests to
|
||||
`127.0.0.2` or `localhost.` are still served. Test: twelve spellings.
|
||||
- A sessionless POST could close the live session before any header check
|
||||
(Greptile, P1): on loopback binds the route validates `Origin` with the same
|
||||
allowlist as `Host`, and only a POST carrying `Content-Type:
|
||||
application/json` and an `Accept` naming both `application/json` and
|
||||
`text/event-stream` may replace a session; others reach the live transport,
|
||||
which refuses them. Test: a `text/plain` initialize and one with a foreign
|
||||
`Origin` are refused (4xx and 403) while the connected client keeps echoing.
|
||||
- The bound URL disappeared at quiet log levels (Greptile, P2): the listening
|
||||
line is written through a child logger pinned to `info`. Test: a `warn`
|
||||
logger still receives the line with the URL.
|
||||
- `rea mcp -h --http` reached the ordinary CLI (Greptile, P2): the dispatcher
|
||||
predicate accepts any flag after the entry. Test: the dispatcher case.
|
||||
|
||||
Checks after the change: `oxfmt --check .` clean; `tsc --noEmit` clean;
|
||||
`oxlint` on the touched sources clean after extracting the staged close into
|
||||
its own function; `npx knip` no findings; `npm run jscpd` 3.35 % duplicated
|
||||
lines; `scan:todos` none; `verify:test-discovery` 1,583 files once; `vitest
|
||||
run` over the listener and bridge adapters tests 14 passed in 2 files; after
|
||||
`npm run build:cached`, `vitest run` over the dispatcher, option, streamable
|
||||
HTTP, shutdown and main-error boundary tests 22 passed in 5 files.
|
||||
+3
-2
@@ -15,10 +15,11 @@ process.env.REA_PACKAGE_VERSION = packageJson.version;
|
||||
const isMcpEntry = args[0] === "--mcp" || args[0] === "mcp";
|
||||
const isMcpMode = isMcpEntry && args.length === 1;
|
||||
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
|
||||
// Only flags may follow the entry: `mcp add --http` stays Incur's `mcp add`.
|
||||
// Only flags may follow the entry: `mcp add --http` stays Incur's `mcp add`,
|
||||
// while `mcp -h --http` reaches the HTTP entry's own help.
|
||||
const isMcpHttpMode =
|
||||
isMcpEntry &&
|
||||
(args[1]?.startsWith("--") ?? false) &&
|
||||
(args[1]?.startsWith("-") ?? false) &&
|
||||
args.slice(1).includes("--http");
|
||||
/** Exit status of a writer terminated by SIGPIPE (128 + 13). */
|
||||
const SIGPIPE_EXIT_STATUS = 141;
|
||||
|
||||
@@ -216,14 +216,108 @@ it("reports a port that cannot be bound as a typed listen error naming the cause
|
||||
await expect(failed).rejects.toThrow(/EADDRINUSE/u);
|
||||
});
|
||||
|
||||
it("keeps the SDK loopback allowlist only for loopback binds", () => {
|
||||
expect(hostHeaderAllowlist("127.0.0.1")).toEqual([
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"[::1]",
|
||||
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("localhost")).toBeDefined();
|
||||
expect(hostHeaderAllowlist("::1")).toBeDefined();
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
hostHeaderValidationResponse,
|
||||
isInitializeRequest,
|
||||
localhostAllowedHostnames,
|
||||
originValidationResponse,
|
||||
readRequestBody,
|
||||
WebStandardStreamableHTTPServerTransport,
|
||||
type McpServer,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { isIPv4, isIPv6 } from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { toWebRequest, writeWebResponse } from "./nodeHttpBridge.js";
|
||||
@@ -23,14 +25,6 @@ import { StreamableHttpListenError } from "./streamableHttpListenError.js";
|
||||
/** MCP endpoint path shared by `rea mcp --http` and agent environment servers. */
|
||||
export const MCP_HTTP_PATH = "/mcp";
|
||||
|
||||
/** Bind literals that keep the SDK loopback Host allowlist. */
|
||||
const LOOPBACK_HOSTS: ReadonlySet<string> = new Set([
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"::1",
|
||||
"[::1]",
|
||||
]);
|
||||
|
||||
/** One plain HTTP route served beside the MCP endpoint as web Request to Response. */
|
||||
export interface StreamableHttpRoute {
|
||||
readonly method: string;
|
||||
@@ -55,9 +49,41 @@ export interface StreamableHttpServer {
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Host header allowlist for a bind, or undefined when validation is relaxed. */
|
||||
export const hostHeaderAllowlist = (host: string): string[] | undefined =>
|
||||
LOOPBACK_HOSTS.has(host) ? localhostAllowedHostnames() : undefined;
|
||||
/** The bind host as the SDK compares Host headers: lowercase, IPv6 bracketed and canonical. */
|
||||
const normalizedHostname = (host: string): string | undefined => {
|
||||
try {
|
||||
return new URL(`http://${isIPv6(host) ? `[${host}]` : host}`).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const isLoopbackIpv4 = (address: string): boolean =>
|
||||
isIPv4(address) && address.startsWith("127.");
|
||||
/** Loopback by address class and localhost names, so no spelling of a local bind skips validation. */
|
||||
const isLoopbackHostname = (hostname: string): boolean => {
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "localhost." ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname.endsWith(".localhost.")
|
||||
)
|
||||
return true;
|
||||
if (isLoopbackIpv4(hostname)) return true;
|
||||
if (!hostname.startsWith("[") || !hostname.endsWith("]")) return false;
|
||||
const inner = hostname.slice(1, -1);
|
||||
if (inner === "::1") return true;
|
||||
if (!inner.startsWith("::ffff:")) return false;
|
||||
const mapped = inner.slice("::ffff:".length);
|
||||
if (isLoopbackIpv4(mapped)) return true;
|
||||
const hex = /^([0-9a-f]{1,4}):[0-9a-f]{1,4}$/u.exec(mapped);
|
||||
return hex !== null && Number.parseInt(hex[1] ?? "0", 16) >> 8 === 0x7f;
|
||||
};
|
||||
/** Host and Origin allowlist for a loopback bind, including the bind's own hostname; undefined relaxes validation. */
|
||||
export const hostHeaderAllowlist = (host: string): string[] | undefined => {
|
||||
const hostname = normalizedHostname(host);
|
||||
if (hostname === undefined || !isLoopbackHostname(hostname)) return undefined;
|
||||
return [...new Set([...localhostAllowedHostnames(), hostname])];
|
||||
};
|
||||
|
||||
const jsonError = (status: number, code: number, message: string): Response =>
|
||||
Response.json(
|
||||
@@ -72,10 +98,27 @@ const describe = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const NO_SHUTDOWN_HOOK = (): void => undefined;
|
||||
const SHUTTING_DOWN = (): Response =>
|
||||
jsonError(503, -32000, "Server shutting down");
|
||||
|
||||
/** The headers the SDK requires of a streamable POST; a request without them is left to the transport's refusal. */
|
||||
const hasStreamablePostHeaders = (request: Request): boolean => {
|
||||
const accept = request.headers.get("accept") ?? "";
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
return (
|
||||
accept.includes("application/json") &&
|
||||
accept.includes("text/event-stream") &&
|
||||
/^application\/json\b/iu.test(contentType.trim())
|
||||
);
|
||||
};
|
||||
|
||||
/** Only a new client's initialize travels without a session id; peek within the SDK body bound. */
|
||||
const isInitializePost = async (request: Request): Promise<boolean> => {
|
||||
if (request.method !== "POST" || request.headers.has("mcp-session-id"))
|
||||
if (
|
||||
request.method !== "POST" ||
|
||||
request.headers.has("mcp-session-id") ||
|
||||
!hasStreamablePostHeaders(request)
|
||||
)
|
||||
return false;
|
||||
const probe = request.clone();
|
||||
try {
|
||||
@@ -102,6 +145,7 @@ interface LiveSession {
|
||||
export class McpSessionSlot {
|
||||
#live: LiveSession | undefined;
|
||||
#arming: Promise<WebStandardStreamableHTTPServerTransport> | undefined;
|
||||
#closed = false;
|
||||
/** The instance's own close hook, captured once so every session end restores exactly it. */
|
||||
readonly #shutdownHook: () => void;
|
||||
|
||||
@@ -114,25 +158,34 @@ export class McpSessionSlot {
|
||||
|
||||
/** Route one MCP request; a new client's initialize replaces a session its predecessor left open. */
|
||||
async handle(request: Request): Promise<Response> {
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
const open = this.#live;
|
||||
if (
|
||||
open?.transport.sessionId !== undefined &&
|
||||
(await isInitializePost(request))
|
||||
) {
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
this.logger?.info("MCP HTTP session replaced by a new client");
|
||||
await open.transport.close();
|
||||
}
|
||||
return (await this.#current()).handleRequest(request);
|
||||
const transport = await this.#current();
|
||||
// Shutdown may have closed the slot while this request was arming it.
|
||||
if (this.#closed) return SHUTTING_DOWN();
|
||||
return transport.handleRequest(request);
|
||||
}
|
||||
|
||||
/** End the live session as part of server shutdown, so the instance's onclose runs. */
|
||||
/** End the live session as part of server shutdown, so the instance's onclose runs; later requests are refused. */
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
await this.#arming?.catch(() => undefined);
|
||||
const open = this.#live;
|
||||
this.#live = undefined;
|
||||
await open?.shutdown();
|
||||
}
|
||||
|
||||
#current(): Promise<WebStandardStreamableHTTPServerTransport> {
|
||||
if (this.#closed)
|
||||
return Promise.reject(new Error("MCP HTTP session slot is closed"));
|
||||
if (this.#live !== undefined) return Promise.resolve(this.#live.transport);
|
||||
this.#arming ??= this.#arm().finally(() => {
|
||||
this.#arming = undefined;
|
||||
@@ -194,10 +247,12 @@ export const serveStreamableHttp = async (
|
||||
let stopped = false;
|
||||
const route = async (request: Request): Promise<Response> => {
|
||||
if (stopped) return jsonError(503, -32000, "Server shutting down");
|
||||
// One loopback allowlist gates Host (DNS rebinding) and Origin (browser pages); a missing Origin passes.
|
||||
const refused =
|
||||
allowedHosts === undefined
|
||||
? undefined
|
||||
: hostHeaderValidationResponse(request, allowedHosts);
|
||||
: (hostHeaderValidationResponse(request, allowedHosts) ??
|
||||
originValidationResponse(request, allowedHosts));
|
||||
if (refused !== undefined) return refused;
|
||||
const { pathname } = new URL(request.url);
|
||||
if (pathname === MCP_HTTP_PATH) return slot.handle(request);
|
||||
@@ -249,13 +304,12 @@ export const serveStreamableHttp = async (
|
||||
{ host },
|
||||
"MCP HTTP Host header validation relaxed for a non-loopback bind",
|
||||
);
|
||||
logger?.info(
|
||||
{ transport: "streamable-http", url },
|
||||
"MCP transport listening",
|
||||
);
|
||||
let closing: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
closing ??= (async () => {
|
||||
// The URL is the only way to reach an ephemeral port, so the announcement ignores the log threshold.
|
||||
logger
|
||||
?.child({}, { level: "info" })
|
||||
.info({ transport: "streamable-http", url }, "MCP transport listening");
|
||||
const close = stagedClose([
|
||||
async () => {
|
||||
stopped = true;
|
||||
httpServer.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -263,10 +317,32 @@ export const serveStreamableHttp = async (
|
||||
error === undefined ? resolve() : reject(error),
|
||||
);
|
||||
});
|
||||
await slot.close();
|
||||
await instance.close();
|
||||
})();
|
||||
return closing;
|
||||
};
|
||||
},
|
||||
() => slot.close(),
|
||||
() => instance.close(),
|
||||
]);
|
||||
return { url, close };
|
||||
};
|
||||
|
||||
/** Run close stages in order once each; a failed stage is retried by the next call instead of replaying its rejection. */
|
||||
const stagedClose = (
|
||||
stages: readonly (() => Promise<void>)[],
|
||||
): (() => Promise<void>) => {
|
||||
const done = stages.map(() => false);
|
||||
let closing: Promise<void> | undefined;
|
||||
return () => {
|
||||
if (closing !== undefined) return closing;
|
||||
const attempt = (async () => {
|
||||
for (const [index, stage] of stages.entries()) {
|
||||
if (done[index]) continue;
|
||||
await stage();
|
||||
done[index] = true;
|
||||
}
|
||||
})();
|
||||
closing = attempt;
|
||||
void attempt.catch(() => {
|
||||
if (closing === attempt) closing = undefined;
|
||||
});
|
||||
return attempt;
|
||||
};
|
||||
};
|
||||
@@ -100,20 +100,16 @@ describe("executable dispatcher", () => {
|
||||
adapter: "cli",
|
||||
args: ["--mcp", "extra"],
|
||||
});
|
||||
await expect(invoke(["mcp", "--http", "--port", "0"])).resolves.toEqual({
|
||||
adapter: "mcp-http",
|
||||
args: ["--http", "--port", "0"],
|
||||
});
|
||||
await expect(invoke(["--mcp", "--http"])).resolves.toEqual({
|
||||
adapter: "mcp-http",
|
||||
args: ["--http"],
|
||||
});
|
||||
await expect(
|
||||
invoke(["mcp", "--host", "127.0.0.1", "--http"]),
|
||||
).resolves.toEqual({
|
||||
adapter: "mcp-http",
|
||||
args: ["--host", "127.0.0.1", "--http"],
|
||||
});
|
||||
for (const args of [
|
||||
["mcp", "--http", "--port", "0"],
|
||||
["--mcp", "--http"],
|
||||
["mcp", "-h", "--http"],
|
||||
["mcp", "--host", "127.0.0.1", "--http"],
|
||||
])
|
||||
await expect(invoke(args)).resolves.toEqual({
|
||||
adapter: "mcp-http",
|
||||
args: args.slice(1),
|
||||
});
|
||||
await expect(invoke(["mcp", "doctor", "--http"])).resolves.toEqual({
|
||||
adapter: "mcp-doctor",
|
||||
args: ["--http"],
|
||||
|
||||
Reference in new issue
Block a user