From 8738690c5ddb079de6e9b97cd8fd24bcf9b9d55d Mon Sep 17 00:00:00 2001 From: Kledd Date: Fri, 20 Feb 2026 02:49:18 -0500 Subject: [PATCH] feat(mcp): add --host flag to HTTP server bind address Allow users to specify the bind address for the MCP HTTP server, solving IPv6/IPv4 mismatch when Docker containers (Colima) route host.docker.internal to 127.0.0.1 while Node resolves localhost to ::1. - Add host option to startMcpHttpServer() with IPv6-safe URL formatting - Add --host to CLI parseArgs, daemon spawn forwarding, and help text - Validate --host input (reject URLs) and normalize bracketed IPv6 - Set listeningPort inside listen() callback for correct --port 0 handling - Update forwarding URLs to use configured host instead of hardcoded localhost - Add 7 tests: default host, explicit IPv4, AddressInfo, MCP handshake, bracketed IPv6 normalization, daemon forwarding, invalid input rejection - Update CLAUDE.md, README.md, mcp-setup.md with --host examples Closes tobi/qmd#227 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- README.md | 1 + skills/qmd/references/mcp-setup.md | 7 +- src/mcp.ts | 24 +++- src/qmd.ts | 25 +++- test/mcp.test.ts | 214 +++++++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 028005b3..336a943a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ qmd query # Search with query expansion + reranking (rec qmd search # Full-text keyword search (BM25, no LLM) qmd vsearch # Vector similarity search (no reranking) qmd mcp # Start MCP server (stdio transport) -qmd mcp --http [--port N] # Start MCP server (HTTP, default port 8181) +qmd mcp --http [--host ADDR] [--port N] # Start MCP server (HTTP, default localhost:8181) qmd mcp --http --daemon # Start as background daemon qmd mcp stop # Stop background MCP daemon ``` diff --git a/README.md b/README.md index 42d5efd9..f47206b3 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ By default, QMD's MCP server uses stdio (launched as a subprocess by each client # Foreground (Ctrl-C to stop) qmd mcp --http # localhost:8181 qmd mcp --http --port 8080 # custom port +qmd mcp --http --host 127.0.0.1 # explicit IPv4 bind (useful for Docker/Colima) # Background daemon qmd mcp --http --daemon # start, writes PID to ~/.cache/qmd/mcp.pid diff --git a/skills/qmd/references/mcp-setup.md b/skills/qmd/references/mcp-setup.md index 5d32a62a..60b6ac8e 100644 --- a/skills/qmd/references/mcp-setup.md +++ b/skills/qmd/references/mcp-setup.md @@ -42,9 +42,10 @@ qmd embed ## HTTP Mode ```bash -qmd mcp --http # Port 8181 -qmd mcp --http --daemon # Background -qmd mcp stop # Stop daemon +qmd mcp --http # Port 8181 +qmd mcp --http --host 127.0.0.1 # Explicit IPv4 (for Docker/Colima) +qmd mcp --http --daemon # Background +qmd mcp stop # Stop daemon ``` ## Tools diff --git a/src/mcp.ts b/src/mcp.ts index 323f4698..b93c93bc 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -551,9 +551,9 @@ 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 the specified host (default: "localhost"). 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 store = createStore(); const mcpServer = createMcpServer(store); const transport = new WebStandardStreamableHTTPServerTransport({ @@ -565,6 +565,13 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole const startTime = Date.now(); const quiet = options?.quiet ?? false; + // Normalize bracketed IPv6 input from direct API callers: "[::1]" → "::1" + const rawHost = options?.host ?? "localhost"; + const bindHost = (rawHost.startsWith("[") && rawHost.endsWith("]")) ? rawHost.slice(1, -1) : rawHost; + // IPv6 addresses must be bracketed in URLs: http://[::1]:8181 not http://::1:8181 + const urlHost = bindHost.includes(":") ? `[${bindHost}]` : bindHost; + let listeningPort = port; // updated to actualPort inside listen callback + /** Format timestamp for request logging */ function ts(): string { return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS @@ -667,7 +674,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole const rawBody = await collectBody(nodeReq); const body = JSON.parse(rawBody); const label = describeRequest(body); - const url = `http://localhost:${port}${pathname}`; + const url = `http://${urlHost}:${listeningPort}${pathname}`; const headers: Record = {}; for (const [k, v] of Object.entries(nodeReq.headers)) { if (typeof v === "string") headers[k] = v; @@ -681,7 +688,7 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole } if (pathname === "/mcp") { - const url = `http://localhost:${port}${pathname}`; + const url = `http://${urlHost}:${listeningPort}${pathname}`; const headers: Record = {}; for (const [k, v] of Object.entries(nodeReq.headers)) { if (typeof v === "string") headers[k] = v; @@ -705,10 +712,13 @@ 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, () => { + listeningPort = (httpServer.address() as import("net").AddressInfo).port; + resolve(); + }); }); - const actualPort = (httpServer.address() as import("net").AddressInfo).port; + const actualPort = listeningPort; let stopping = false; const stop = async () => { @@ -731,7 +741,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://${urlHost}:${actualPort}/mcp`); return { httpServer, port: actualPort, stop }; } diff --git a/src/qmd.ts b/src/qmd.ts index d57b7e8c..4cd53217 100755 --- a/src/qmd.ts +++ b/src/qmd.ts @@ -2267,6 +2267,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 @@ -2332,7 +2333,7 @@ function showHelp(): void { console.log(" qmd search - Full-text keyword search (BM25, no LLM)"); console.log(" qmd vsearch - Vector similarity search (no reranking)"); console.log(" qmd mcp - Start MCP server (stdio transport)"); - console.log(" qmd mcp --http [--port N] - Start MCP server (HTTP transport, default port 8181)"); + console.log(" qmd mcp --http [--host ADDR] [--port N] - Start MCP server (HTTP transport)"); console.log(" qmd mcp --http --daemon - Start MCP server as background daemon"); console.log(" qmd mcp stop - Stop background MCP daemon"); console.log(""); @@ -2736,6 +2737,17 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW if (cli.values.http) { const port = Number(cli.values.port) || 8181; + let host = (cli.values.host as string) || undefined; + + // Validate --host input: reject values containing scheme or path + if (host && (host.includes("://") || host.includes("/"))) { + console.error(`Invalid --host value: "${host}". Provide a hostname or IP address, not a URL.`); + process.exit(1); + } + // Normalize bracketed IPv6 input: [::1] → ::1 (prevents double-bracketing in URL formatting) + if (host?.startsWith("[") && host.endsWith("]")) { + host = host.slice(1, -1); + } if (cli.values.daemon) { // Guard: check if already running @@ -2757,6 +2769,9 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW 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) { + spawnArgs.push("--host", host); + } const child = nodeSpawn(process.execPath, spawnArgs, { stdio: ["ignore", logFd, logFd], detached: true, @@ -2765,7 +2780,9 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW 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})`); + const displayHost = host ?? "localhost"; + const displayUrlHost = displayHost.includes(":") ? `[${displayHost}]` : displayHost; + console.log(`Started on http://${displayUrlHost}:${port}/mcp (PID ${child.pid})`); console.log(`Logs: ${logPath}`); process.exit(0); } @@ -2776,10 +2793,10 @@ if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsW process.removeAllListeners("SIGINT"); const { startMcpHttpServer } = await import("./mcp.js"); try { - await startMcpHttpServer(port); + await startMcpHttpServer(port, { host }); } catch (e: any) { 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 ${host ?? "localhost"}. Try a different port with --port.`); process.exit(1); } throw e; diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 24cf528b..086f1b3f 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1047,3 +1047,217 @@ describe("MCP HTTP Transport", () => { expect(json.result.content.length).toBeGreaterThan(0); }); }); + +// ============================================================================= +// Host binding tests +// ============================================================================= + +describe("host binding", () => { + let hostTestDbPath: string; + let hostTestConfigDir: string; + const origIndexPath = process.env.INDEX_PATH; + const origConfigDir = process.env.QMD_CONFIG_DIR; + + beforeAll(async () => { + hostTestDbPath = `/tmp/qmd-mcp-host-test-${Date.now()}.sqlite`; + const db = openDatabase(hostTestDbPath); + initTestDatabase(db); + db.close(); + + 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 { require("fs").unlinkSync(hostTestDbPath); } catch {} + try { + const files = await readdir(hostTestConfigDir); + for (const f of files) await unlink(join(hostTestConfigDir, f)); + await rmdir(hostTestConfigDir); + } catch {} + }); + + test("default host: server is reachable via localhost", async () => { + const handle = await startMcpHttpServer(0, { quiet: true }); + try { + const res = await fetch(`http://localhost:${handle.port}/health`); + expect(res.status).toBe(200); + } finally { + await handle.stop(); + } + }); + + test("host option: server binds to specified address", async () => { + const handle = await startMcpHttpServer(0, { quiet: true, host: "127.0.0.1" }); + try { + const res = await fetch(`http://127.0.0.1:${handle.port}/health`); + expect(res.status).toBe(200); + } finally { + await handle.stop(); + } + }); + + test("host option: server reports correct bound address", async () => { + const handle = await startMcpHttpServer(0, { quiet: true, host: "127.0.0.1" }); + try { + const addr = handle.httpServer.address() as import("net").AddressInfo; + expect(addr.address).toBe("127.0.0.1"); + } finally { + await handle.stop(); + } + }); + + test("host option: MCP protocol works on custom host", async () => { + const handle = await startMcpHttpServer(0, { quiet: true, host: "127.0.0.1" }); + try { + const res = await fetch(`http://127.0.0.1:${handle.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(res.status).toBe(200); + } finally { + await handle.stop(); + } + }); + + test("host option: bracketed IPv6 input is normalized", async () => { + // Simulates --host [::1] — brackets should be stripped before bind + const handle = await startMcpHttpServer(0, { quiet: true, host: "[::1]" }); + try { + const addr = handle.httpServer.address() as import("net").AddressInfo; + // Should bind to ::1 (without brackets), not "[::1]" + expect(addr.address).toBe("::1"); + // Verify reachability via bracketed URL notation + const res = await fetch(`http://[::1]:${handle.port}/health`); + expect(res.status).toBe(200); + } finally { + await handle.stop(); + } + }); +}); + +// ============================================================================= +// Daemon --host forwarding (integration tests) +// ============================================================================= + +import { spawn, execSync } from "node:child_process"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs"; + +const thisDir = dirname(fileURLToPath(import.meta.url)); + +describe("daemon --host forwarding", () => { + const qmdScript = resolve(thisDir, "..", "src", "qmd.ts"); + const tsxBin = resolve(thisDir, "..", "node_modules", ".bin", "tsx"); + + // Isolated state dirs — prevents `mcp stop` from touching real user daemons + let daemonCacheDir: string; + let daemonConfigDir: string; + let testEnv: Record; + let daemonPid: number | undefined; + + beforeAll(() => { + daemonCacheDir = mkdtempSync(join(tmpdir(), "qmd-test-cache-")); + daemonConfigDir = mkdtempSync(join(tmpdir(), "qmd-test-config-")); + testEnv = { + ...process.env, + XDG_CACHE_HOME: daemonCacheDir, + QMD_CONFIG_DIR: daemonConfigDir, + } as Record; + }); + + afterAll(() => { + // Kill daemon if still running + if (daemonPid) { + try { process.kill(daemonPid, "SIGTERM"); } catch {} + } + // Also try `mcp stop` with isolated env as safety net + try { + execSync(`"${tsxBin}" "${qmdScript}" mcp stop`, { + env: testEnv, + timeout: 5000, + stdio: "ignore", + }); + } catch {} + // Clean up temp dirs + rmSync(daemonCacheDir, { recursive: true, force: true }); + rmSync(daemonConfigDir, { recursive: true, force: true }); + }); + + test("daemon spawn forwards --host to child process", async () => { + const port = 19876 + Math.floor(Math.random() * 1000); + + const child = spawn(tsxBin, [ + qmdScript, "mcp", "--http", "--daemon", + "--host", "127.0.0.1", "--port", String(port), + ], { + env: testEnv, + stdio: "pipe", + }); + + // Wait for spawn to complete (daemon detaches immediately) + await new Promise((resolve) => child.on("close", () => resolve())); + + // Read PID from isolated PID file for cleanup + const pidPath = join(daemonCacheDir, "qmd", "mcp.pid"); + if (existsSync(pidPath)) { + daemonPid = parseInt(readFileSync(pidPath, "utf-8").trim()); + } + + // Poll health endpoint until ready (robust for CI / loaded machines) + const deadline = Date.now() + 10000; + let healthy = false; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + if (res.status === 200) { healthy = true; break; } + } catch {} + await new Promise((r) => setTimeout(r, 200)); + } + + expect(healthy).toBe(true); + }, 15000); + + test("invalid --host value exits with error", async () => { + const child = spawn(tsxBin, [ + qmdScript, "mcp", "--http", "--host", "http://127.0.0.1", + ], { + env: testEnv, + stdio: "pipe", + }); + + let stderr = ""; + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); + + const exitCode = await new Promise((resolve) => + child.on("close", (code) => resolve(code)) + ); + + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid --host value"); + expect(stderr).toContain("not a URL"); + }); +});