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.
230 lines
5.7 KiB
JavaScript
230 lines
5.7 KiB
JavaScript
#!/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);
|
|
}
|
|
}
|