mcp-smoke.mjs
Source file: qa/mcp-smoke.mjs. Copy it from the repository, or use the copy button on the code block.
#!/usr/bin/env node/** * MCP stdio smoke test. Zero dependencies. * * node qa/mcp-smoke.mjs <command> [args...] * MCP_SMOKE_CALL='search_notes {"query":"x"}' node qa/mcp-smoke.mjs node build/index.js * * Launches the server, performs the MCP handshake, lists tools (and resources/prompts * if advertised), optionally calls tools, and fails on protocol errors or stdout pollution. * Can also be imported: `import { smoke } from "./mcp-smoke.mjs"`. */import { spawn } from "node:child_process";import { fileURLToPath } from "node:url";import { resolve } from "node:path";
// 2025-era `initialize` opening: served by v1 servers and, by default, by v2 servers too.const PROTOCOL_VERSION = "2025-06-18";
/** * @param {string} command * @param {string[]} args * @param {{ env?: Record<string,string>, cwd?: string, timeoutMs?: number, * calls?: { name: string, arguments?: object, expectError?: boolean, expectText?: string }[] }} [opts] * @returns {Promise<{ ok: boolean, report: string[] }>} */export function smoke(command, args, opts = {}) { const { env = {}, cwd, timeoutMs = 30_000, calls = [] } = opts; const report = []; const needsShell = process.platform === "win32" && !/^(node|python3?)(\.exe)?$/i.test(command); const child = spawn(command, args, { cwd, env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"], shell: needsShell });
let stderr = ""; let buffer = ""; let nextId = 1; const pending = new Map(); let fatal = null;
child.stderr.on("data", (d) => (stderr += d.toString())); child.stdout.on("data", (d) => { buffer += d.toString(); let idx; while ((idx = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { fatal = `stdout pollution: non-JSON line on stdout: ${line.slice(0, 120)} (log to stderr instead)`; for (const p of pending.values()) p.reject(new Error(fatal)); continue; } if (msg.jsonrpc !== "2.0") fatal = `message without jsonrpc "2.0": ${line.slice(0, 120)}`; if (msg.id !== undefined && pending.has(msg.id)) { const p = pending.get(msg.id); pending.delete(msg.id); msg.error ? p.reject(new Error(`${p.method} → JSON-RPC error ${msg.error.code}: ${msg.error.message}`)) : p.resolve(msg.result); } } });
const exited = new Promise((res) => child.on("exit", (code) => res(code))); child.on("error", (e) => { fatal = `failed to start "${command}": ${e.message}`; for (const p of pending.values()) p.reject(new Error(fatal)); });
const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); const request = (method, params) => new Promise((resolveReq, rejectReq) => { if (fatal) return rejectReq(new Error(fatal)); const id = nextId++; const timer = setTimeout(() => { pending.delete(id); rejectReq(new Error(`${method} timed out after ${timeoutMs} ms`)); }, timeoutMs); pending.set(id, { method, resolve: (r) => (clearTimeout(timer), resolveReq(r)), reject: (e) => (clearTimeout(timer), rejectReq(e)), }); send({ jsonrpc: "2.0", id, method, params }); });
const run = async () => { const init = await request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "mcp-smoke", version: "1.0.0" }, }); if (!init?.serverInfo?.name) throw new Error("initialize result has no serverInfo.name"); report.push(`server: ${init.serverInfo.name} ${init.serverInfo.version ?? ""} (protocol ${init.protocolVersion})`); send({ jsonrpc: "2.0", method: "notifications/initialized" });
const caps = init.capabilities ?? {}; if (!caps.tools) throw new Error("server does not advertise the tools capability");
const { tools = [] } = await request("tools/list", {}); if (!tools.length) throw new Error("tools/list returned no tools"); for (const t of tools) { if (!t.name) throw new Error("a tool has no name"); if (!/^[A-Za-z0-9_.-]{1,64}$/.test(t.name)) throw new Error(`tool name "${t.name}" has invalid characters or is too long`); if (!t.description || t.description.length < 20) throw new Error(`tool "${t.name}" needs a meaningful description`); if (t.inputSchema?.type !== "object") throw new Error(`tool "${t.name}" inputSchema.type must be "object"`); } report.push(`tools: ${tools.map((t) => t.name).join(", ")}`);
if (caps.resources) { const { resources = [] } = await request("resources/list", {}); report.push(`resources: ${resources.map((r) => r.uri).join(", ") || "(none)"}`); } if (caps.prompts) { const { prompts = [] } = await request("prompts/list", {}); report.push(`prompts: ${prompts.map((p) => p.name).join(", ") || "(none)"}`); }
for (const call of calls) { const result = await request("tools/call", { name: call.name, arguments: call.arguments ?? {} }); const text = (result?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n"); if (!!result?.isError !== !!call.expectError) { throw new Error(`tools/call ${call.name}: expected isError=${!!call.expectError}, got ${!!result?.isError}: ${text.slice(0, 200)}`); } if (call.expectText && !text.includes(call.expectText)) { throw new Error(`tools/call ${call.name}: expected text to include "${call.expectText}", got: ${text.slice(0, 200)}`); } report.push(`call ${call.name}: ok${call.expectError ? " (expected error)" : ""}`); } if (fatal) throw new Error(fatal); };
return run() .then(() => ({ ok: true, report })) .catch((e) => { report.push(`ERROR: ${e.message}`); if (stderr.trim()) report.push(`server stderr:\n${stderr.trim().split("\n").slice(-15).join("\n")}`); return { ok: false, report }; }) .finally(async () => { child.stdin.end(); child.kill(); await Promise.race([exited, new Promise((r) => setTimeout(r, 2000))]); });}
// CLI entry pointif (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const [command, ...args] = process.argv.slice(2); if (!command) { console.error("usage: node qa/mcp-smoke.mjs <command> [args...]"); process.exit(2); } const calls = []; if (process.env.MCP_SMOKE_CALL) { const m = process.env.MCP_SMOKE_CALL.match(/^(\S+)\s*(.*)$/); calls.push({ name: m[1], arguments: m[2] ? JSON.parse(m[2]) : {} }); } const { ok, report } = await smoke(command, args, { calls }); for (const line of report) console.log(` ${line}`); console.log(ok ? "PASS" : "FAIL"); process.exit(ok ? 0 : 1);}