Skip to content

mcp-smoke.mjs

Source file: qa/mcp-smoke.mjs. Copy it from the repository, or use the copy button on the code block.

mcp-smoke.mjs
#!/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 point
if (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);
}