Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ qmd query <query> # Search with query expansion + reranking (rec
qmd search <query> # Full-text keyword search (BM25, no LLM)
qmd vsearch <query> # 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
```
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions skills/qmd/references/mcp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 17 additions & 7 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpServerHandle> {
export async function startMcpHttpServer(port: number, options?: { quiet?: boolean; host?: string }): Promise<HttpServerHandle> {
const store = createStore();
const mcpServer = createMcpServer(store);
const transport = new WebStandardStreamableHTTPServerTransport({
Expand All @@ -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
Expand Down Expand Up @@ -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<string, string> = {};
for (const [k, v] of Object.entries(nodeReq.headers)) {
if (typeof v === "string") headers[k] = v;
Expand All @@ -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<string, string> = {};
for (const [k, v] of Object.entries(nodeReq.headers)) {
if (typeof v === "string") headers[k] = v;
Expand All @@ -705,10 +712,13 @@ export async function startMcpHttpServer(port: number, options?: { quiet?: boole

await new Promise<void>((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 () => {
Expand All @@ -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 };
}

Expand Down
25 changes: 21 additions & 4 deletions src/qmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2332,7 +2333,7 @@ function showHelp(): void {
console.log(" qmd search <query> - Full-text keyword search (BM25, no LLM)");
console.log(" qmd vsearch <query> - 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("");
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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);
}
Expand All @@ -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;
Expand Down
214 changes: 214 additions & 0 deletions test/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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<string, string>;
});

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<void>((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<number | null>((resolve) =>
child.on("close", (code) => resolve(code))
);

expect(exitCode).toBe(1);
expect(stderr).toContain("Invalid --host value");
expect(stderr).toContain("not a URL");
});
});