diff --git a/src/cli/qmd.ts b/src/cli/qmd.ts index 52a076da..5dc6e18d 100755 --- a/src/cli/qmd.ts +++ b/src/cli/qmd.ts @@ -97,6 +97,7 @@ import { loadConfig, } from "../collections.js"; import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js"; +import { normalizeMcpHost, validateMcpHostInput } from "../mcp-host.js"; // Enable production mode - allows using default database path // Tests must set INDEX_PATH or use createStore() with explicit path @@ -2349,6 +2350,7 @@ function parseCLI() { http: { type: "boolean" }, daemon: { type: "boolean" }, port: { type: "string" }, + host: { type: "string" }, }, allowPositionals: true, strict: false, // Allow unknown options to pass through @@ -2537,6 +2539,7 @@ function showHelp(): void { console.log(" qmd multi-get - Batch fetch via glob or comma-separated list"); console.log(" qmd skill show/install - Show or install the packaged QMD skill"); console.log(" qmd mcp - Start the MCP server (stdio transport for AI agents)"); + console.log(" qmd mcp --http [--host ADDR] [--port N] - Start MCP server (HTTP, default localhost:8181)"); console.log(""); console.log("Collections & context:"); console.log(" qmd collection add/list/remove/rename/show - Manage indexed folders"); @@ -2588,7 +2591,7 @@ function showHelp(): void { console.log(" - `qmd skill install` installs the QMD skill into ./.agents/skills/qmd."); console.log(" - Use `qmd skill install --global` for ~/.agents/skills/qmd."); console.log(" - `qmd --skill` is kept as an alias for `qmd skill show`."); - console.log(" - Advanced: `qmd mcp --http ...` and `qmd mcp --http --daemon` are optional for custom transports."); + console.log(" - Advanced: `qmd mcp --http [--host ADDR] [--port N]` and `qmd mcp --http --daemon` are optional for custom transports."); console.log(""); console.log("Global options:"); console.log(" --index - Use a named index (default: index)"); @@ -3007,6 +3010,25 @@ if (isMain) { if (cli.values.http) { const port = Number(cli.values.port) || 8181; + // Validate and normalize --host + let host: string | undefined; + let normalizedHost = normalizeMcpHost(undefined); + if (cli.values.host !== undefined) { + if (typeof cli.values.host !== "string") { + console.error(`Invalid --host value: "--host" requires a hostname or IP address argument.`); + process.exit(1); + } + const rawHost = cli.values.host; + try { + validateMcpHostInput(rawHost); + } catch (err: any) { + console.error(String(err.message || err)); + process.exit(1); + } + normalizedHost = normalizeMcpHost(rawHost); + host = normalizedHost.bindHost; + } + if (cli.values.daemon) { // Guard: check if already running if (existsSync(pidPath)) { @@ -3027,17 +3049,63 @@ if (isMain) { const spawnArgs = selfPath.endsWith(".ts") ? ["--import", pathJoin(dirname(selfPath), "..", "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)] : [selfPath, "mcp", "--http", "--port", String(port)]; + if (host !== undefined) { + spawnArgs.push("--host", host); + } const child = nodeSpawn(process.execPath, spawnArgs, { - stdio: ["ignore", logFd, logFd], + stdio: ["ignore", logFd, logFd, "ipc"], detached: true, }); - child.unref(); closeSync(logFd); // parent's copy; child inherited the fd - writeFileSync(pidPath, String(child.pid)); - console.log(`Started on http://localhost:${port}/mcp (PID ${child.pid})`); - console.log(`Logs: ${logPath}`); - process.exit(0); + // Wait for child to report ready or error via IPC. + // Settled guard prevents double-resolution from message+exit or error+exit races. + const result = await new Promise<{ status: string; port?: number; code?: string; message?: string }>((resolve) => { + let settled = false; + const settle = (r: { status: string; port?: number; code?: string; message?: string }) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(r); + }; + + const timeout = setTimeout(() => { + settle({ status: "error", message: "Daemon startup timed out (10s)" }); + }, 10_000); + + child.on("message", (msg: any) => settle(msg)); + + child.on("error", (err) => { + settle({ status: "error", message: `Spawn error: ${err.message}` }); + }); + + child.on("exit", (code) => { + const logContent = existsSync(logPath) ? readFileSync(logPath, "utf-8").trim() : ""; + settle({ status: "error", message: `Daemon exited with code ${code}.${logContent ? "\n" + logContent : ""}` }); + }); + }); + + if (result.status === "ready") { + // Disconnect IPC before unref — Node docs: unref() does not fully detach + // while an IPC channel is still established. + if (child.connected) child.disconnect(); + child.unref(); + writeFileSync(pidPath, String(child.pid)); + const actualPort = result.port ?? port; + console.log(`Started on http://${normalizedHost.displayHost}:${actualPort}/mcp (PID ${child.pid})`); + console.log(`Logs: ${logPath}`); + process.exit(0); + } else { + if (child.connected) child.disconnect(); + try { child.kill(); } catch {} + try { unlinkSync(pidPath); } catch {} + if ((result as any).code === "EADDRINUSE") { + console.error(`Port ${port} already in use on ${normalizedHost.bindHost}. Try a different port with --port.`); + } else { + console.error(`Daemon failed to start: ${result.message}`); + } + process.exit(1); + } } // Foreground HTTP mode — remove top-level cursor handlers so the @@ -3046,10 +3114,22 @@ if (isMain) { process.removeAllListeners("SIGINT"); const { startMcpHttpServer } = await import("../mcp/server.js"); try { - await startMcpHttpServer(port); + const handle = await startMcpHttpServer(port, { host }); + // Signal parent daemon process if spawned with IPC. + // Use send(msg, callback) → disconnect in callback for deterministic ordering. + if (process.send) { + process.send({ status: "ready", port: handle.port }, () => { + process.disconnect?.(); + }); + } } catch (e: any) { + if (process.send) { + process.send({ status: "error", code: e?.code, message: e?.message || String(e) }, () => { + process.disconnect?.(); + }); + } if (e?.code === "EADDRINUSE") { - console.error(`Port ${port} already in use. Try a different port with --port.`); + console.error(`Port ${port} already in use on ${normalizedHost.bindHost}. Try a different port with --port.`); process.exit(1); } throw e; diff --git a/src/mcp-host.ts b/src/mcp-host.ts new file mode 100644 index 00000000..2604c204 --- /dev/null +++ b/src/mcp-host.ts @@ -0,0 +1,69 @@ +import { isIP } from "net"; + +export type NormalizedMcpHost = { + bindHost: string; + displayHost: string; +}; + +/** + * Validate an MCP HTTP host argument from CLI input. + * Host must be a hostname/IP literal, not a URL. + */ +export function validateMcpHostInput(rawHost: string): void { + const value = rawHost.trim(); + if (value.includes("://") || value.includes("/")) { + throw new Error(`Invalid --host value: "${rawHost}". Provide a hostname or IP address, not a URL.`); + } + if (value.startsWith("-")) { + throw new Error(`Invalid --host value: "${rawHost}". "--host" requires a hostname or IP address argument.`); + } + // Reject host:port patterns (e.g. "localhost:8181", "[::1]:8080") + // but allow bare IPv6 (e.g. "::1", "2001:db8::1") and bracketed IPv6 ("[::1]") + // Reject empty/blank input + if (!value) { + throw new Error(`Invalid --host value: "--host" requires a non-empty hostname or IP address.`); + } + + const inner = value.startsWith("[") && value.endsWith("]") + ? value.slice(1, -1).trim() + : value; + + // If brackets were used, inner must be a valid IPv6 address. + // Rejects "[]", "[localhost]", "[foo]", "[127.0.0.1]" — brackets are IPv6-only syntax. + if (value.startsWith("[") && value.endsWith("]")) { + if (isIP(inner) !== 6) { + throw new Error(`Invalid --host value: "${rawHost}". Square brackets are only valid around IPv6 addresses.`); + } + } + + // Reject values containing whitespace (hostnames/IPs never have spaces) + if (/\s/.test(inner)) { + throw new Error(`Invalid --host value: "${rawHost}". Hostname or IP address must not contain whitespace.`); + } + + if (inner.includes(":") && isIP(inner) === 0) { + throw new Error( + `Invalid --host value: "${rawHost}". Use --host and --port separately (e.g., --host localhost --port 8181).`, + ); + } +} + +/** + * Normalize host for socket binding and URL display. + * - "[::1]" -> bind "::1", display "[::1]" + * - empty/whitespace -> localhost + */ +export function normalizeMcpHost(rawHost?: string | null): NormalizedMcpHost { + let host = typeof rawHost === "string" ? rawHost.trim() : ""; + + if (host.startsWith("[") && host.endsWith("]")) { + host = host.slice(1, -1).trim(); + } + + if (!host) host = "localhost"; + + return { + bindHost: host, + displayHost: host.includes(":") ? `[${host}]` : host, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f1cc2a95..a1bc2419 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -8,6 +8,7 @@ */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { normalizeMcpHost } from "../mcp-host.js"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "url"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -538,9 +539,12 @@ export type HttpServerHandle = { /** * Start MCP server over Streamable HTTP (JSON responses, no SSE). - * Binds to localhost only. Returns a handle for shutdown and port discovery. + * Binds to localhost by default. Use `options.host` to override (e.g. "127.0.0.1" + * to force IPv4, or "0.0.0.0" for all interfaces). + * Returns a handle for shutdown and port discovery. */ -export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise { +export async function startMcpHttpServer(port: number, options?: { quiet?: boolean; host?: string }): Promise { + const { bindHost, displayHost } = normalizeMcpHost(options?.host); const store = await createStore({ dbPath: getDefaultDbPath() }); // Pre-fetch default collection names for REST endpoint @@ -769,7 +773,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole await new Promise((resolve, reject) => { httpServer.on("error", reject); - httpServer.listen(port, "localhost", () => resolve()); + httpServer.listen(port, bindHost, () => resolve()); }); const actualPort = (httpServer.address() as import("net").AddressInfo).port; @@ -797,7 +801,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole process.exit(0); }); - log(`QMD MCP server listening on http://localhost:${actualPort}/mcp`); + log(`QMD MCP server listening on http://${displayHost}:${actualPort}/mcp`); return { httpServer, port: actualPort, stop }; } diff --git a/test/cli.test.ts b/test/cli.test.ts index 834ac188..21351b4c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1309,8 +1309,10 @@ describe("mcp http daemon", () => { } /** Spawn a foreground HTTP server (non-blocking) and return the process */ - function spawnHttpServer(port: number): import("child_process").ChildProcess { - const proc = spawn(tsxBin, [qmdScript, "mcp", "--http", "--port", String(port)], { + function spawnHttpServer(port: number, host?: string): import("child_process").ChildProcess { + const args = [qmdScript, "mcp", "--http", "--port", String(port)]; + if (host) args.push("--host", host); + const proc = spawn(tsxBin, args, { cwd: fixturesDir, env: { ...process.env, @@ -1324,11 +1326,12 @@ describe("mcp http daemon", () => { } /** Wait for HTTP server to become ready */ - async function waitForServer(port: number, timeoutMs = 5000): Promise { + async function waitForServer(port: number, timeoutMs = 5000, host = "localhost"): Promise { const deadline = Date.now() + timeoutMs; + const displayHost = host.includes(":") ? `[${host}]` : host; while (Date.now() < deadline) { try { - const res = await fetch(`http://localhost:${port}/health`); + const res = await fetch(`http://${displayHost}:${port}/health`); if (res.ok) return true; } catch { /* not ready yet */ } await sleep(200); @@ -1506,4 +1509,391 @@ describe("mcp http daemon", () => { await sleep(500); try { unlinkSync(pidPath()); } catch {} }); + + // ------------------------------------------------------------------------- + // Daemon IPC readiness handshake + // ------------------------------------------------------------------------- + + test("daemon waits for child to bind before reporting success", async () => { + const port = randomPort(); + const { stdout, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--daemon", "--host", "127.0.0.1", "--port", String(port), + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Started on"); + + // Verify the server is actually responding (not just that parent printed success) + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + + // Clean up + const pf = pidPath(); + if (existsSync(pf)) { + const pid = parseInt(readFileSync(pf, "utf-8").trim()); + try { process.kill(pid, "SIGTERM"); } catch {} + await sleep(500); + try { unlinkSync(pf); } catch {} + } + }); + + test("daemon reports failure on port collision", async () => { + const port = randomPort(); + // Start a foreground server to occupy the port + const blocker = spawnHttpServer(port, "127.0.0.1"); + try { + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + + // Try to start daemon on the same port — should fail + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--daemon", "--host", "127.0.0.1", "--port", String(port), + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("already in use"); + + // PID file must NOT exist after failed daemon start + expect(existsSync(pidPath())).toBe(false); + } finally { + blocker.kill("SIGTERM"); + await new Promise(r => blocker.on("close", r)); + } + }); + + // ------------------------------------------------------------------------- + // --host flag tests + // ------------------------------------------------------------------------- + + test("foreground HTTP server accepts --host 127.0.0.1", async () => { + const port = randomPort(); + const proc = spawnHttpServer(port, "127.0.0.1"); + try { + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + const res = await fetch(`http://127.0.0.1:${port}/health`); + expect(res.status).toBe(200); + } finally { + proc.kill("SIGTERM"); + await new Promise(r => proc.on("close", r)); + } + }); + + test("--daemon forwards --host to child process", async () => { + const port = randomPort(); + const { stdout, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--daemon", "--host", "127.0.0.1", "--port", String(port), + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`http://127.0.0.1:${port}/mcp`); + + const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); + spawnedPids.push(pid); + + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + + process.kill(pid, "SIGTERM"); + await sleep(500); + try { unlinkSync(pidPath()); } catch {} + }); + + test("--host rejects URL-like values", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "http://127.0.0.1", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("not a URL"); + }); + + test("--host without a value exits with error", async () => { + // parseArgs strict:false gives boolean true for bare --host + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("requires"); + }); + + test("--host with value stolen by next flag exits with error", async () => { + // --host --daemon: parseArgs gives host="--daemon" + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "--daemon", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("requires a hostname"); + }); + + test("--host with host:port pattern exits with error", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "localhost:8181", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("--host and --port separately"); + }); + + test("foreground HTTP server accepts --host 0.0.0.0", async () => { + const port = randomPort(); + const proc = spawnHttpServer(port, "0.0.0.0"); + try { + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + } finally { + proc.kill("SIGTERM"); + await new Promise(r => proc.on("close", r)); + } + }); + + test("foreground HTTP server accepts --host ::1 (bare IPv6)", async () => { + // Check IPv6 availability + const ipv6 = await new Promise((resolve) => { + const { createServer } = require("net"); + const srv = createServer(); + srv.listen(0, "::1", () => { srv.close(() => resolve(true)); }); + srv.on("error", () => resolve(false)); + }); + if (!ipv6) return; // skip gracefully + + const port = randomPort(); + const proc = spawnHttpServer(port, "::1"); + try { + const ready = await waitForServer(port, 5000, "::1"); + expect(ready).toBe(true); + } finally { + proc.kill("SIGTERM"); + await new Promise(r => proc.on("close", r)); + } + }); + + test("foreground HTTP server accepts --host [::1] (bracketed)", async () => { + const ipv6 = await new Promise((resolve) => { + const { createServer } = require("net"); + const srv = createServer(); + srv.listen(0, "::1", () => { srv.close(() => resolve(true)); }); + srv.on("error", () => resolve(false)); + }); + if (!ipv6) return; + + const port = randomPort(); + const proc = spawnHttpServer(port, "[::1]"); + try { + const ready = await waitForServer(port, 5000, "::1"); + expect(ready).toBe(true); + } finally { + proc.kill("SIGTERM"); + await new Promise(r => proc.on("close", r)); + } + }); + + test("--daemon with --host [::1] shows bracketed display in output", async () => { + const port = randomPort(); + const { stdout, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--daemon", "--host", "[::1]", "--port", String(port), + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`http://[::1]:${port}/mcp`); + + // Clean up daemon + try { + const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); + spawnedPids.push(pid); + process.kill(pid, "SIGTERM"); + await sleep(500); + try { unlinkSync(pidPath()); } catch {} + } catch {} + }); + + test("--daemon without --host shows localhost in output", async () => { + const port = randomPort(); + const { stdout, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--daemon", "--port", String(port), + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`http://localhost:${port}/mcp`); + + const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); + spawnedPids.push(pid); + process.kill(pid, "SIGTERM"); + await sleep(500); + try { unlinkSync(pidPath()); } catch {} + }); + + test("--host rejects /tmp/socket (path-like)", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "/tmp/socket", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("not a URL"); + }); + + test("--host rejects 127.0.0.1:8080 (host:port)", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "127.0.0.1:8080", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("--host and --port separately"); + }); + + test("--host rejects [::1]:8080 (bracketed IPv6 with port)", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "[::1]:8080", + ]); + expect(exitCode).toBe(1); + // [::1]:8080 doesn't end with ], so it won't be treated as bracketed IPv6 + // The inner value will contain ":" and fail isIP, triggering the host:port rejection + expect(stderr).toContain("Invalid --host"); + }); + + test("EADDRINUSE error message includes custom host", async () => { + const port = randomPort(); + const proc = spawnHttpServer(port, "127.0.0.1"); + try { + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + + // Now try to start a second foreground server on the same port + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "127.0.0.1", "--port", String(port), + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("already in use on 127.0.0.1"); + } finally { + proc.kill("SIGTERM"); + await new Promise(r => proc.on("close", r)); + } + }); + + test("--host + --port together work correctly via daemon", async () => { + const port = randomPort(); + const { stdout, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--daemon", "--host", "127.0.0.1", "--port", String(port), + ]); + expect(exitCode).toBe(0); + + const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); + spawnedPids.push(pid); + + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + + process.kill(pid, "SIGTERM"); + await sleep(500); + try { unlinkSync(pidPath()); } catch {} + }); + + test("--host position is independent of other flags", async () => { + // Put --host before --http + const port = randomPort(); + const { exitCode, stdout } = await runDaemonQmd([ + "mcp", "--host", "127.0.0.1", "--http", "--daemon", "--port", String(port), + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`http://127.0.0.1:${port}/mcp`); + + const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); + spawnedPids.push(pid); + process.kill(pid, "SIGTERM"); + await sleep(500); + try { unlinkSync(pidPath()); } catch {} + }); + + test("--host with hostname 'localhost' resolves and binds", async () => { + const port = randomPort(); + const proc = spawnHttpServer(port, "localhost"); + try { + const ready = await waitForServer(port, 5000, "localhost"); + expect(ready).toBe(true); + } finally { + proc.kill("SIGTERM"); + await new Promise(r => proc.on("close", r)); + } + }); + + test("MCP protocol works via CLI-spawned server with custom host", async () => { + const port = randomPort(); + const proc = spawnHttpServer(port, "127.0.0.1"); + try { + const ready = await waitForServer(port, 5000, "127.0.0.1"); + expect(ready).toBe(true); + + // Initialize MCP session + const initRes = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }, + }), + }); + expect(initRes.status).toBe(200); + const sessionId = initRes.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + // tools/list with session + const toolsRes = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "mcp-session-id": sessionId!, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), + }); + expect(toolsRes.status).toBe(200); + const tools = await toolsRes.json(); + expect(tools.result.tools.length).toBeGreaterThan(0); + } finally { + proc.kill("SIGTERM"); + await new Promise(r => proc.on("close", r)); + } + }); + + test("--host rejects empty string", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("non-empty"); + }); + + test("--host rejects whitespace-only string", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", " ", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("non-empty"); + }); + + test("--host rejects empty brackets []", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "[]", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("brackets"); + }); + + test("--host rejects bracketed hostname [localhost]", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "[localhost]", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("brackets"); + }); + + test("--host rejects whitespace in host value", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "foo bar", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("whitespace"); + }); + + test("--host rejects --port=8181 (flag-like value)", async () => { + const { stderr, exitCode } = await runDaemonQmd([ + "mcp", "--http", "--host", "--port=8181", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("requires a hostname"); + }); }); diff --git a/test/mcp-host.test.ts b/test/mcp-host.test.ts new file mode 100644 index 00000000..ba9809d1 --- /dev/null +++ b/test/mcp-host.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect } from "vitest"; +import { normalizeMcpHost, validateMcpHostInput } from "../src/mcp-host.js"; + +// ============================================================================= +// normalizeMcpHost — 13 tests +// ============================================================================= + +describe("mcp-host normalization", () => { + it("falls back to localhost for undefined or blank host", () => { + expect(normalizeMcpHost(undefined)).toEqual({ bindHost: "localhost", displayHost: "localhost" }); + expect(normalizeMcpHost(" ")).toEqual({ bindHost: "localhost", displayHost: "localhost" }); + }); + + it("normalizes bracketed IPv6 input and trims whitespace", () => { + expect(normalizeMcpHost("[::1] ")).toEqual({ bindHost: "::1", displayHost: "[::1]" }); + }); + + it("brackets plain IPv6 for display only", () => { + expect(normalizeMcpHost("2001:db8::1")).toEqual({ + bindHost: "2001:db8::1", + displayHost: "[2001:db8::1]", + }); + }); + + it("preserves plain IPv4 address unchanged", () => { + expect(normalizeMcpHost("127.0.0.1")).toEqual({ + bindHost: "127.0.0.1", + displayHost: "127.0.0.1", + }); + }); + + it("preserves 0.0.0.0 (wildcard) unchanged", () => { + expect(normalizeMcpHost("0.0.0.0")).toEqual({ + bindHost: "0.0.0.0", + displayHost: "0.0.0.0", + }); + }); + + it("preserves hostname strings unchanged", () => { + expect(normalizeMcpHost("my-server.local")).toEqual({ + bindHost: "my-server.local", + displayHost: "my-server.local", + }); + expect(normalizeMcpHost("localhost")).toEqual({ + bindHost: "localhost", + displayHost: "localhost", + }); + }); + + it("handles null the same as undefined", () => { + expect(normalizeMcpHost(null)).toEqual({ bindHost: "localhost", displayHost: "localhost" }); + }); + + it("handles empty string as localhost fallback", () => { + expect(normalizeMcpHost("")).toEqual({ bindHost: "localhost", displayHost: "localhost" }); + }); + + it("trims whitespace from non-IPv6 hosts", () => { + expect(normalizeMcpHost(" 127.0.0.1 ")).toEqual({ + bindHost: "127.0.0.1", + displayHost: "127.0.0.1", + }); + }); + + it("handles bracketed IPv6 with inner whitespace", () => { + expect(normalizeMcpHost("[ ::1 ]")).toEqual({ bindHost: "::1", displayHost: "[::1]" }); + }); + + it("handles full IPv6 address without abbreviation", () => { + const full = "0000:0000:0000:0000:0000:0000:0000:0001"; + const result = normalizeMcpHost(full); + expect(result.bindHost).toBe(full); + expect(result.displayHost).toBe(`[${full}]`); + }); + + it("handles IPv4-mapped IPv6 address", () => { + const result = normalizeMcpHost("::ffff:127.0.0.1"); + expect(result.bindHost).toBe("::ffff:127.0.0.1"); + expect(result.displayHost).toBe("[::ffff:127.0.0.1]"); + }); + + it("return shape has exactly bindHost and displayHost keys", () => { + const result = normalizeMcpHost("127.0.0.1"); + expect(Object.keys(result).sort()).toEqual(["bindHost", "displayHost"]); + }); +}); + +// ============================================================================= +// validateMcpHostInput — 9 tests +// ============================================================================= + +describe("mcp-host validation", () => { + it("accepts hostnames and IPs", () => { + for (const valid of ["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0", "my-server.local"]) { + expect(() => validateMcpHostInput(valid)).not.toThrow(); + } + }); + + it("rejects URL-like hosts (://)", () => { + for (const invalid of ["http://127.0.0.1", "/foo", "localhost/path"]) { + expect(() => validateMcpHostInput(invalid)).toThrow(/Invalid --host value/); + } + }); + + it("rejects flag-like values (value-stealing from parseArgs)", () => { + for (const invalid of ["--daemon", "--port", "-p"]) { + expect(() => validateMcpHostInput(invalid)).toThrow(/Invalid --host value/); + } + }); + + it("rejects host:port patterns", () => { + for (const invalid of ["localhost:8181", "127.0.0.1:8080", "[::1]:8080"]) { + expect(() => validateMcpHostInput(invalid)).toThrow(/Invalid --host value/); + } + }); + + it("rejects https and ftp URLs", () => { + expect(() => validateMcpHostInput("https://localhost")).toThrow(/not a URL/); + expect(() => validateMcpHostInput("ftp://server")).toThrow(/not a URL/); + }); + + it("rejects relative paths", () => { + expect(() => validateMcpHostInput("./path")).toThrow(/not a URL/); + expect(() => validateMcpHostInput("../parent")).toThrow(/not a URL/); + }); + + it("rejects --flag=value patterns", () => { + expect(() => validateMcpHostInput("--port=8181")).toThrow(/requires a hostname/); + }); + + it("accepts link-local IPv6 address", () => { + expect(() => validateMcpHostInput("fe80::1")).not.toThrow(); + }); + + it("rejects empty string", () => { + expect(() => validateMcpHostInput("")).toThrow(/non-empty/); + }); + + it("rejects whitespace-only string", () => { + expect(() => validateMcpHostInput(" ")).toThrow(/non-empty/); + }); + + it("rejects empty brackets", () => { + expect(() => validateMcpHostInput("[]")).toThrow(/brackets.*IPv6/); + }); + + it("rejects bracketed hostname", () => { + expect(() => validateMcpHostInput("[localhost]")).toThrow(/brackets.*IPv6/); + }); + + it("rejects bracketed non-IPv6", () => { + expect(() => validateMcpHostInput("[foo]")).toThrow(/brackets.*IPv6/); + }); + + it("rejects bracketed IPv4", () => { + expect(() => validateMcpHostInput("[127.0.0.1]")).toThrow(/brackets.*IPv6/); + }); + + it("rejects whitespace in host", () => { + expect(() => validateMcpHostInput("foo bar")).toThrow(/whitespace/); + }); + + it("rejects tab in host", () => { + expect(() => validateMcpHostInput("foo\tbar")).toThrow(/whitespace/); + }); + + it("error messages include the raw input value", () => { + try { + validateMcpHostInput("http://bad"); + expect.unreachable("should have thrown"); + } catch (e: any) { + expect(e.message).toContain("http://bad"); + } + }); +}); diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 24f13cd6..fec8cebc 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1075,3 +1075,285 @@ describe.skipIf(!!process.env.CI)("MCP HTTP Transport", () => { expect(json.result.content.length).toBeGreaterThan(0); }); }); + +// ============================================================================= +// MCP HTTP host binding — 18 in-process integration tests +// ============================================================================= + +import { createServer as createNetServer } from "node:net"; + +/** Detect IPv6 loopback availability (::1) */ +async function hasIPv6Loopback(): Promise { + return new Promise((resolve) => { + const srv = createNetServer(); + srv.listen(0, "::1", () => { srv.close(() => resolve(true)); }); + srv.on("error", () => resolve(false)); + }); +} + +/** Create a session-tracking MCP client for a given base URL */ +function makeMcpClient(base: string) { + let sessionId: string | null = null; + return async function mcpRequest(body: object) { + const headers: Record = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }; + if (sessionId) headers["mcp-session-id"] = sessionId; + const res = await fetch(`${base}/mcp`, { method: "POST", headers, body: JSON.stringify(body) }); + const sid = res.headers.get("mcp-session-id"); + if (sid) sessionId = sid; + const json = await res.json(); + return { status: res.status, json, sessionId: sid || sessionId }; + }; +} + +describe("MCP HTTP host binding", () => { + let ipv6Available = false; + // Stash/restore env + const origIndexPath = process.env.INDEX_PATH; + const origConfigDir = process.env.QMD_CONFIG_DIR; + let hostTestDbPath: string; + let hostTestConfigDir: string; + + beforeAll(async () => { + ipv6Available = await hasIPv6Loopback(); + + // Create isolated test database + hostTestDbPath = `/tmp/qmd-mcp-host-test-${Date.now()}.sqlite`; + const db = openDatabase(hostTestDbPath); + initTestDatabase(db); + seedTestData(db); + db.close(); + + // Create isolated YAML config + const configPrefix = join(tmpdir(), `qmd-mcp-host-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); + hostTestConfigDir = await mkdtemp(configPrefix); + const testConfig: CollectionConfig = { + collections: { docs: { path: "/test/docs", pattern: "**/*.md" } }, + }; + await writeFile(join(hostTestConfigDir, "index.yml"), YAML.stringify(testConfig)); + + process.env.INDEX_PATH = hostTestDbPath; + process.env.QMD_CONFIG_DIR = hostTestConfigDir; + }); + + afterAll(async () => { + if (origIndexPath !== undefined) process.env.INDEX_PATH = origIndexPath; + else delete process.env.INDEX_PATH; + if (origConfigDir !== undefined) process.env.QMD_CONFIG_DIR = origConfigDir; + else delete process.env.QMD_CONFIG_DIR; + try { unlinkSync(hostTestDbPath); } catch {} + try { + const files = await readdir(hostTestConfigDir); + for (const f of files) await unlink(join(hostTestConfigDir, f)); + await rmdir(hostTestConfigDir); + } catch {} + }); + + // Helper: start server and ensure cleanup + async function startServer(opts: { host?: string; port?: number } = {}) { + const handle = await startMcpHttpServer(opts.port ?? 0, { quiet: true, host: opts.host }); + return handle; + } + + // --- Binding tests --- + + test("explicit IPv4 bind serves health check", async () => { + const h = await startServer({ host: "127.0.0.1" }); + try { + const res = await fetch(`http://127.0.0.1:${h.port}/health`); + expect(res.status).toBe(200); + } finally { await h.stop(); } + }); + + test("bracketed IPv6 normalization binds to ::1", async () => { + if (!ipv6Available) return; + const h = await startServer({ host: "[::1]" }); + try { + const addr = h.httpServer.address() as import("net").AddressInfo; + expect(addr.address).toBe("::1"); + } finally { await h.stop(); } + }); + + test("empty host falls back to localhost", async () => { + const h = await startServer({ host: " " }); + try { + const res = await fetch(`http://localhost:${h.port}/health`); + expect(res.status).toBe(200); + } finally { await h.stop(); } + }); + + test("no host option defaults to localhost", async () => { + const h = await startServer(); + try { + const res = await fetch(`http://localhost:${h.port}/health`); + expect(res.status).toBe(200); + } finally { await h.stop(); } + }); + + test("host: undefined defaults to localhost", async () => { + const h = await startServer({ host: undefined }); + try { + const res = await fetch(`http://localhost:${h.port}/health`); + expect(res.status).toBe(200); + } finally { await h.stop(); } + }); + + test("ephemeral port (0) reports actual bound port", async () => { + const h = await startServer({ host: "127.0.0.1", port: 0 }); + try { + expect(h.port).toBeGreaterThan(0); + expect(h.port).not.toBe(0); + } finally { await h.stop(); } + }); + + test("bound address matches requested host for IPv4", async () => { + const h = await startServer({ host: "127.0.0.1" }); + try { + const addr = h.httpServer.address() as import("net").AddressInfo; + expect(addr.address).toBe("127.0.0.1"); + } finally { await h.stop(); } + }); + + test("bound address matches requested host for IPv6", async () => { + if (!ipv6Available) return; + const h = await startServer({ host: "::1" }); + try { + const addr = h.httpServer.address() as import("net").AddressInfo; + expect(addr.address).toBe("::1"); + } finally { await h.stop(); } + }); + + test("wildcard 0.0.0.0 is reachable from 127.0.0.1", async () => { + const h = await startServer({ host: "0.0.0.0" }); + try { + const res = await fetch(`http://127.0.0.1:${h.port}/health`); + expect(res.status).toBe(200); + } finally { await h.stop(); } + }); + + test("EADDRINUSE when binding same host:port twice", async () => { + const h1 = await startServer({ host: "127.0.0.1" }); + try { + await expect(startServer({ host: "127.0.0.1", port: h1.port })).rejects.toMatchObject({ code: "EADDRINUSE" }); + } finally { await h1.stop(); } + }); + + test("dual-stack: same port on different hosts (IPv4 vs IPv6)", async () => { + if (!ipv6Available) return; + const h1 = await startServer({ host: "127.0.0.1" }); + try { + const h2 = await startServer({ host: "::1", port: h1.port }); + try { + const r1 = await fetch(`http://127.0.0.1:${h1.port}/health`); + const r2 = await fetch(`http://[::1]:${h2.port}/health`); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + } finally { await h2.stop(); } + } finally { await h1.stop(); } + }); + + // --- MCP session tests on custom host --- + + test("MCP session works over explicit IPv4 host", async () => { + const h = await startServer({ host: "127.0.0.1" }); + try { + const client = makeMcpClient(`http://127.0.0.1:${h.port}`); + const init = await client({ + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }, + }); + expect(init.status).toBe(200); + expect(init.sessionId).toBeTruthy(); + + const tools = await client({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + expect(tools.status).toBe(200); + expect(tools.json.result.tools.length).toBeGreaterThan(0); + } finally { await h.stop(); } + }); + + test("MCP session works over IPv6 host", async () => { + if (!ipv6Available) return; + const h = await startServer({ host: "::1" }); + try { + const client = makeMcpClient(`http://[::1]:${h.port}`); + const init = await client({ + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }, + }); + expect(init.status).toBe(200); + } finally { await h.stop(); } + }); + + test("concurrent sessions on custom host both succeed", async () => { + const h = await startServer({ host: "127.0.0.1" }); + try { + const clientA = makeMcpClient(`http://127.0.0.1:${h.port}`); + const clientB = makeMcpClient(`http://127.0.0.1:${h.port}`); + const initA = await clientA({ + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "testA", version: "1.0" } }, + }); + const initB = await clientB({ + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "testB", version: "1.0" } }, + }); + expect(initA.sessionId).not.toBe(initB.sessionId); + + const toolsA = await clientA({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + const toolsB = await clientB({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + expect(toolsA.status).toBe(200); + expect(toolsB.status).toBe(200); + } finally { await h.stop(); } + }); + + test("missing session ID returns 400 on custom host", async () => { + const h = await startServer({ host: "127.0.0.1" }); + try { + const res = await fetch(`http://127.0.0.1:${h.port}/mcp`, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.message).toContain("Missing session ID"); + } finally { await h.stop(); } + }); + + test("invalid session ID returns 404 on custom host", async () => { + const h = await startServer({ host: "127.0.0.1" }); + try { + const res = await fetch(`http://127.0.0.1:${h.port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "mcp-session-id": "nonexistent-uuid", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.error.message).toContain("Session not found"); + } finally { await h.stop(); } + }); + + test("stop() shuts down server on custom host", async () => { + const h = await startServer({ host: "127.0.0.1" }); + const port = h.port; + await h.stop(); + await expect(fetch(`http://127.0.0.1:${port}/health`)).rejects.toThrow(); + }); + + test("health endpoint returns correct JSON shape on custom host", async () => { + const h = await startServer({ host: "127.0.0.1" }); + try { + const res = await fetch(`http://127.0.0.1:${h.port}/health`); + const body = await res.json(); + expect(body).toHaveProperty("status", "ok"); + expect(typeof body.uptime).toBe("number"); + } finally { await h.stop(); } + }); +});