import { spawn } from 'node:child_process'; import { resolve } from 'node:path'; import { z } from 'zod'; import type { ToolDef } from '../types.js'; import type { CodecontextResponse } from '../../codecontext_client.js'; // ======================= MCP Client ======================= const BOOCONTEXT_PATH = resolve('/opt/forks/boocontext/dist/standalone.js'); const TOOL_CALL_TIMEOUT_MS = 60_000; interface JsonRpcMessage { jsonrpc: '2.0'; id?: number | string; result?: { content?: Array<{ type: string; text: string }>; }; error?: { code?: number; message: string }; } /** * Single-shot MCP JSON-RPC client for boocontext. * Spawns the process, sends initialize + tools/call over NDJSON, returns the * text result from the content array. The boocontext MCP server auto-detects * newline-delimited JSON transport when the first input lacks Content-Length * headers, which is exactly what we send. */ async function callBoocontext( toolName: string, args: Record, ): Promise { return new Promise((resolvePromise, reject) => { const child = spawn(process.execPath, [BOOCONTEXT_PATH], { stdio: ['pipe', 'pipe', 'pipe'], timeout: TOOL_CALL_TIMEOUT_MS, }); let stdout = ''; let stderr = ''; let resolved = false; function finalize(err?: Error, result?: string): void { if (resolved) return; resolved = true; if (err) reject(err); else resolvePromise(result!); child.kill(); } child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); child.on('error', (err: Error) => { finalize(new Error(`boocontext spawn error: ${err.message}`)); }); child.on('close', (code: number | null) => { if (resolved) return; // Parse newline-delimited JSON responses from stdout const lines = stdout.split('\n').filter((l) => l.trim().length > 0); let toolText: string | undefined; let toolError: string | undefined; for (const line of lines) { try { const msg = JSON.parse(line) as JsonRpcMessage; if (msg.id === 2) { if (msg.error) { toolError = msg.error.message ?? 'boocontext tool call failed'; } else if (msg.result?.content?.[0]?.text !== undefined) { toolText = msg.result.content[0].text; } } } catch { // skip malformed JSON lines } } if (toolError) { finalize(new Error(toolError)); } else if (toolText !== undefined) { finalize(undefined, toolText); } else { const errSuffix = stderr.length > 0 ? ` stderr: ${stderr.slice(0, 500)}` : ''; finalize( new Error(`boocontext MCP call failed (exit ${code})${errSuffix}`), ); } }); // Step 1: initialize — establishes MCP protocol version + capabilities child.stdin!.write( JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'boocode-server', version: '1.0.0' }, }, }) + '\n', ); // Step 2: tools/call — invoke the named boocontext tool child.stdin!.write( JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: toolName, arguments: args }, }) + '\n', ); child.stdin!.end(); // Safety timeout — prevent hung processes setTimeout(() => { finalize( new Error( `boocontext call timed out after ${TOOL_CALL_TIMEOUT_MS}ms`, ), ); }, TOOL_CALL_TIMEOUT_MS); }); } // ======================= Tool Definition ======================= const TRUNCATION_LIMIT = 32_000; export const GetCodeImpactInput = z.object({ symbol: z.string().min(1).describe('Symbol name for TSA trace_impact'), file: z.string().optional().describe('File path for codesight blast_radius'), directory: z .string() .optional() .describe('Directory (defaults to project root)'), depth: z .number() .int() .min(1) .max(5) .optional() .describe('Max blast-radius traversal depth (default 1)'), }); export type GetCodeImpactInputT = z.infer; const DESCRIPTION = 'Impact analysis. Merges symbol-level call trace with file-level blast radius. ' + 'Use before making changes to understand change propagation. ' + 'Single call replaces separate get_symbol_info + get_blast_radius steps.'; /** * Standalone execute function — calls the boocontext MCP `boocontext_impact` * tool via a short-lived child process, then wraps the result in the standard * CodecontextResponse shape with inline truncation at 32 KB. */ export async function executeGetCodeImpact( input: GetCodeImpactInputT, projectPath: string, ): Promise { const args: Record = { symbol: input.symbol, directory: input.directory ?? projectPath, }; if (input.file) args['file'] = input.file; const text = await callBoocontext('boocontext_impact', args); // Inline truncation matching codecontext_client.ts patterns (32 KB ceiling). if (text.length > TRUNCATION_LIMIT) { const sliced = text.slice(0, TRUNCATION_LIMIT); const omitted = text.length - TRUNCATION_LIMIT; return { result: `${sliced}\n\n[truncated, ${omitted} chars omitted; narrow with symbol or file parameters]`, truncated: true, }; } return { result: text, truncated: false }; } export const getCodeImpact: ToolDef = { name: 'get_code_impact', description: DESCRIPTION, inputSchema: GetCodeImpactInput, jsonSchema: { type: 'function', function: { name: 'get_code_impact', description: DESCRIPTION, parameters: { type: 'object', properties: { symbol: { type: 'string', description: 'Symbol name for TSA trace_impact', }, file: { type: 'string', description: 'File path for codesight blast_radius', }, directory: { type: 'string', description: 'Directory (defaults to project root)', }, depth: { type: 'number', description: 'Max blast-radius traversal depth (default 1)', }, }, required: ['symbol'], additionalProperties: false, }, }, }, execute(input, projectRoot) { return executeGetCodeImpact(input, projectRoot); }, };