Files
reverse-engineering-anything/src/server/http/serveStreamableHttp.ts
T
Edgar Arakelyanandgensokyo-dev 60bd646478 feat(mcp): add opt-in streamable HTTP transport (#4955)
Add an explicit Streamable HTTP entry for environment/gateway hosts while keeping stdio registrations and default behavior. Preserve the shared provider/session owner across DELETE and replacement; fence and drain tool/completion work before handover or teardown.

Bound HTTP input and plain-response admission, cancel refused bodies, join whole exchanges and asynchronous response cleanup before provider hooks, gate startup readiness, and reject queued HTTP/1.1 pipelining while retaining sequential keep-alive.

Validation: 8 static/build tasks, 83 adapter tests and registry metadata pass. A real installed gh binary produces seven equivalent CLI/stdio/HTTP reports with DELETE/replacement and clean signal shutdown. Broader local native-provider/ownership failures reproduce on clean main and are documented with limitations in the PR.

Co-authored-by: Edgar Arakelyan <edgar.arakelyan@scale.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
2026-10-19 23:58:51 +08:00

698 lines
25 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 { once } from "node:events";
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 { 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;
/** 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: Error) => 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])];
};
const jsonError = (status: number, code: number, message: string): Response =>
Response.json(
{ jsonrpc: "2.0", error: { code, message }, id: null },
{ status },
);
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 {
httpServer.listen(port, host);
await once(httpServer, "listening");
} catch (cause: unknown) {
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?.(error));
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;
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");
const prepareShutdown = stagedClose([
prepareTransportShutdown,
() => live.close(),
]);
const close = stagedClose([
prepareShutdown,
...(closeInstance ? [() => instance.close()] : []),
]);
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;
};
};