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
98 changes: 89 additions & 9 deletions src/cli/qmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2537,6 +2539,7 @@ function showHelp(): void {
console.log(" qmd multi-get <pattern> - 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");
Expand Down Expand Up @@ -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 <name> - Use a named index (default: index)");
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand All @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions src/mcp-host.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
12 changes: 8 additions & 4 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HttpServerHandle> {
export async function startMcpHttpServer(port: number, options?: { quiet?: boolean; host?: string }): Promise<HttpServerHandle> {
const { bindHost, displayHost } = normalizeMcpHost(options?.host);
const store = await createStore({ dbPath: getDefaultDbPath() });

// Pre-fetch default collection names for REST endpoint
Expand Down Expand Up @@ -769,7 +773,7 @@ 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, () => resolve());
});

const actualPort = (httpServer.address() as import("net").AddressInfo).port;
Expand Down Expand Up @@ -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 };
}

Expand Down
Loading