Files
rea/src/server/http/serveStreamableHttp.ts
T
morluto 0c440bfc07 fix(cli): match listener recovery guidance to system failure
Classify listener errno and lifecycle phase at the transport owner. Preserve native cause and unknown codes while distinguishing host lookup, unavailable address, occupied port and permission recovery.

Refs #5455.
2026-10-20 19:54:56 +08:00

716 lines
26 KiB
TypeScript

import {
hostHeaderValidationResponse,
isInitializeRequest,
isJsonContentType,
localhostAllowedHostnames,
originValidationResponse,
readRequestBody,
WebStandardStreamableHTTPServerTransport,
type McpServer,
type McpRequestContext,
type Server,
} from "@modelcontextprotocol/server";
import { randomUUID } from "node:crypto";
import {
createServer as createHttpServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { isIPv4, isIPv6, type AddressInfo } from "node:net";
import type { Logger } from "pino";
import {
toWebRequest,
UnsupportedRequestTargetError,
writeWebResponse,
} from "./nodeHttpBridge.js";
import { listenHttpServer } from "./listenHttpServer.js";
import { StreamableHttpListenError } from "./streamableHttpListenError.js";
import {
NodeHttpRequestOwner,
type HttpAdmissionLifetime,
type NodeHttpExchangeContext,
} from "./NodeHttpRequestOwner.js";
import { RequestTrackingMcpServer } from "../RequestTrackingMcpServer.js";
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
/** MCP endpoint path shared by `rea mcp --http` and agent environment servers. */
export const MCP_HTTP_PATH = "/mcp";
/** One plain HTTP route served beside the MCP endpoint as web Request to Response. */
export interface StreamableHttpRoute {
readonly method: string;
readonly path: string;
readonly handle: (request: Request) => Response | Promise<Response>;
}
export interface ServeStreamableHttpOptions {
readonly host: string;
readonly port: number;
/** Cancellation during listener startup; later shutdown remains owned by close(). */
readonly signal?: AbortSignal;
/** Called once; the instance serves every session in turn on a fresh transport. */
readonly createMcpServer: (
context: McpRequestContext,
) => RequestTrackingMcpServer | Promise<RequestTrackingMcpServer>;
/** Maximum time a client session handover waits for cancelled tool/completion handlers. */
readonly sessionDrainTimeoutMs?: number;
/** Concurrent HTTP input/route work (default 8); unread uploads retain their permit. */
readonly maxConcurrentDispatches?: number;
readonly routes?: readonly StreamableHttpRoute[];
readonly logger?: Logger;
/** Reports listener failures after binding; request failures are logged instead. */
readonly onerror?: (error: StreamableHttpListenError) => void;
/** Close the server instance as the last stage of `close`; false when the factory's owner closes it. */
readonly closeInstance?: boolean;
}
export interface StreamableHttpServer {
/** The bound MCP endpoint URL. */
readonly url: string;
/** Stop incoming dispatch and drain handlers before shared provider owners close. */
readonly prepareShutdown: () => Promise<void>;
readonly close: () => Promise<void>;
}
/** 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.");
/** Classify the native bound IP; configured DNS names are not binding evidence. */
const isLoopbackAddress = (hostname: string): boolean => {
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;
};
/** Classify the bound address first, then allow its address and configured name. */
export const hostHeaderAllowlist = (
host: string,
boundAddress: string,
): string[] | undefined => {
const bound = normalizedHostname(boundAddress);
if (bound === undefined || !isLoopbackAddress(bound)) return undefined;
const names = [normalizedHostname(host), bound];
const known = names.filter((name): name is string => name !== undefined);
return [...new Set([...localhostAllowedHostnames(), ...known])];
};
/** A JSON-RPC error answered outside any session, so its id is null. */
export const jsonRpcErrorResponse = (
status: number,
code: number,
message: string,
): Response =>
Response.json(
{ jsonrpc: "2.0", error: { code, message }, id: null },
{ status },
);
const jsonError = jsonRpcErrorResponse;
const protocolOf = (instance: McpServer | Server): Server =>
"server" in instance ? instance.server : instance;
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");
const SESSION_NOT_FOUND = (): Response =>
jsonError(404, -32001, "Session not found");
const DEFAULT_SESSION_DRAIN_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_CONCURRENT_DISPATCHES = 8;
class SessionDrainTimeoutError extends AnalysisResourceConstraintError {
constructor(timeoutMs: number) {
super(
"mcp_http_session",
"transport",
"Previous MCP session still has running requests; retry after they finish",
{ drain_timeout_ms: timeoutMs },
);
}
}
/** Split a header field on `separator` outside quoted strings, honouring backslash escapes (RFC 9110). */
const splitUnquoted = (value: string, separator: string): string[] => {
const parts: string[] = [];
let current = "";
let quoted = false;
let escaped = false;
for (const char of value) {
if (escaped) escaped = false;
else if (quoted && char === "\\") escaped = true;
else if (char === '"') quoted = !quoted;
else if (char === separator && !quoted) {
parts.push(current);
current = "";
continue;
}
current += char;
}
parts.push(current);
return parts;
};
/** The media type proper of one header value or Accept range: no parameters, lowercase. */
const mediaType = (value: string): string =>
(splitUnquoted(value, ";")[0] ?? "").trim().toLowerCase();
/** An Accept range's quality weight; a missing or unreadable `q` counts as 1, as in RFC 9110. */
const quality = (range: string): number => {
const q = splitUnquoted(range, ";")
.slice(1)
.map((parameter) => parameter.split("="))
.find(([name]) => name?.trim().toLowerCase() === "q")?.[1];
const value = q === undefined ? 1 : Number(q.trim());
return Number.isFinite(value) ? value : 1;
};
/** 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") ?? "";
// Exact media ranges with a positive weight: `application/json-patch+json`, `text/event-streaming`
// and `application/json;q=0` do not count.
const accepted = new Set(
splitUnquoted(accept, ",")
.filter((range) => quality(range) > 0)
.map(mediaType),
);
// The SDK's own checks too: its Accept test is a case-sensitive substring match, so a header it
// would refuse with 406 (such as `Application/JSON`) must not close the live session first.
return (
accepted.has("application/json") &&
accepted.has("text/event-stream") &&
accept.includes("application/json") &&
accept.includes("text/event-stream") &&
isJsonContentType(request.headers.get("content-type"))
);
};
/** 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") ||
!hasStreamablePostHeaders(request)
)
return false;
const probe = request.clone();
try {
const read = await readRequestBody(probe);
if (read.tooLarge) return false;
const body: unknown = JSON.parse(read.text);
// The transport accepts only a lone initialize, so only that may evict a session.
return Array.isArray(body)
? body.length === 1 && isInitializeRequest(body[0])
: isInitializeRequest(body);
} catch {
return false;
} finally {
// The clone is one branch of a tee; awaiting its cancel would wait for the original to drain.
void probe.body?.cancel().catch(() => undefined);
}
};
/** Owns parsing and dispatch as well as the connected transport of one session. */
class LiveSession {
#closed = false;
readonly #bodyAbort = new AbortController();
readonly #shutdown: () => Promise<void>;
constructor(
readonly transport: WebStandardStreamableHTTPServerTransport,
private readonly instance: RequestTrackingMcpServer,
) {
this.#shutdown = transport.close.bind(transport);
}
/** Install after connect, so the SDK and Evidence adapter have bound their callbacks. */
fenceDispatch(): void {
const dispatch = this.transport.onmessage;
this.transport.onmessage = (...args) => {
if (!this.#closed) dispatch?.(...args);
};
}
handle(request: Request): Promise<Response> {
if (this.#closed) return Promise.resolve(SESSION_NOT_FOUND());
// DELETE awaits this session's close; including it in its own drain would deadlock.
if (request.method === "DELETE")
return this.transport.handleRequest(request);
return Promise.resolve(
this.instance.runRequest(undefined, async () => {
if (this.#closed) return SESSION_NOT_FOUND();
const bodyAbort = new AbortController();
try {
const guarded =
request.body === null
? request
: new Request(request, {
body: request.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>(),
{
signal: AbortSignal.any([
this.#bodyAbort.signal,
bodyAbort.signal,
]),
},
),
duplex: "half",
} as RequestInit);
const response = await this.transport.handleRequest(guarded);
if (!this.#closed) return response;
// The SDK may have crossed an await before close and created its SSE
// response afterwards. Cancel that stream before releasing its owner.
await response.body?.cancel();
return SESSION_NOT_FOUND();
} finally {
// Header/size refusals can leave the SDK body unread. Its forwarding
// stream belongs to this dispatch, not the remaining session lifetime.
bodyAbort.abort();
}
}),
);
}
shutdown(): Promise<void> {
this.#closed = true;
this.#bodyAbort.abort();
return this.#shutdown();
}
}
/** Owns the single connected server and the transport of its current session. */
export class McpSessionSlot {
#live: LiveSession | undefined;
#arming: Promise<LiveSession> | undefined;
#closed = false;
#hookRan = false;
#draining = false;
#ending: Promise<void> | undefined;
/** The instance's own close hook, captured once; it runs exactly once, when the listener closes. */
readonly #shutdownHook: () => void;
constructor(
private readonly instance: RequestTrackingMcpServer,
private readonly logger: Logger | undefined,
private readonly drainTimeoutMs = DEFAULT_SESSION_DRAIN_TIMEOUT_MS,
) {
instance.enableRequestTracking();
const protocol = protocolOf(instance);
const captured = protocol.onclose ?? NO_SHUTDOWN_HOOK;
let running = false;
// Completion is recorded only after the hook returns, so a hook that threw stays eligible for a retry.
this.#shutdownHook = () => {
if (this.#hookRan || running) return;
running = true;
try {
captured();
this.#hookRan = true;
} finally {
running = false;
}
};
protocol.onclose = this.#shutdownHook;
}
/** Route one MCP request; a new client's initialize replaces a session its predecessor left open. */
async handle(request: Request): Promise<Response> {
try {
return await this.#handle(request);
} catch (cause: unknown) {
if (!(cause instanceof SessionDrainTimeoutError)) throw cause;
return jsonError(503, -32000, cause.message);
}
}
async #handle(request: Request): Promise<Response> {
if (this.#closed) return SHUTTING_DOWN();
if (this.#draining) await this.#live?.transport.close();
if (this.#closed) return SHUTTING_DOWN();
const open = this.#live;
if (
open?.transport.sessionId !== undefined &&
(await isInitializePost(request)) &&
// A client that left while its initialize was probed would evict the session for nobody.
!request.signal.aborted
) {
if (this.#closed) return SHUTTING_DOWN();
this.logger?.info("MCP HTTP session replaced by a new client");
await open.transport.close();
}
if (this.#closed) return SHUTTING_DOWN();
const session = await this.#current();
// Shutdown may have closed the slot while this request was arming it.
if (this.#closed) return SHUTTING_DOWN();
return session.handle(request);
}
/** Cancel and drain MCP work before any shared provider shutdown hook can run. */
async prepareShutdown(): Promise<void> {
this.#closed = true;
await this.#arming?.catch(() => undefined);
const open = this.#live;
// The session stays owned until its shutdown succeeds, so a retry can finish it.
try {
await open?.transport.close();
} catch (cause: unknown) {
// Shutdown may have joined a handover that already started its bounded
// drain. Retry that timeout with shutdown's completion-based wait.
if (!(cause instanceof SessionDrainTimeoutError)) throw cause;
await open?.transport.close();
}
if (this.#live === open) this.#live = undefined;
}
/** Finish shutdown once every consumer of the shared providers has settled. */
async close(): Promise<void> {
await this.prepareShutdown();
// Without a live transport nothing fires the instance's hook; run it here, once.
this.#shutdownHook();
}
#current(): Promise<LiveSession> {
if (this.#closed)
return Promise.reject(new Error("MCP HTTP session slot is closed"));
if (this.#live !== undefined) return Promise.resolve(this.#live);
this.#arming ??= this.#arm().finally(() => {
this.#arming = undefined;
});
return this.#arming;
}
async #arm(): Promise<LiveSession> {
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: () => this.logger?.info("MCP HTTP session opened"),
onsessionclosed: () =>
this.logger?.info("MCP HTTP session closed by the client"),
});
const session = new LiveSession(transport, this.instance);
// The SDK closes the transport on DELETE; that ends the session, not the server.
transport.close = () => this.#endSession(session);
await this.instance.connect(transport);
session.fenceDispatch();
this.#live = session;
return session;
}
/** Close one session's transport while the instance's onclose hooks wait for close(). */
async #endSession(session: LiveSession): Promise<void> {
if (this.#live !== session) return;
if (this.#ending !== undefined) return this.#ending;
this.#draining = true;
const ending = this.#finishSession(session).finally(() => {
if (this.#ending === ending) this.#ending = undefined;
});
this.#ending = ending;
return ending;
}
async #finishSession(session: LiveSession): Promise<void> {
const protocol = protocolOf(this.instance);
protocol.onclose = NO_SHUTDOWN_HOOK;
try {
await session.shutdown();
if (
!(await this.instance.drainRequests(
this.#closed ? undefined : this.drainTimeoutMs,
))
)
throw new SessionDrainTimeoutError(this.drainTimeoutMs);
// A timed-out handler remains owned and blocks reuse until a later drain succeeds.
if (this.#live === session) this.#live = undefined;
this.#draining = false;
} finally {
protocol.onclose = this.#shutdownHook;
}
}
}
const endpointUrl = (address: AddressInfo): string => {
const host =
address.family === "IPv6" ? `[${address.address}]` : address.address;
return `http://${host}:${String(address.port)}${MCP_HTTP_PATH}`;
};
interface NodeRequestTarget {
readonly route: (request: Request) => Promise<{
readonly response: Response;
readonly admissionLifetime: HttpAdmissionLifetime;
}>;
readonly origin: () => string;
readonly logger: Logger | undefined;
}
/** Serve one MCP server, plus plain routes, over streamable HTTP until closed. */
export const serveStreamableHttp = async (
options: ServeStreamableHttpOptions,
): Promise<StreamableHttpServer> => {
const { host, port, logger, routes = [], closeInstance = true } = options;
// Scoped IPv6 literals bind but cannot be named in a URL; refuse them before anything exists.
if (host.includes("%"))
throw new StreamableHttpListenError(
new Error(`scoped IPv6 bind address ${host} is not supported`),
);
// Loopback is classified from the address the listener binds, after it binds; the instance
// is created after that, so a bind failure leaves nothing to clean up.
let allowedHosts: string[] | undefined;
let slot: McpSessionSlot | undefined;
let origin = "";
let stopped = false;
const route: NodeRequestTarget["route"] = async (request) => {
const local = (response: Response) => ({
response,
admissionLifetime: "response" as const,
});
if (stopped) return local(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) ??
originValidationResponse(request, allowedHosts));
if (refused !== undefined) return local(refused);
if (slot === undefined)
return local(jsonError(503, -32000, "Server starting"));
const { pathname } = new URL(request.url);
if (pathname === MCP_HTTP_PATH)
return {
response: await slot.handle(request),
admissionLifetime: "dispatch",
};
const match = routes.find(
(candidate) =>
candidate.path === pathname && candidate.method === request.method,
);
return local(
match === undefined
? jsonError(404, -32601, `No route for ${request.method} ${pathname}`)
: await match.handle(request),
);
};
const httpServer = createHttpServer();
const target: NodeRequestTarget = { route, origin: () => origin, logger };
const requests = new NodeHttpRequestOwner(
options.maxConcurrentDispatches ?? DEFAULT_MAX_CONCURRENT_DISPATCHES,
{
serve: (request, response, context) =>
serveNodeRequest(target, request, response, context),
refuse: (reason) =>
jsonError(
503,
-32000,
reason === "capacity"
? "HTTP request capacity is full; retry after pending dispatches finish"
: "HTTP response cleanup failed; this server is not accepting new requests",
),
},
);
const prepareTransportShutdown = stagedClose([
async () => {
stopped = true;
await closeHttpServer(httpServer);
},
() => slot?.prepareShutdown() ?? Promise.resolve(),
() => requests.drain(),
]);
try {
await listenHttpServer(httpServer, host, port, options.signal);
options.signal?.throwIfAborted();
} catch (cause: unknown) {
if (options.signal?.aborted && cause === options.signal.reason) {
if (httpServer.listening) await prepareTransportShutdown();
throw cause;
}
throw new StreamableHttpListenError(cause);
}
const bound = httpServer.address();
let url: string;
try {
if (bound === null || typeof bound === "string")
throw new TypeError("MCP HTTP listener has no TCP address");
url = endpointUrl(bound);
origin = new URL(url).origin;
allowedHosts = hostHeaderAllowlist(host, bound.address);
} catch (cause: unknown) {
await prepareTransportShutdown().catch(() => undefined);
throw new StreamableHttpListenError(cause);
}
httpServer.on("request", (request, response) => {
void requests.handle(request, response).catch((cause: unknown) => {
logger?.warn(
{ error: describe(cause) },
"MCP HTTP request did not complete",
);
response.destroy();
});
});
// Bind failures already rejected the listening wait above; only later listener errors are reported.
httpServer.on("error", (error) =>
options.onerror?.(new StreamableHttpListenError(error, "serving")),
);
let instance: RequestTrackingMcpServer;
try {
instance = await options.createMcpServer({ era: "legacy" });
} catch (cause: unknown) {
// Starting responses may still be in flight; application routes stay unavailable.
await prepareTransportShutdown().catch(() => undefined);
throw cause;
}
protocolOf(instance).onerror = (error) =>
logger?.warn({ error: error.message }, "MCP HTTP request rejected");
const live = new McpSessionSlot(
instance,
logger,
options.sessionDrainTimeoutMs,
);
slot = live;
const prepareShutdown = stagedClose([
prepareTransportShutdown,
() => live.close(),
]);
const close = stagedClose([
prepareShutdown,
...(closeInstance ? [() => instance.close()] : []),
]);
if (options.signal?.aborted) {
await close();
throw options.signal.reason;
}
if (allowedHosts === undefined)
logger?.warn(
{ host },
"MCP HTTP Host header validation relaxed for a non-loopback bind",
);
// 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");
return { url, prepareShutdown, close };
};
/** Stop accepting, drop open connections and release the port. */
const closeHttpServer = (
httpServer: ReturnType<typeof createHttpServer>,
): Promise<void> => {
// close() first, as Node documents: a connection accepted before it would otherwise miss the forced-close pass.
const closed = new Promise<void>((resolve, reject) => {
httpServer.close((error) =>
error === undefined ? resolve() : reject(error),
);
});
httpServer.closeAllConnections();
return closed;
};
/** Translate one node:http exchange through `route`, logging a failure and answering it when headers are still open. */
const serveNodeRequest = async (
target: NodeRequestTarget,
request: IncomingMessage,
response: ServerResponse,
context: NodeHttpExchangeContext,
): Promise<void> => {
const { route, origin, logger } = target;
// A reply sent before an incoming body was fully consumed leaves the rest on the socket, which
// the bridged stream keeps paused; closing the connection after the reply releases it. The
// incoming message decides (end of stream, not "body disturbed"), so a partially read upload
// and a GET or HEAD that carried a body close too.
const declaresBody =
request.headers["content-length"] !== undefined ||
request.headers["transfer-encoding"] !== undefined;
let webRequest: Request | undefined;
const unreadBody = (): boolean => declaresBody && !request.readableEnded;
let reported = false;
let admissionLifetime: HttpAdmissionLifetime = "response";
const settled = (): void => {
if (reported) return;
reported = true;
context.onDispatchSettled({
hasUnreadBody: unreadBody(),
admissionLifetime,
});
};
try {
try {
webRequest = toWebRequest(request, response, origin());
} catch (cause: unknown) {
// Fetch refuses methods such as TRACE that node:http accepts, and the bridge refuses
// targets that are not origin-form paths; both are answered instead of thrown.
const target = cause instanceof UnsupportedRequestTargetError;
await writeWebResponse(
jsonError(
target ? 400 : 405,
-32600,
target
? "Unsupported request target"
: `Unsupported method ${request.method ?? ""}`,
),
response,
{ closeConnection: true, onBodyCleanup: context.onBodyCleanup },
);
return;
}
let webResponse: Response;
try {
const routed = await route(webRequest);
webResponse = routed.response;
admissionLifetime = routed.admissionLifetime;
} finally {
settled();
}
await writeWebResponse(webResponse, response, {
closeConnection: unreadBody(),
onBodyCleanup: context.onBodyCleanup,
});
} catch (cause: unknown) {
// A client that left mid-stream is routine; a failure before headers is not.
logger?.[response.headersSent ? "debug" : "warn"](
{ error: describe(cause) },
"MCP HTTP request did not complete",
);
if (response.headersSent) response.destroy();
else
await writeWebResponse(
jsonError(500, -32603, "Internal server error"),
response,
{ closeConnection: unreadBody(), onBodyCleanup: context.onBodyCleanup },
).catch(() => undefined);
} finally {
settled();
}
};
/** 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;
};
};