mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
b1a462fba2
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>
329 lines
14 KiB
JavaScript
329 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* MCP Tool-Advertisement / Handler Parity Audit
|
|
* ----------------------------------------------
|
|
* AQE-equivalent of ruflo's CLI<->MCP parity audit (scripts/audit-cli-mcp-tools.mjs).
|
|
*
|
|
* Ruflo's audit walked every `callMCPTool('name')` string reference in the CLI
|
|
* and asserted it resolved to a registered MCP tool (caught ~20 dangling refs).
|
|
*
|
|
* The AQE CLI does NOT use that `callMCPTool('name')` string pattern (verified:
|
|
* `grep -rn "callMCPTool" src/` returns nothing). So the meaningful parity
|
|
* surface here is *advertisement <-> handler parity* on the MCP server itself:
|
|
*
|
|
* - ADVERTISED = the set of tool names returned by `tools/list`.
|
|
* - HANDLED = the set of tools that route to a real handler on `tools/call`.
|
|
*
|
|
* Architecture note (src/mcp/protocol-server.ts):
|
|
* Every tool is registered as a single `ToolEntry { definition, handler }` in
|
|
* one `this.tools` Map. `tools/list` enumerates `definition.name`, and
|
|
* `tools/call` looks up the SAME map (throws METHOD_NOT_FOUND "Unknown tool"
|
|
* otherwise). So advertisement and handler are structurally coupled: an
|
|
* advertised tool ALWAYS has a handler, and a handler is ALWAYS advertised.
|
|
* The parity regression to guard against is therefore the *advertised tool set
|
|
* drifting* (a tool silently disappearing, or an unexpected new one appearing),
|
|
* plus proof that the handler-resolution path correctly rejects unknown tools.
|
|
*
|
|
* Why drive via the spawned server (not in-process registry construction):
|
|
* Constructing the registry in-process runs registerAllTools() which, via
|
|
* entry.ts boot wiring, spins up daemons and writes to .agentic-qe/* stores
|
|
* (memory.db, *.rvf). Per CLAUDE.md data-protection that risks the real
|
|
* learning stores. So we read the advertised set purely from `tools/list`
|
|
* (read-only on the wire) and do ONE bogus-tool `tools/call` probe to confirm
|
|
* the routing layer rejects unknown tools. We never call a real tool handler.
|
|
*
|
|
* Monotone baseline (verification/mcp-tool-parity-baseline.json):
|
|
* Records the current known-good advertised tool set. The audit FAILS only on
|
|
* NEW discrepancies vs the baseline (removed tools = regression; added tools =
|
|
* reported as drift to be acknowledged). An existing backlog never blocks; a
|
|
* future regression always does. Run with --update-baseline to re-baseline
|
|
* after an intentional change.
|
|
*
|
|
* Exit codes: 0 = no new discrepancies, 1 = new discrepancies (or boot failure).
|
|
*
|
|
* Usage:
|
|
* node scripts/audit-mcp-tool-parity.mjs # audit against baseline
|
|
* node scripts/audit-mcp-tool-parity.mjs --update-baseline
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import readline from 'node:readline';
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, resolve, join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const REPO_ROOT = resolve(__dirname, '..');
|
|
const BASELINE_PATH = resolve(REPO_ROOT, 'verification', 'mcp-tool-parity-baseline.json');
|
|
|
|
const BOOT_TIMEOUT_MS = 90_000;
|
|
const REQUEST_TIMEOUT_MS = 45_000;
|
|
|
|
const UPDATE_BASELINE = process.argv.includes('--update-baseline');
|
|
|
|
// Isolated throwaway data dir (CLAUDE.md data-protection): the server must not
|
|
// mutate the real .agentic-qe stores nor contend for the session daemons' lock.
|
|
const TMP_ROOT = mkdtempSync(join(tmpdir(), 'aqe-mcp-parity-'));
|
|
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'];
|
|
|
|
let child = null;
|
|
let cleanedUp = false;
|
|
|
|
function killTree(signal) {
|
|
if (!child || child.pid == null) return;
|
|
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');
|
|
setTimeout(() => killTree('SIGKILL'), 2000).unref?.();
|
|
}
|
|
try { if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
|
|
process.on('exit', cleanup);
|
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
|
process.on('SIGTERM', () => { cleanup(); process.exit(1); });
|
|
|
|
function startServer() {
|
|
child = spawn(SERVER_CMD, SERVER_ARGS, {
|
|
cwd: REPO_ROOT,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
detached: true,
|
|
env: { ...process.env, AQE_HTTP_PORT: '0', AQE_PROJECT_ROOT: TMP_ROOT },
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
console.error('[FAIL] Failed to spawn MCP server: ' + err.message);
|
|
cleanup();
|
|
process.exit(1);
|
|
});
|
|
|
|
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 });
|
|
const pending = new Map();
|
|
|
|
rl.on('line', (line) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed[0] !== '{') return;
|
|
let msg;
|
|
try { msg = JSON.parse(trimmed); } catch { return; }
|
|
if (msg == null || typeof msg !== 'object') return;
|
|
if (msg.id === undefined || msg.id === null) return;
|
|
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;
|
|
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 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 loadBaseline() {
|
|
if (!existsSync(BASELINE_PATH)) return null;
|
|
try {
|
|
return JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'));
|
|
} catch (err) {
|
|
console.error('[WARN] Could not parse baseline, treating as absent: ' + err.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeBaseline(toolNames) {
|
|
const dir = dirname(BASELINE_PATH);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
const payload = {
|
|
description:
|
|
'Monotone baseline of MCP advertised tool names (tools/list). The parity ' +
|
|
'audit fails on NEW discrepancies vs this set: removed tools = regression. ' +
|
|
'Regenerate with: node scripts/audit-mcp-tool-parity.mjs --update-baseline',
|
|
generatedAt: new Date().toISOString(),
|
|
toolCount: toolNames.length,
|
|
// Known gaps: in this architecture advertised tools are always handler-backed
|
|
// (single ToolEntry map), so there are no advertised-but-unhandled gaps.
|
|
knownGaps: [],
|
|
advertisedTools: [...toolNames].sort(),
|
|
};
|
|
writeFileSync(BASELINE_PATH, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
}
|
|
|
|
async function main() {
|
|
console.error(`[parity] Booting real MCP server: ${SERVER_CMD} ${SERVER_ARGS.join(" ")} (AQE_HTTP_PORT=0, AQE_PROJECT_ROOT=${TMP_ROOT})`);
|
|
const server = startServer();
|
|
|
|
// 1. initialize (required before tools/list per MCP lifecycle).
|
|
const initResp = await server.request(
|
|
{
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'aqe-parity', version: '1.0.0' } },
|
|
},
|
|
BOOT_TIMEOUT_MS,
|
|
);
|
|
if (initResp.error) {
|
|
console.error('[FAIL] initialize errored: ' + JSON.stringify(initResp.error));
|
|
cleanup();
|
|
process.exit(1);
|
|
}
|
|
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }) + '\n');
|
|
|
|
// 2. tools/list -> advertised set (this is also the handler set; same map).
|
|
const listResp = await server.request(
|
|
{ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
|
|
REQUEST_TIMEOUT_MS,
|
|
);
|
|
if (listResp.error || !listResp.result || !Array.isArray(listResp.result.tools)) {
|
|
console.error('[FAIL] tools/list did not return a tools array: ' + JSON.stringify(listResp.error || listResp.result));
|
|
cleanup();
|
|
process.exit(1);
|
|
}
|
|
const advertised = listResp.result.tools.map((t) => t.name).filter((n) => typeof n === 'string');
|
|
const advertisedSet = new Set(advertised);
|
|
console.error(`[parity] Advertised tools (tools/list): ${advertisedSet.size}`);
|
|
|
|
// 3. Handler-resolution probe: a BOGUS tool MUST be rejected (not executed as
|
|
// a normal tool). This proves the tools/call routing path is wired to the
|
|
// same registry that backs tools/list — without invoking any real handler.
|
|
//
|
|
// Rejection can surface two ways in this server:
|
|
// (a) a JSON-RPC error (code -32601 / "Unknown tool"), or
|
|
// (b) a result whose content text decodes to { success:false, error: ... }.
|
|
// handleToolsCall throws a plain {code,message} object for unknown tools;
|
|
// the request-handler safety net (protocol-server.ts ~L291) catches it and
|
|
// wraps it as content `{"success":false,"error":"Internal error: ..."}`.
|
|
// Either shape proves the unknown tool was rejected, not run.
|
|
const bogusName = '__aqe_parity_nonexistent_tool__';
|
|
const probeResp = await server.request(
|
|
{ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: bogusName, arguments: {} } },
|
|
REQUEST_TIMEOUT_MS,
|
|
);
|
|
|
|
let rejectedUnknown = false;
|
|
let how = '';
|
|
if (probeResp.error !== undefined) {
|
|
rejectedUnknown = probeResp.error.code === -32601 || /unknown tool/i.test(probeResp.error.message || '');
|
|
how = `JSON-RPC error code=${probeResp.error.code}`;
|
|
} else if (probeResp.result && Array.isArray(probeResp.result.content)) {
|
|
if (probeResp.result.isError === true) {
|
|
rejectedUnknown = true;
|
|
how = 'result.isError=true';
|
|
} else {
|
|
// Inspect the wrapped content text for an explicit failure/rejection.
|
|
const text = probeResp.result.content.map((c) => (c && c.text) || '').join('\n');
|
|
let decoded = null;
|
|
try { decoded = JSON.parse(text); } catch { /* not JSON */ }
|
|
if (decoded && decoded.success === false) {
|
|
rejectedUnknown = true;
|
|
how = `content success:false ("${String(decoded.error).slice(0, 80)}")`;
|
|
} else if (/unknown tool|internal error|not found/i.test(text)) {
|
|
rejectedUnknown = true;
|
|
how = 'content text indicates rejection';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!rejectedUnknown) {
|
|
console.error('[FAIL] tools/call did NOT reject an unknown tool — handler routing is broken.');
|
|
console.error(' Response: ' + JSON.stringify(probeResp.error || probeResp.result));
|
|
cleanup();
|
|
process.exit(1);
|
|
}
|
|
console.error(`[parity] Handler routing probe OK: unknown tool "${bogusName}" correctly rejected (${how}).`);
|
|
|
|
cleanup();
|
|
|
|
// 4. Baseline diff.
|
|
if (UPDATE_BASELINE) {
|
|
writeBaseline(advertised);
|
|
console.error(`\n[OK] Baseline written: ${BASELINE_PATH} (${advertisedSet.size} tools)`);
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const baseline = loadBaseline();
|
|
if (!baseline) {
|
|
// No baseline yet: establish it. Zero discrepancies by definition.
|
|
writeBaseline(advertised);
|
|
console.error('\n========================================');
|
|
console.error('[OK] No baseline existed — created one (monotone seed).');
|
|
console.error(` Advertised tools : ${advertisedSet.size}`);
|
|
console.error(` Baseline : ${BASELINE_PATH}`);
|
|
console.error(' Future runs fail on any NEW discrepancy.');
|
|
console.error('========================================');
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const baselineSet = new Set(baseline.advertisedTools || []);
|
|
const removed = [...baselineSet].filter((n) => !advertisedSet.has(n)).sort();
|
|
const added = [...advertisedSet].filter((n) => !baselineSet.has(n)).sort();
|
|
|
|
console.error('\n========================================');
|
|
console.error('[parity] Advertisement <-> handler parity audit');
|
|
console.error(` Baseline tools : ${baselineSet.size}`);
|
|
console.error(` Advertised now : ${advertisedSet.size}`);
|
|
console.error(` Removed (NEW gap): ${removed.length}`);
|
|
console.error(` Added (drift) : ${added.length}`);
|
|
|
|
if (added.length > 0) {
|
|
console.error('\n + New tools (advertised, handler-backed) since baseline:');
|
|
for (const n of added) console.error(' + ' + n);
|
|
console.error(' These are not failures (every advertised tool has a handler),');
|
|
console.error(' but re-baseline to acknowledge: --update-baseline');
|
|
}
|
|
|
|
if (removed.length > 0) {
|
|
console.error('\n - REGRESSION: tools in baseline no longer advertised/handled:');
|
|
for (const n of removed) console.error(' - ' + n);
|
|
console.error('========================================');
|
|
console.error('\n[FAIL] Parity regression: ' + removed.length + ' tool(s) disappeared from tools/list.');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.error('========================================');
|
|
console.error('\n[PASS] No parity regressions. ' + (added.length ? `(${added.length} new tool(s) — consider re-baselining.)` : 'Advertised set matches baseline.'));
|
|
process.exitCode = 0;
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('\n[FAIL] Parity audit error: ' + (err && err.message ? err.message : String(err)));
|
|
cleanup();
|
|
process.exitCode = 1;
|
|
});
|