From 3653f6015c32e0487704eed5b25022eea5f8cf28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobi=20L=C3=BCtke?= Date: Sat, 9 May 2026 17:54:08 +0000 Subject: [PATCH] Fix MCP stdio native log pollution --- CHANGELOG.md | 1 + bin/qmd | 10 ++++++++ test/cli.test.ts | 66 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde9802..1af3da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bin/qmd b/bin/qmd index f658b3b..7522b2e 100755 --- a/bin/qmd +++ b/bin/qmd @@ -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). diff --git a/test/cli.test.ts b/test/cli.test.ts index 2e49deb..5748676 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -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((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 }); + } + }); +});