Initial release: MCP server enforcing Worker-Reviewer loop
Diligence prevents AI agents from shipping quick fixes that break things by enforcing a research-propose-verify loop before any code changes. Key features: - Worker sub-agent researches and proposes with file:line citations - Reviewer sub-agent independently verifies claims by searching codebase - Iterates until approved (max 5 rounds) - Loads project-specific context from .claude/CODEBASE_CONTEXT.md - State persisted across sessions Validated on production codebase: caught architectural mistake (broker subscriptions on client-side code) that naive agent would have shipped.
This commit is contained in:
229
test/mcp-client.mjs
Normal file
229
test/mcp-client.mjs
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MCP Test Client
|
||||
*
|
||||
* Programmatically tests the diligence MCP server by:
|
||||
* 1. Spawning the server as a child process
|
||||
* 2. Sending JSON-RPC messages via stdio
|
||||
* 3. Receiving and parsing responses
|
||||
*
|
||||
* Usage:
|
||||
* const client = new McpClient();
|
||||
* await client.connect();
|
||||
* const result = await client.callTool('status', {});
|
||||
* await client.disconnect();
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { createInterface } from 'readline';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export class McpClient {
|
||||
constructor(serverPath = join(__dirname, '..', 'index.mjs')) {
|
||||
this.serverPath = serverPath;
|
||||
this.process = null;
|
||||
this.requestId = 0;
|
||||
this.pendingRequests = new Map();
|
||||
this.readline = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.process = spawn('node', [this.serverPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: join(__dirname, 'fixture'), // Run in fixture directory
|
||||
});
|
||||
|
||||
this.readline = createInterface({
|
||||
input: this.process.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
this.readline.on('line', (line) => {
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.id !== undefined && this.pendingRequests.has(message.id)) {
|
||||
const { resolve, reject } = this.pendingRequests.get(message.id);
|
||||
this.pendingRequests.delete(message.id);
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message || JSON.stringify(message.error)));
|
||||
} else {
|
||||
resolve(message.result);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore non-JSON lines
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr.on('data', (data) => {
|
||||
// Server logs to stderr
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[server]', data.toString());
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('error', reject);
|
||||
this.process.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`Server exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the MCP connection
|
||||
this._send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.requestId++,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '0.1.0',
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' },
|
||||
capabilities: {},
|
||||
},
|
||||
}).then(() => {
|
||||
// Send initialized notification
|
||||
this._sendNotification('notifications/initialized', {});
|
||||
resolve();
|
||||
}).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.process) {
|
||||
this.process.kill('SIGTERM');
|
||||
this.process = null;
|
||||
}
|
||||
if (this.readline) {
|
||||
this.readline.close();
|
||||
this.readline = null;
|
||||
}
|
||||
}
|
||||
|
||||
_send(message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.process) {
|
||||
reject(new Error('Not connected'));
|
||||
return;
|
||||
}
|
||||
this.pendingRequests.set(message.id, { resolve, reject });
|
||||
this.process.stdin.write(JSON.stringify(message) + '\n');
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (this.pendingRequests.has(message.id)) {
|
||||
this.pendingRequests.delete(message.id);
|
||||
reject(new Error('Request timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
_sendNotification(method, params) {
|
||||
if (!this.process) return;
|
||||
this.process.stdin.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params,
|
||||
}) + '\n');
|
||||
}
|
||||
|
||||
async listTools() {
|
||||
const result = await this._send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.requestId++,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
});
|
||||
return result.tools;
|
||||
}
|
||||
|
||||
async callTool(name, args = {}) {
|
||||
const result = await this._send({
|
||||
jsonrpc: '2.0',
|
||||
id: this.requestId++,
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
});
|
||||
// Extract text from content array
|
||||
if (result.content && result.content[0] && result.content[0].text) {
|
||||
return {
|
||||
text: result.content[0].text,
|
||||
isError: result.isError || false,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Convenience methods for common workflows
|
||||
async status() {
|
||||
return this.callTool('status');
|
||||
}
|
||||
|
||||
async start(task) {
|
||||
return this.callTool('start', { task });
|
||||
}
|
||||
|
||||
async propose(proposal) {
|
||||
return this.callTool('propose', { proposal });
|
||||
}
|
||||
|
||||
async review(decision, reasoning) {
|
||||
return this.callTool('review', { decision, reasoning });
|
||||
}
|
||||
|
||||
async getWorkerBrief() {
|
||||
return this.callTool('get_worker_brief');
|
||||
}
|
||||
|
||||
async getReviewerBrief() {
|
||||
return this.callTool('get_reviewer_brief');
|
||||
}
|
||||
|
||||
async implement() {
|
||||
return this.callTool('implement');
|
||||
}
|
||||
|
||||
async complete(summary) {
|
||||
return this.callTool('complete', { summary });
|
||||
}
|
||||
|
||||
async abort(reason) {
|
||||
return this.callTool('abort', { reason });
|
||||
}
|
||||
|
||||
async approve(reason) {
|
||||
return this.callTool('approve', { reason });
|
||||
}
|
||||
}
|
||||
|
||||
// CLI usage for quick testing
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const client = new McpClient();
|
||||
|
||||
try {
|
||||
console.log('Connecting to MCP server...');
|
||||
await client.connect();
|
||||
console.log('Connected!\n');
|
||||
|
||||
// List tools
|
||||
const tools = await client.listTools();
|
||||
console.log('Available tools:');
|
||||
tools.forEach(t => console.log(` - ${t.name}: ${t.description.slice(0, 60)}...`));
|
||||
console.log();
|
||||
|
||||
// Check status
|
||||
const status = await client.status();
|
||||
console.log('Status:');
|
||||
console.log(status.text);
|
||||
|
||||
await client.disconnect();
|
||||
console.log('\nDisconnected.');
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
await client.disconnect();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user