Files
proffesor-for-testing__agen…/scripts/smoke-mcp-protocol.mjs
Dragan Spiridonov b1a462fba2 feat(mcp): protocol-compliance smoke + tool-parity audit (#510 item 4)
Ports ruflo's #1874 discipline. Replaces the FAKE mcp:validate theater
(echo 'All tools registered' && exit 0) with a real smoke that boots the
actual MCP server (tsx src/mcp/entry.ts) over stdio and validates the
initialize + tools/list handshake against the MCP SPEC, not the code:
  - protocolVersion is a YYYY-MM-DD string (the core ruflo #1874 guard — an
    object here is what Claude Code's Zod rejects)
  - capabilities is an object; serverInfo.name/version are non-empty strings
  - tools/list returns a non-empty array, each entry name + object inputSchema

scripts/audit-mcp-tool-parity.mjs is the AQE-equivalent of ruflo's CLI<->MCP
parity audit (the CLI has no callMCPTool('name') string pattern, so the
meaningful surface is advertisement<->handler parity). Drives the advertised
set from a live tools/list, diffs a monotone baseline
(verification/mcp-tool-parity-baseline.json, 86 tools), fails only on NEW
discrepancies (removed tool = regression), and probes that unknown-tool
tools/call is rejected. --update-baseline to re-baseline.

Both scripts spawn the server against an isolated AQE_PROJECT_ROOT temp dir
(data protection: real .agentic-qe/*.rvf proven byte-identical before/after)
and kill the whole detached process group on exit (no leaked servers).

Verified: npm run mcp:validate -> PASS (protocolVersion 2025-11-25, 86 tools);
mcp:parity -> PASS (0 removed, 0 added). Both exit 0, real stores untouched.

Side-finding (pre-existing, not fixed here): handleToolsCall throws a plain
object for unknown tools, which escapes the request-handler safety net as
'Internal error: [object Object]' instead of a proper JSON-RPC -32601. Logged
for a dedicated fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:40:51 +00:00

291 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* MCP Protocol-Compliance Smoke Test
* -----------------------------------
* Ports ruflo's #1874 discipline: boot the REAL AQE MCP server (the same way
* Claude Code launches it — `npx tsx src/mcp/entry.ts` over STDIO with
* newline-delimited JSON-RPC) and validate the `initialize` + `tools/list`
* handshake against the MCP *spec*, not the source code.
*
* Core guard (ruflo #1874): `result.protocolVersion` MUST be a plain string
* matching /^\d{4}-\d{2}-\d{2}$/. Claude Code's Zod schema rejects an object
* here. We assert the string form explicitly so a regression (e.g. someone
* returning an object) fails loudly instead of silently breaking Claude Code.
*
* Transport notes:
* - src/mcp/transport/stdio.ts uses readline for input and writes `data + '\n'`
* (newline-delimited, NOT Content-Length framed).
* - entry.ts suppresses most stderr in MCP mode, but boot/daemon code may emit
* a few stdout lines. The client below tolerates any non-JSON / non-matching
* line and scans for the JSON-RPC response carrying the expected `id`.
*
* Exit codes: 0 = all assertions pass, 1 = any failure or timeout.
*
* Usage: node scripts/smoke-mcp-protocol.mjs
*/
import { spawn } from 'node:child_process';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, '..');
// Data-dir isolation (CLAUDE.md data-protection): run the server against a
// throwaway AQE_PROJECT_ROOT so the smoke never mutates the real
// .agentic-qe stores and never contends for the session daemons' memory.db lock
// (lock contention previously made the smoke time out).
const TMP_ROOT = mkdtempSync(join(tmpdir(), 'aqe-mcp-smoke-'));
// Prefer the local tsx binary over `npx tsx` — fewer wrapper processes (so the
// whole tree is reliably killed) and no dist-tag re-resolution latency.
const TSX_BIN = join(REPO_ROOT, 'node_modules', '.bin', 'tsx');
const SERVER_CMD = existsSync(TSX_BIN) ? TSX_BIN : 'npx';
const SERVER_ARGS = existsSync(TSX_BIN) ? ['src/mcp/entry.ts'] : ['tsx', 'src/mcp/entry.ts'];
// Generous boot budget — the real server initializes daemons, HNSW, fleet, etc.
const BOOT_TIMEOUT_MS = 90_000;
// Per-request response budget once the server is up.
const REQUEST_TIMEOUT_MS = 45_000;
let child = null;
let cleanedUp = false;
function killTree(signal) {
if (!child || child.pid == null) return;
// The server is spawned `detached`, so it leads its own process group.
// Killing the negative pid reaps the whole tree (tsx + any grandchildren),
// preventing the leaked-server problem of a bare child.kill().
try { process.kill(-child.pid, signal); } catch { /* ignore */ }
try { if (!child.killed) child.kill(signal); } catch { /* ignore */ }
}
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
if (child && !child.killed) {
killTree('SIGTERM');
// Hard-kill shortly after if SIGTERM is ignored.
setTimeout(() => killTree('SIGKILL'), 2000).unref?.();
}
try { if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true }); } catch { /* ignore */ }
}
function fail(message, detail) {
console.error('\n[FAIL] ' + message);
if (detail !== undefined) {
console.error(typeof detail === 'string' ? detail : JSON.stringify(detail, null, 2));
}
cleanup();
process.exitCode = 1;
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(1); });
process.on('SIGTERM', () => { cleanup(); process.exit(1); });
/**
* Spawn the real MCP server and return a small client object that can send a
* JSON-RPC request and resolve with the response carrying the matching id.
*/
function startServer() {
child = spawn(SERVER_CMD, SERVER_ARGS, {
cwd: REPO_ROOT,
stdio: ['pipe', 'pipe', 'pipe'],
detached: true, // own process group so cleanup can kill the whole tree
env: {
...process.env,
AQE_HTTP_PORT: '0',
AQE_PROJECT_ROOT: TMP_ROOT, // isolated throwaway data dir
},
});
child.on('error', (err) => {
fail(`Failed to spawn MCP server (${SERVER_CMD} ${SERVER_ARGS.join(' ')}): ` + err.message);
process.exit(1);
});
// Drain stderr so the child never blocks on a full pipe; surface fatal lines.
const errRl = readline.createInterface({ input: child.stderr });
errRl.on('line', (line) => {
if (/FATAL|Fatal error/i.test(line)) {
console.error('[server stderr] ' + line);
}
});
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
// Map of pending id -> { resolve, timer }. Lines are scanned as they arrive;
// any line that doesn't JSON-parse to a matching response is treated as noise.
const pending = new Map();
rl.on('line', (line) => {
const trimmed = line.trim();
if (!trimmed || trimmed[0] !== '{') return; // noise / boot log
let msg;
try {
msg = JSON.parse(trimmed);
} catch {
return; // non-JSON noise line — tolerate
}
if (msg == null || typeof msg !== 'object') return;
if (msg.id === undefined || msg.id === null) return; // notification, ignore
const waiter = pending.get(msg.id);
if (waiter) {
clearTimeout(waiter.timer);
pending.delete(msg.id);
waiter.resolve(msg);
}
});
child.on('exit', (code, signal) => {
if (cleanedUp) return;
// Unexpected early exit — reject all pending requests.
for (const [, waiter] of pending) {
clearTimeout(waiter.timer);
waiter.reject(new Error(`Server exited early (code=${code}, signal=${signal})`));
}
pending.clear();
});
function request(payload, timeoutMs) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
pending.delete(payload.id);
rejectPromise(new Error(`Timed out after ${timeoutMs}ms waiting for response id=${payload.id} (method=${payload.method})`));
}, timeoutMs);
pending.set(payload.id, { resolve: resolvePromise, reject: rejectPromise, timer });
child.stdin.write(JSON.stringify(payload) + '\n');
});
}
return { request };
}
function assert(cond, message, detail) {
if (!cond) {
throw new SmokeAssertionError(message, detail);
}
}
class SmokeAssertionError extends Error {
constructor(message, detail) {
super(message);
this.name = 'SmokeAssertionError';
this.detail = detail;
}
}
async function main() {
console.error(`[smoke] Booting real MCP server: ${SERVER_CMD} ${SERVER_ARGS.join(' ')} (AQE_HTTP_PORT=0, AQE_PROJECT_ROOT=${TMP_ROOT})`);
console.error(`[smoke] Boot budget ${BOOT_TIMEOUT_MS}ms, per-request ${REQUEST_TIMEOUT_MS}ms`);
const server = startServer();
// -- 1. initialize handshake -------------------------------------------------
const initReq = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-11-25',
capabilities: {},
clientInfo: { name: 'aqe-smoke', version: '1.0.0' },
},
};
console.error('[smoke] -> initialize (id=1)');
const initResp = await server.request(initReq, BOOT_TIMEOUT_MS);
assert(initResp.error === undefined, 'initialize returned a JSON-RPC error', initResp.error);
const result = initResp.result;
assert(result && typeof result === 'object', 'initialize result is not an object', result);
// CORE GUARD (ruflo #1874): protocolVersion MUST be a YYYY-MM-DD *string*.
// An object here is exactly the ruflo bug — Claude Code's Zod rejects it.
assert(
typeof result.protocolVersion === 'string',
`initialize result.protocolVersion must be a STRING (Claude Code Zod would reject a non-string). Got type=${typeof result.protocolVersion}`,
result.protocolVersion,
);
assert(
/^\d{4}-\d{2}-\d{2}$/.test(result.protocolVersion),
`initialize result.protocolVersion must match /^\\d{4}-\\d{2}-\\d{2}$/ (Claude Code Zod would accept this). Got: "${result.protocolVersion}"`,
);
assert(
result.capabilities && typeof result.capabilities === 'object' && !Array.isArray(result.capabilities),
'initialize result.capabilities must be an object',
result.capabilities,
);
assert(result.serverInfo && typeof result.serverInfo === 'object', 'initialize result.serverInfo must be an object', result.serverInfo);
assert(
typeof result.serverInfo.name === 'string' && result.serverInfo.name.length > 0,
'initialize result.serverInfo.name must be a non-empty string',
result.serverInfo,
);
assert(
typeof result.serverInfo.version === 'string' && result.serverInfo.version.length > 0,
'initialize result.serverInfo.version must be a non-empty string',
result.serverInfo,
);
console.error(`[smoke] OK initialize: protocolVersion="${result.protocolVersion}" serverInfo=${result.serverInfo.name} v${result.serverInfo.version}`);
// Per MCP lifecycle, send the `initialized` notification (no id, no response).
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }) + '\n');
// -- 2. tools/list -----------------------------------------------------------
console.error('[smoke] -> tools/list (id=2)');
const listResp = await server.request(
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
REQUEST_TIMEOUT_MS,
);
assert(listResp.error === undefined, 'tools/list returned a JSON-RPC error', listResp.error);
const listResult = listResp.result;
assert(listResult && typeof listResult === 'object', 'tools/list result is not an object', listResult);
assert(Array.isArray(listResult.tools), 'tools/list result.tools must be an array', listResult);
assert(listResult.tools.length > 0, 'tools/list result.tools must be a non-empty array');
for (const tool of listResult.tools) {
assert(
tool && typeof tool.name === 'string' && tool.name.length > 0,
'every tools/list entry must have a non-empty string name',
tool,
);
assert(
tool.inputSchema && typeof tool.inputSchema === 'object' && !Array.isArray(tool.inputSchema),
`tool "${tool && tool.name}" inputSchema must be an object`,
tool && tool.inputSchema,
);
}
const toolCount = listResult.tools.length;
console.error(`[smoke] OK tools/list: ${toolCount} tools, each with name + object inputSchema`);
// -- Summary -----------------------------------------------------------------
console.error('\n========================================');
console.error('[PASS] MCP protocol smoke succeeded');
console.error(` protocolVersion : ${result.protocolVersion}`);
console.error(` serverInfo : ${result.serverInfo.name} v${result.serverInfo.version}`);
console.error(` tools advertised: ${toolCount}`);
console.error('========================================');
cleanup();
process.exitCode = 0;
}
main().catch((err) => {
if (err instanceof SmokeAssertionError) {
fail(err.message, err.detail);
} else {
fail('Smoke test error: ' + (err && err.message ? err.message : String(err)));
}
});