Files
rea/src/main/mcpHttpArguments.ts
T
Edgar Arakelyan 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

105 lines
3.7 KiB
TypeScript

import { Help, z } from "incur";
import {
MCP_HTTP_PORT_CONSTRAINT,
mcpHttpPortSchema,
} from "../config/mcpHttpEndpoint.js";
/**
* Canonical `rea mcp --http` options. The help renderer lists exactly this
* schema and the parser accepts exactly these flags plus the `-h` alias.
*/
const MCP_HTTP_OPTIONS = z.object({
http: z
.boolean()
.describe("Serve the MCP catalog over streamable HTTP at /mcp, not stdio"),
host: z
.string()
.min(1)
.optional()
.describe("Listener address; default MCP_HOST, else 127.0.0.1"),
port: z
.number()
.int()
.min(0)
.max(65535)
.optional()
.describe("Listener port; default MCP_PORT, else 0 for an ephemeral port"),
help: z.boolean().optional().describe("Show help"),
});
const MCP_HTTP_ALIAS = { help: "h" } as const;
const MCP_HTTP_COMMAND = "rea mcp --http";
const MCP_HTTP_DESCRIPTION =
"Serve the MCP server over streamable HTTP for one local client";
const HELP_FLAGS: ReadonlySet<string> = new Set(["--help", "-h"]);
const VALUED_FLAGS: ReadonlySet<string> = new Set(["--host", "--port"]);
/** Render help for the dispatcher entry, which accepts no Incur global options. */
export const renderMcpHttpHelp = (): string =>
Help.formatCommand(MCP_HTTP_COMMAND, {
description: MCP_HTTP_DESCRIPTION,
hideGlobalOptions: true,
options: MCP_HTTP_OPTIONS,
alias: MCP_HTTP_ALIAS,
hint: "This entry accepts only the options above. The bound URL is logged as one JSON line on stderr.",
});
/** Parsed `rea mcp --http` arguments; omitted values defer to the environment. */
export type McpHttpArguments =
| { readonly ok: true; readonly host?: string; readonly port?: number }
| { readonly ok: false; readonly help: true }
| { readonly ok: false; readonly help: false; readonly message: string };
const invalid = (message: string): McpHttpArguments => ({
ok: false,
help: false,
message,
});
/** Split `--flag=value` into its flag and inline value. */
const splitFlag = (argument: string): readonly [string, string | undefined] => {
const separator = argument.indexOf("=");
return separator === -1
? [argument, undefined]
: [argument.slice(0, separator), argument.slice(separator + 1)];
};
/** Parse the dispatcher arguments after `mcp`; help wins only in flag position. */
export const parseMcpHttpArguments = (
arguments_: readonly string[],
): McpHttpArguments => {
const values = new Map<string, string>();
let http = false;
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index] ?? "";
if (HELP_FLAGS.has(argument)) return { ok: false, help: true };
if (argument === "--http") {
http = true;
continue;
}
const [flag, inline] = splitFlag(argument);
if (!VALUED_FLAGS.has(flag))
return invalid(`Unknown mcp --http argument: ${argument}`);
const value = inline ?? arguments_[index + 1];
if (inline === undefined) index += 1;
if (value === undefined || (inline === undefined && value.startsWith("-")))
return invalid(`Missing value for ${flag}`);
values.set(flag, value);
}
if (!http) return invalid("--host and --port require --http");
const host = values.get("--host")?.trim();
if (host !== undefined && host.length === 0)
return invalid("--host must be a non-empty host name or IP address");
const portText = values.get("--port");
const port =
portText === undefined ? undefined : mcpHttpPortSchema.safeParse(portText);
if (port !== undefined && !port.success)
return invalid(`Invalid --port: ${MCP_HTTP_PORT_CONSTRAINT}`);
return {
ok: true,
...(host === undefined ? {} : { host }),
...(port === undefined ? {} : { port: port.data }),
};
};