Shared boocontext MCP client (boocontext_client.ts) wrapping the existing mcp-client.ts callTool() infrastructure with 32KB truncation and error handling. Used by get_code_health. 4 new first-class agent tools backed by the boocontext MCP server: - get_code_health — A-F grades per file across 7 dimensions, project health summary, refactoring candidates (wraps boocontext_health) - get_code_impact — merged symbol trace + blast radius in one call (wraps boocontext_impact, replaces two-step get_symbol_info+get_blast_radius) - get_type_info — TypeScript type recovery via type-inject MCP (wraps boocontext_types, returns signatures, interfaces, generics, JSDoc) - get_code_map — DCP-compressed context map with compress toggle (wraps boocontext_map, 10x token reduction vs full scan) All 4 registered in ALL_TOOLS as read-only tools.
229 lines
6.6 KiB
TypeScript
229 lines
6.6 KiB
TypeScript
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<string, unknown>,
|
|
): Promise<string> {
|
|
return new Promise<string>((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<typeof GetCodeImpactInput>;
|
|
|
|
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<CodecontextResponse> {
|
|
const args: Record<string, unknown> = {
|
|
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<GetCodeImpactInputT> = {
|
|
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);
|
|
},
|
|
};
|