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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
64 changes: 53 additions & 11 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,16 +868,18 @@ export async function startMcpServer(options: McpStartupOptions = {}): Promise<v
// =============================================================================

export type HttpServerHandle = {
/** First bound listener; close() closes every listener. */
httpServer: import("http").Server;
port: number;
stop: () => Promise<void>;
};

/**
* 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
Expand Down Expand Up @@ -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];

Expand Down Expand Up @@ -1143,21 +1145,61 @@ export async function startMcpHttpServer(
nodeRes.writeHead(500);
nodeRes.end("Internal Server Error");
}
});
};

await new Promise<void>((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<void>((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<void>(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<void>(resolve => httpServer.close(() => resolve()));
await store.close();
};

Expand Down
153 changes: 153 additions & 0 deletions test/mcp-http-loopback.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<void> {
return new Promise((resolve, reject) => {
server.close(error => error ? reject(error) : resolve());
});
}

function getMcp(hostname: string, port: number, hostHeader?: string): Promise<number> {
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<HttpServerHandle> {
handle = await startMcpHttpServer(port, { quiet: true, dbPath: join(workDir, "index.sqlite"), host });
return handle;
}

async function occupy(host: string, port = 0): Promise<number> {
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);
});
});
8 changes: 8 additions & 0 deletions test/mcp-origin-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading