149 lines
6.5 KiB
JavaScript
149 lines
6.5 KiB
JavaScript
// SPDX-License-Identifier: MIT
|
|
// MCP stdio server e2e — spawns `bin/cli.js mcp start` and speaks JSON-RPC.
|
|
// Pins ADR-263 O2 (ping answered while a long tools/call runs), O6 (version
|
|
// from package.json), and O8 (underscore names advertised, dotted accepted,
|
|
// resources/prompts stubs).
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { which } from '../src/tools.js';
|
|
|
|
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
const CLI = join(PKG_ROOT, 'bin', 'cli.js');
|
|
|
|
/** Start the MCP server; returns {send, next, close} where next(id) resolves the response with that id. */
|
|
function startServer() {
|
|
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
const waiters = new Map();
|
|
let buf = '';
|
|
child.stdout.on('data', (d) => {
|
|
buf += d;
|
|
let nl;
|
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
const line = buf.slice(0, nl).trim();
|
|
buf = buf.slice(nl + 1);
|
|
if (!line) continue;
|
|
const msg = JSON.parse(line);
|
|
const w = waiters.get(msg.id);
|
|
if (w) { waiters.delete(msg.id); w(msg); }
|
|
}
|
|
});
|
|
return {
|
|
send(msg) { child.stdin.write(JSON.stringify(msg) + '\n'); },
|
|
next(id) { return new Promise((res) => waiters.set(id, res)); },
|
|
close() { child.stdin.end(); child.kill(); },
|
|
};
|
|
}
|
|
|
|
test('MCP handshake: initialize reports the package.json version; list endpoints respond', async () => {
|
|
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
const s = startServer();
|
|
try {
|
|
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
const init = await s.next(1);
|
|
assert.equal(init.result.serverInfo.version, pkg.version, 'ADR-263 O6: version must match package.json');
|
|
|
|
s.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
|
const tools = (await s.next(2)).result.tools;
|
|
assert.equal(tools.length, 6);
|
|
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/, `advertised name not host-safe: ${t.name}`);
|
|
|
|
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
|
|
assert.deepEqual((await s.next(3)).result, { resources: [] });
|
|
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
|
|
assert.deepEqual((await s.next(4)).result, { prompts: [] });
|
|
|
|
// Dotted legacy name still callable (alias).
|
|
s.send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'ruview.onboard', arguments: {} } });
|
|
const call = await s.next(5);
|
|
assert.equal(call.result.isError, false);
|
|
} finally {
|
|
s.close();
|
|
}
|
|
});
|
|
|
|
test('MCP server answers ping while a long tools/call is in flight (ADR-263 O2)', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
|
|
// Fake RuView repo whose verify.py sleeps 3 s then passes.
|
|
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-e2e-'));
|
|
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
|
|
mkdirSync(proofDir, { recursive: true });
|
|
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(3)\nprint("VERDICT: PASS")\n');
|
|
|
|
const s = startServer();
|
|
try {
|
|
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
await s.next(1);
|
|
|
|
const verifyDone = s.next(10);
|
|
s.send({ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
|
|
|
// Give the server a beat to start the child, then ping.
|
|
await new Promise((r) => setTimeout(r, 300));
|
|
const t0 = Date.now();
|
|
const pinged = s.next(11);
|
|
s.send({ jsonrpc: '2.0', id: 11, method: 'ping' });
|
|
await pinged;
|
|
const pingMs = Date.now() - t0;
|
|
assert.ok(pingMs < 1000, `ping took ${pingMs} ms while verify was in flight — server is blocking`);
|
|
|
|
const verify = await verifyDone;
|
|
const payload = JSON.parse(verify.result.content[0].text);
|
|
assert.equal(payload.verdict, 'PASS');
|
|
} finally {
|
|
s.close();
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('tools/call executions are serialized — two slow calls run sequentially', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
|
|
// Two verify.py that each sleep 0.8 s. Serialized ⇒ ~1.6 s+; concurrent ⇒ ~0.8 s.
|
|
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-serial-'));
|
|
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
|
|
mkdirSync(proofDir, { recursive: true });
|
|
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(0.8)\nprint("VERDICT: PASS")\n');
|
|
|
|
const s = startServer();
|
|
try {
|
|
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
|
await s.next(1);
|
|
|
|
const t0 = Date.now();
|
|
const a = s.next(20);
|
|
const b = s.next(21);
|
|
s.send({ jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
|
s.send({ jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
|
|
const [ra, rb] = await Promise.all([a, b]);
|
|
const elapsed = Date.now() - t0;
|
|
|
|
assert.equal(JSON.parse(ra.result.content[0].text).verdict, 'PASS');
|
|
assert.equal(JSON.parse(rb.result.content[0].text).verdict, 'PASS');
|
|
assert.ok(elapsed > 1400, `two 0.8 s tool calls finished in ${elapsed} ms — they overlapped instead of serializing`);
|
|
} finally {
|
|
s.close();
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('stdin close flushes an in-flight tools/call response before exit', async () => {
|
|
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let out = '';
|
|
child.stdout.on('data', (d) => { out += d; });
|
|
const exited = new Promise((res) => child.on('exit', res));
|
|
|
|
// Write a tools/call then immediately close stdin. The old fire-and-forget
|
|
// dispatch raced rl 'close' → process.exit and could drop this response.
|
|
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'tools/call', params: { name: 'ruview_onboard', arguments: {} } }) + '\n');
|
|
child.stdin.end();
|
|
|
|
await exited;
|
|
const msgs = out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
const resp = msgs.find((m) => m.id === 42);
|
|
assert.ok(resp, 'the in-flight tools/call response must be flushed to stdout before exit');
|
|
assert.equal(resp.result.isError, false);
|
|
});
|