Fix MCP stdio native log pollution

This commit is contained in:
Tobi Lütke 2026-05-09 17:54:08 +00:00
parent 3f055e705d
commit 3653f6015c
No known key found for this signature in database
3 changed files with 76 additions and 1 deletions

View File

@ -10,6 +10,7 @@
hashes referenced by sibling collections, and drops `vectors_vec` only
when the scoped clear empties all vectors.
- Hybrid search: weight RRF lists by query type so original FTS and original vector evidence get the intended 2x boost, instead of accidentally boosting the first lexical expansion. #591
- MCP: seed llama.cpp/GGML quiet env vars before launching `qmd mcp` so native logs cannot pollute stdio JSON-RPC framing. #593
- GPU: respect explicit `QMD_LLAMA_GPU=metal|vulkan|cuda` backend overrides instead of always using auto GPU selection. #529
- Fix: preserve original filename case in `handelize()`. The previous
`.toLowerCase()` call made indexed paths unreachable on case-sensitive

10
bin/qmd
View File

@ -15,6 +15,16 @@ done
# to avoid native module ABI mismatches (e.g., better-sqlite3 compiled for bun vs node)
DIR="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
# MCP stdio reserves stdout exclusively for JSON-RPC frames. node-llama-cpp
# / llama.cpp / ggml can write native logs directly to stdout before JS-level
# log handlers are attached, so seed the native quiet env before Node/Bun imports
# the CLI and its LLM modules. Preserve explicit user values when provided.
if [ "$1" = "mcp" ]; then
export LLAMA_LOG_LEVEL="${LLAMA_LOG_LEVEL:-error}"
export GGML_LOG_LEVEL="${GGML_LOG_LEVEL:-error}"
export GGML_BACKEND_SILENT="${GGML_BACKEND_SILENT:-1}"
fi
# Detect the package manager that installed dependencies by checking lockfiles.
# $BUN_INSTALL is intentionally NOT checked — it only indicates that bun exists
# on the system, not that it was used to install this package (see #361).

View File

@ -6,7 +6,7 @@
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
import { chmod, copyFile, mkdtemp, rm, writeFile, mkdir } from "fs/promises";
import { existsSync, lstatSync, readFileSync, symlinkSync, writeFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join, dirname } from "path";
@ -1601,3 +1601,67 @@ describe("mcp http daemon", () => {
try { unlinkSync(pidPath()); } catch {}
});
});
// =============================================================================
// MCP stdio stdout hygiene
// =============================================================================
describe("mcp stdio launcher", () => {
test("sets native llama/ggml quiet env before Node starts so stdout stays JSON-RPC only", async () => {
const tempPackage = await mkdtemp(join(tmpdir(), "qmd-bin-mcp-"));
try {
await mkdir(join(tempPackage, "bin"), { recursive: true });
await mkdir(join(tempPackage, "dist", "cli"), { recursive: true });
await mkdir(join(tempPackage, "fake-bin"), { recursive: true });
const qmdBin = join(tempPackage, "bin", "qmd");
await copyFile(join(projectRoot, "bin", "qmd"), qmdBin);
await chmod(qmdBin, 0o755);
// Force the wrapper down the Node branch, then put our fake `node` first
// in PATH. The fake node behaves like the native llama/ggml layer: it
// writes a non-JSON stdout line unless qmd pre-seeded the documented
// quiet env vars before launching JS.
await writeFile(join(tempPackage, "package-lock.json"), "{}\n");
const fakeNode = join(tempPackage, "fake-bin", "node");
await writeFile(fakeNode, `#!/bin/sh
if [ "\${GGML_BACKEND_SILENT:-}" != "1" ]; then
printf 'llama.cpp native log on stdout\\n'
fi
printf '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}\\n'
`);
await chmod(fakeNode, 0o755);
const proc = spawn(qmdBin, ["mcp"], {
cwd: tempPackage,
env: {
...process.env,
PATH: `${join(tempPackage, "fake-bin")}:${process.env.PATH}`,
LLAMA_LOG_LEVEL: "",
GGML_LOG_LEVEL: "",
GGML_BACKEND_SILENT: "",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString(); });
proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
const exitCode = await new Promise<number>((resolve, reject) => {
proc.once("error", reject);
proc.on("close", (code) => resolve(code ?? 1));
});
expect(exitCode).toBe(0);
expect(stderr).toBe("");
const lines = stdout.trim().split("\n").filter(Boolean);
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(() => JSON.parse(line)).not.toThrow();
}
} finally {
await rm(tempPackage, { recursive: true, force: true });
}
});
});