diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb32c5f..74add161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- The default HTTP MCP server now listens on both IPv4 and IPv6 loopback addresses. - Embedding generation and legacy fingerprint adoption now tokenize documents with the store-selected embedding model instead of the global default. This keeps chunk boundaries aligned with the model that creates and verifies the diff --git a/README.md b/README.md index 76b822b2..34db49ca 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ qmd mcp stop # stop via PID file qmd status # shows "MCP: running (PID ...)" when active ``` -The server binds to `localhost` by default. Pass `--host` (or set the `QMD_HOST` +The server serves both loopbacks (`127.0.0.1` and `::1`) by default. Pass `--host` (or set the `QMD_HOST` environment variable) to override — `--host 0.0.0.0` is useful when the server runs in a container and a liveness probe connects from a non-loopback address. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 73f3ee46..c1b21722 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -868,6 +868,7 @@ export async function startMcpServer(options: McpStartupOptions = {}): Promise Promise; @@ -875,9 +876,10 @@ export type HttpServerHandle = { /** * Start MCP server over Streamable HTTP (JSON responses by default). - * Binds to `options.host` (default "localhost", overridable via the QMD_HOST - * env var) — set "0.0.0.0" to accept connections from other hosts, e.g. a - * container liveness probe. Returns a handle for shutdown and port discovery. + * Serves both loopbacks (127.0.0.1 and ::1) for the default "localhost" host. + * Override via `options.host` or QMD_HOST — set "0.0.0.0" to accept connections + * from other hosts, e.g. a container liveness probe. Returns a handle for + * shutdown and port discovery. * * HTTP is sessionless (MCP 2026-07-28): there is no `Mcp-Session-Id`, no * initialize handshake, and no idle-session TTL. 2025-era clients are still @@ -979,7 +981,7 @@ export async function startMcpHttpServer( ...(options.allowedHosts ? { allowedHosts: options.allowedHosts } : {}), }); - const httpServer = createServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => { + const requestHandler = async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => { const reqStart = Date.now(); const pathname = (nodeReq.url || "/").split("?")[0]; @@ -1143,21 +1145,61 @@ export async function startMcpHttpServer( nodeRes.writeHead(500); nodeRes.end("Internal Server Error"); } - }); + }; - await new Promise((resolve, reject) => { - httpServer.on("error", reject); - httpServer.listen(port, host, () => resolve()); - }); + const httpServers: import("http").Server[] = []; + let actualPort = port; + try { + let unavailableError: unknown; + // Bind IPv4 first, then IPv6 on the same port, including for port 0. + for (const bindHost of host === "localhost" ? ["127.0.0.1", "::1"] : [host]) { + const server = createServer(requestHandler); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(actualPort, bindHost, () => { + server.removeListener("error", reject); + resolve(); + }); + }); + httpServers.push(server); + actualPort = (server.address() as import("net").AddressInfo).port; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (host !== "localhost" || (code !== "EADDRNOTAVAIL" && code !== "EAFNOSUPPORT")) throw error; + unavailableError = error; + } + } + if (httpServers.length === 0) throw unavailableError; + } catch (error) { + await Promise.all(httpServers.map(server => new Promise(resolve => server.close(() => resolve())))); + await mcpHandler.close(); + await store.close(); + throw error; + } - const actualPort = (httpServer.address() as import("net").AddressInfo).port; + const httpServer = httpServers[0]!; + if (httpServers.length > 1) { + const closeListeners = httpServers.map(server => server.close.bind(server)); + httpServer.close = (callback) => { + let remaining = closeListeners.length; + let closeError: Error | undefined; + for (const close of closeListeners) { + close(error => { + closeError ??= error; + if (--remaining === 0) callback?.(closeError); + }); + } + return httpServer; + }; + } let stopping = false; const stop = async () => { if (stopping) return; stopping = true; await mcpHandler.close(); - httpServer.close(); + await new Promise(resolve => httpServer.close(() => resolve())); await store.close(); }; diff --git a/test/mcp-http-loopback.test.ts b/test/mcp-http-loopback.test.ts new file mode 100644 index 00000000..db5f50ee --- /dev/null +++ b/test/mcp-http-loopback.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { request } from "node:http"; +import { createServer, type AddressInfo, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { startMcpHttpServer, type HttpServerHandle } from "../src/mcp/server"; +import { _resetProductionModeForTesting } from "../src/store"; + +function listen(server: Server, host: string, port = 0): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.removeListener("error", reject); + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); +} + +function getMcp(hostname: string, port: number, hostHeader?: string): Promise { + return new Promise((resolve, reject) => { + const req = request({ + hostname, port, path: "/mcp", agent: false, + headers: hostHeader ? { Host: hostHeader, Origin: `http://${hostHeader}` } : {}, + }, res => { + res.resume(); + res.on("end", () => resolve(res.statusCode!)); + }); + req.on("error", reject); + req.end(); + }); +} + +const ipv6Available = await (async () => { + const probe = createServer(); + try { + await listen(probe, "::1"); + return true; + } catch (error) { + if (!["EADDRNOTAVAIL", "EAFNOSUPPORT"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; + return false; + } finally { + if (probe.listening) await close(probe); + } +})(); + +describe("MCP HTTP loopback listeners", () => { + let workDir: string; + let handle: HttpServerHandle | undefined; + const servers: Server[] = []; + const envKeys = ["QMD_HOST", "QMD_CONFIG_DIR", "QMD_ALLOWED_HOSTS", "QMD_ALLOWED_ORIGINS"] as const; + const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]])); + const signalListeners = { + SIGTERM: process.listeners("SIGTERM"), + SIGINT: process.listeners("SIGINT"), + }; + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "qmd-http-loopback-")); + for (const key of envKeys) delete process.env[key]; + process.env.QMD_CONFIG_DIR = workDir; + }); + + afterEach(async () => { + await handle?.stop(); + handle = undefined; + for (const server of servers.splice(0)) { + if (server.listening) await close(server); + } + _resetProductionModeForTesting(); + for (const key of envKeys) { + if (originalEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalEnv[key]; + } + for (const signal of ["SIGTERM", "SIGINT"] as const) { + for (const listener of process.listeners(signal)) { + if (!signalListeners[signal].includes(listener)) process.removeListener(signal, listener); + } + } + await rm(workDir, { recursive: true, force: true }); + }); + + async function start(host?: string, port = 0): Promise { + handle = await startMcpHttpServer(port, { quiet: true, dbPath: join(workDir, "index.sqlite"), host }); + return handle; + } + + async function occupy(host: string, port = 0): Promise { + const server = createServer(); + servers.push(server); + return listen(server, host, port); + } + + test("default host serves IPv4 on an ephemeral port", async () => { + const { port } = await start(); + expect(port).toBeGreaterThan(0); + expect(await getMcp("127.0.0.1", port)).toBe(405); + }); + + test.skipIf(!ipv6Available)("default host serves both families on the same port (requires IPv6 loopback)", async () => { + const { port } = await start(); + expect(await getMcp("127.0.0.1", port)).toBe(405); + expect(await getMcp("::1", port)).toBe(405); + }); + + test("accepts all loopback Host and Origin headers over IPv4", async () => { + const { port } = await start(); + for (const host of ["127.0.0.1", "[::1]", "localhost"]) { + expect(await getMcp("127.0.0.1", port, `${host}:${port}`)).toBe(405); + } + }); + + test.skipIf(!ipv6Available)("accepts all loopback Host and Origin headers over IPv6 (requires IPv6 loopback)", async () => { + const { port } = await start(); + for (const host of ["127.0.0.1", "[::1]", "localhost"]) { + expect(await getMcp("::1", port, `${host}:${port}`)).toBe(405); + } + }); + + test.skipIf(!ipv6Available).each(["option", "environment"])("explicit IPv4 host via %s does not serve IPv6 (requires IPv6 loopback)", async (source) => { + if (source === "environment") process.env.QMD_HOST = "127.0.0.1"; + const { port } = await start(source === "option" ? "127.0.0.1" : undefined); + expect(await getMcp("127.0.0.1", port)).toBe(405); + await expect(getMcp("::1", port)).rejects.toMatchObject({ code: "ECONNREFUSED" }); + }); + + test("IPv4 collision rejects and leaves no IPv6 listener when IPv6 loopback is available", async () => { + const port = await occupy("127.0.0.1"); + await expect(start(undefined, port)).rejects.toMatchObject({ code: "EADDRINUSE" }); + if (ipv6Available) await occupy("::1", port); + }); + + test.skipIf(!ipv6Available)("IPv6 collision rejects and releases the IPv4 listener (requires IPv6 loopback)", async () => { + const port = await occupy("::1"); + await expect(start(undefined, port)).rejects.toMatchObject({ code: "EADDRINUSE" }); + await occupy("127.0.0.1", port); + }); + + test.each(["stop", "close"])("handle %s releases IPv4 and IPv6 when IPv6 loopback is available", async (method) => { + const server = await start(); + expect(await getMcp("127.0.0.1", server.port)).toBe(405); + if (ipv6Available) expect(await getMcp("::1", server.port)).toBe(405); + if (method === "stop") await server.stop(); + else await close(server.httpServer); + await occupy("127.0.0.1", server.port); + if (ipv6Available) await occupy("::1", server.port); + }); +}); diff --git a/test/mcp-origin-guard.test.ts b/test/mcp-origin-guard.test.ts index 448b9d82..b2e05817 100644 --- a/test/mcp-origin-guard.test.ts +++ b/test/mcp-origin-guard.test.ts @@ -112,6 +112,14 @@ describe("checkRequestOrigin", () => { } }); + test("allows every loopback Host and Origin combination", () => { + for (const host of ["127.0.0.1:12345", "[::1]:12345", "localhost:12345"]) { + for (const origin of ["http://127.0.0.1:12345", "http://[::1]:12345", "http://localhost:12345"]) { + expect(checkRequestOrigin({ host, origin }, guard).ok).toBe(true); + } + } + }); + test("rejects a foreign Origin", () => { const verdict = checkRequestOrigin({ origin: "https://evil.example", host: "localhost:8181" }, guard); expect(verdict.ok).toBe(false);