From d48e472fa6a664cacbb400560e673533005795ce Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 31 Oct 2026 03:13:07 +0800 Subject: [PATCH] fix(mcp): preserve repeated HTTP host allowlists (#8164) --- docs/cli.md | 8 +- src/main/mcpHttpArguments.ts | 9 +- src/server/http/serveStreamableHttp.test.ts | 44 ++++++++++ tests/boundary/mcp/mcpHttpOptions.test.ts | 91 +++++++++++++++++++++ 4 files changed, 148 insertions(+), 4 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 620ef19d92..df08944142 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -853,7 +853,13 @@ 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. A bind outside loopback requires `MCP_ALLOWED_HOSTS` or `--allowed-hosts`, a comma-separated list of client host -names or IP addresses without URLs or ports. For example: +names or IP addresses without URLs or ports. + +Repeat `--allowed-hosts` to append names, or put multiple names in one +comma-separated value. All occurrences are validated together and deduplicated +after hostname normalization; the explicit list replaces `MCP_ALLOWED_HOSTS`. + +For example: ```sh rea mcp --http --host 0.0.0.0 --allowed-hosts 127.0.0.1,rea.lan diff --git a/src/main/mcpHttpArguments.ts b/src/main/mcpHttpArguments.ts index 013cf583b0..e12d055a75 100644 --- a/src/main/mcpHttpArguments.ts +++ b/src/main/mcpHttpArguments.ts @@ -29,7 +29,7 @@ const MCP_HTTP_OPTIONS = z.object({ .array(z.string()) .optional() .describe( - "Host/Origin names a non-loopback bind may serve; default MCP_ALLOWED_HOSTS, else the bind is refused", + "Host/Origin names a non-loopback bind may serve; repeat or separate names with commas; default MCP_ALLOWED_HOSTS, else the bind is refused", ), help: z.boolean().optional().describe("Show help"), }); @@ -85,6 +85,7 @@ export const parseMcpHttpArguments = ( arguments_: readonly string[], ): McpHttpArguments => { const values = new Map(); + const allowedHostValues: string[] = []; let http = false; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index] ?? ""; @@ -100,7 +101,8 @@ export const parseMcpHttpArguments = ( if (inline === undefined) index += 1; if (value === undefined || (inline === undefined && value.startsWith("-"))) return invalid(`Missing value for ${flag}`); - values.set(flag, value); + if (flag === "--allowed-hosts") allowedHostValues.push(value); + else values.set(flag, value); } if (!http) return invalid("--host and --port require --http"); const host = values.get("--host")?.trim(); @@ -111,7 +113,8 @@ export const parseMcpHttpArguments = ( portText === undefined ? undefined : mcpHttpPortSchema.safeParse(portText); if (port !== undefined && !port.success) return invalid(`Invalid --port: ${MCP_HTTP_PORT_CONSTRAINT}`); - const allowedHostsText = values.get("--allowed-hosts"); + const allowedHostsText = + allowedHostValues.length === 0 ? undefined : allowedHostValues.join(","); const allowedHosts = allowedHostsText === undefined ? undefined diff --git a/src/server/http/serveStreamableHttp.test.ts b/src/server/http/serveStreamableHttp.test.ts index 3044fdb574..a8b1f87c52 100644 --- a/src/server/http/serveStreamableHttp.test.ts +++ b/src/server/http/serveStreamableHttp.test.ts @@ -21,6 +21,7 @@ import { } from "./serveStreamableHttp.js"; import { StreamableHttpListenError } from "./streamableHttpListenError.js"; import { mcpAllowedHostsTextSchema } from "../../config/mcpHttpEndpoint.js"; +import { parseMcpHttpArguments } from "../../main/mcpHttpArguments.js"; import { RequestTrackingMcpServer } from "../RequestTrackingMcpServer.js"; const servers: StreamableHttpServer[] = []; @@ -346,6 +347,49 @@ it.each([ expect(JSON.parse(response.body)).toEqual({ allowed: true }); }); +it("serves every declared repeated host while refusing undeclared hosts and origins", async () => { + const parsed = parseMcpHttpArguments([ + "--http", + "--allowed-hosts", + "a.example", + "--allowed-hosts=b.example", + ]); + if (!parsed.ok || parsed.allowedHosts === undefined) + throw new Error("Expected valid repeated host lists"); + const { served } = await start({ + host: "0.0.0.0", + allowedHosts: parsed.allowedHosts, + routes: [ + { + method: "GET", + path: "/probe", + handle: () => Response.json({ allowed: true }), + }, + ], + }); + const url = new URL(served.url); + url.hostname = "127.0.0.1"; + url.pathname = "/probe"; + for (const host of ["a.example", "b.example"]) { + const response = await rawHttpRequest(url, { + headers: { host, origin: `http://${host}` }, + }); + expect(response.status, host).toBe(200); + expect(JSON.parse(response.body)).toEqual({ allowed: true }); + } + expect( + (await rawHttpRequest(url, { headers: { host: "undeclared.example" } })) + .status, + ).toBe(403); + expect( + ( + await rawHttpRequest(url, { + headers: { host: "a.example", origin: "http://undeclared.example" }, + }) + ).status, + ).toBe(403); +}); + it("announces an admitted non-loopback URL and gates it on the operator allowlist", async () => { const { served } = await start({ host: "0.0.0.0", diff --git a/tests/boundary/mcp/mcpHttpOptions.test.ts b/tests/boundary/mcp/mcpHttpOptions.test.ts index 6081b3a7f5..a7845bd06f 100644 --- a/tests/boundary/mcp/mcpHttpOptions.test.ts +++ b/tests/boundary/mcp/mcpHttpOptions.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest"; +import { fileURLToPath } from "node:url"; +import { processTest } from "../../support/process/processFixture.js"; import { parseMcpHttpEndpoint, @@ -60,6 +62,56 @@ describe("rea mcp --http arguments", () => { expect(help).not.toContain("--filter-output"); }); + it("combines repeated allowlists before normalization and explicit override", () => { + const parsed = parseMcpHttpArguments([ + "--http", + "--allowed-hosts", + "REA.LAN,bücher.example", + "--allowed-hosts=rea.lan,[::1]", + "--allowed-hosts", + "b.example", + ]); + expect(parsed).toEqual({ + ok: true, + allowedHosts: ["rea.lan", "xn--bcher-kva.example", "[::1]", "b.example"], + }); + if (!parsed.ok) throw new Error("Expected valid repeated host lists"); + expect( + parseMcpHttpEndpoint( + { MCP_ALLOWED_HOSTS: "environment.example" }, + parsed, + ), + ).toEqual({ + ok: true, + value: { host: "127.0.0.1", port: 0, allowedHosts: parsed.allowedHosts }, + }); + }); + + it.each(["", "a.example,", "https://a.example", "a.example:80"])( + "rejects invalid earlier allowlist %j instead of discarding it", + (invalidHosts) => { + expect( + parseMcpHttpArguments([ + "--http", + `--allowed-hosts=${invalidHosts}`, + "--allowed-hosts=b.example", + ]), + ).toMatchObject({ + ok: false, + help: false, + message: expect.stringContaining("Invalid --allowed-hosts"), + }); + expect( + parseMcpHttpArguments([ + "--http", + `--allowed-hosts=${invalidHosts}`, + "--allowed-hosts=b.example", + "--help", + ]), + ).toEqual({ ok: false, help: true }); + }, + ); + it("writes help as one newline-terminated stdout block and rejections to stderr", async () => { const io = () => { const stdout: string[] = []; @@ -87,6 +139,45 @@ describe("rea mcp --http arguments", () => { }); }); +processTest( + "rejects an invalid earlier host list before the compiled CLI opens a listener", + async ({ processes }) => { + const child = processes.spawn(process.execPath, [ + fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url)), + "mcp", + "--http", + "--host", + "127.0.0.1", + "--port", + "0", + "--allowed-hosts=https://a.example", + "--allowed-hosts=b.example", + ]); + child.stdin.end(); + let stdout = ""; + let stderr = ""; + const result = await new Promise<{ + listening: boolean; + code?: number | null; + }>((resolve, reject) => { + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + if (stderr.includes("MCP transport listening")) + resolve({ listening: true }); + }); + child.once("error", reject); + child.once("close", (code) => resolve({ listening: false, code })); + }); + expect(result, stderr).toEqual({ listening: false, code: 1 }); + expect(stdout).toBe(""); + expect(stderr).toContain("Invalid --allowed-hosts"); + expect(stderr).not.toContain("MCP transport listening"); + }, +); + describe("MCP_HOST and MCP_PORT", () => { it("defaults to loopback and an ephemeral port", () => { expect(parseMcpHttpEndpoint({})).toEqual({