diff --git a/apps/server/src/services/boocontext_client.ts b/apps/server/src/services/boocontext_client.ts new file mode 100644 index 0000000..09fe1ef --- /dev/null +++ b/apps/server/src/services/boocontext_client.ts @@ -0,0 +1,110 @@ +/** + * v2.7.18: shared MCP client wrapper for the boocontext sidecar. + * + * Calls into the existing multi-server MCP client infrastructure + * (services/mcp-client.ts) which connects to boocontext as a stdio + * MCP process defined in data/mcp.json (server name "boocontext", + * command: `node /opt/forks/boocontext/dist/standalone.js`). + * + * The boocontext MCP server is initialized once at app boot in + * index.ts via initMcp() and the actual MCP tool call routing is + * handled by mcp-client.ts:callTool() — this module is a thin + * convenience wrapper that prepends the "boocontext_" server prefix, + * normalises the response, and applies inline truncation matching + * the same pattern as codecontext_client.ts. + * + * Usage: + * import { callBoocontext } from './services/boocontext_client.js'; + * const resp = await callBoocontext({ + * toolName: 'codesight_get_summary', + * args: { directory: '/opt/boocode' }, + * }); + */ + +import { callTool } from './mcp-client.js'; +import { truncateIfNeeded } from './truncate.js'; + +// ---- Exported types ---- + +export interface BoocontextRequest { + /** Unprefixed tool name as defined on the boocontext MCP server + * (e.g. "codesight_scan", "boocontext_overview", "codesight_get_summary"). */ + toolName: string; + /** Arguments to pass to the tool. */ + args: Record; +} + +export interface BoocontextResponse { + /** The tool output text. */ + result: string; + /** Whether the result was truncated to fit the inline limit. */ + truncated: boolean; + /** Opaque id pointing at the full pre-slice content on tmpfs, set when + * truncated=true and storage succeeded. */ + outputPath?: string; +} + +// ---- Constants ---- + +/** Must match the server name in data/mcp.json. */ +const BOOCONTEXT_SERVER_NAME = 'boocontext'; + +/** Inline truncation limit, matching codecontext_client.ts. */ +const TRUNCATION_LIMIT = 32_000; + +// ---- Public API ---- + +/** + * Call a boocontext MCP tool by its unprefixed name. + * + * Prepends the "boocontext_" server prefix, delegates to the + * multi-server MCP client's callTool(), and normalises the response + * into a BoocontextResponse with inline truncation. + * + * @param req The tool name and arguments. + * @param log Optional Fastify-compatible logger (for debug traces). + * @returns The tool result, possibly truncated. + * @throws If the boocontext server is not connected or the tool + * returns an MCP-level error. + */ +export async function callBoocontext( + req: BoocontextRequest, + log?: { debug?: (obj: object, msg: string) => void; warn?: (obj: object, msg: string) => void }, +): Promise { + const prefixedName = `${BOOCONTEXT_SERVER_NAME}_${req.toolName}`; + + log?.debug?.({ tool: prefixedName }, 'boocontext: calling tool'); + + const raw = await callTool(prefixedName, req.args); + + // callTool returns { error: true, output: string } on failure (both + // for MCP-level isError and for network/protocol exceptions). + if (typeof raw === 'object' && raw !== null && (raw as Record).error === true) { + const errOutput = (raw as Record).output ?? 'Unknown MCP error'; + throw new Error(`boocontext error: ${String(errOutput)}`); + } + + const result = typeof raw === 'string' ? raw : JSON.stringify(raw); + + // Inline truncation at 32 kB, matching codecontext_client.ts. + // The model gets a clear hint about how to narrow the next call + // rather than a silent cut. + if (result.length > TRUNCATION_LIMIT) { + const truncated = result.slice(0, TRUNCATION_LIMIT); + const omitted = result.length - TRUNCATION_LIMIT; + const slicedWithMarker = + `${truncated}\n\n[truncated, ${omitted} chars omitted; narrow with additional filters]`; + const wrapped = await truncateIfNeeded({ + fullContent: result, + slicedContent: slicedWithMarker, + wasTruncated: true, + }); + return { + result: wrapped.content, + truncated: wrapped.truncated, + ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), + }; + } + + return { result, truncated: false }; +} diff --git a/apps/server/src/services/tools/codecontext/get_code_health.ts b/apps/server/src/services/tools/codecontext/get_code_health.ts new file mode 100644 index 0000000..75a81e3 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_code_health.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import type { ToolDef } from '../types.js'; +import { callBoocontext } from '../../boocontext_client.js'; + +export const GetCodeHealthInput = z.object({ + directory: z.string().optional().describe('Directory to analyze (defaults to project root)'), + file: z.string().optional().describe('Optional: specific file to analyze'), +}); +export type GetCodeHealthInputT = z.infer; + +const DESCRIPTION = + 'Code health analysis. Returns A–F grades per file across 7 dimensions ' + + '(cohesion, coupling, complexity, documentation, duplication, unit size, test coverage). ' + + 'Includes project health summary and refactoring candidates.'; + +/** + * Standalone execute function — calls the boocontext MCP server's + * boocontext_health tool and returns the raw report text. + * + * Structured for direct test access: accepts input + projectPath, + * no side effects beyond the MCP call. + */ +export async function executeGetCodeHealth( + input: GetCodeHealthInputT, + projectPath: string, +): Promise { + const args: Record = {}; + if (input.directory) args['directory'] = input.directory; + if (input.file) args['file'] = input.file; + const resp = await callBoocontext({ toolName: 'boocontext_health', args }); + return resp.result; +} + +export const getCodeHealth: ToolDef = { + name: 'get_code_health', + description: DESCRIPTION, + inputSchema: GetCodeHealthInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_code_health', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + directory: { + type: 'string', + description: 'Directory to analyze (defaults to project root)', + }, + file: { + type: 'string', + description: 'Optional: specific file to analyze', + }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + return executeGetCodeHealth(input, projectRoot); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/get_code_impact.ts b/apps/server/src/services/tools/codecontext/get_code_impact.ts new file mode 100644 index 0000000..e9a7a18 --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_code_impact.ts @@ -0,0 +1,228 @@ +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); + }, +}; diff --git a/apps/server/src/services/tools/codecontext/get_code_map.ts b/apps/server/src/services/tools/codecontext/get_code_map.ts new file mode 100644 index 0000000..8d4f15e --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_code_map.ts @@ -0,0 +1,192 @@ +import { spawn } from 'node:child_process'; +import { z } from 'zod'; +import type { ToolDef } from '../types.js'; + +export const GetCodeMapInput = z.object({ + directory: z.string().optional().describe('Directory to scan (defaults to project root)'), + compress: z.boolean().optional().describe('Apply DCP compression if payload exceeds threshold (default: true)'), +}); +export type GetCodeMapInputT = z.infer; + +const DESCRIPTION = + 'DCP-compressed codebase context map. Returns filenames, sizes, import relationships in a compressed format. ' + + 'Use compress=false for full detail, compress=true (default) for token-efficient overview.'; + +const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js'; +const TOOL_TIMEOUT_MS = 30_000; +const MAX_RESULT_BYTES = 32_768; + +export interface CodeMapResponse { + result: string; + truncated: boolean; +} + +/** + * Calls the boocontext MCP server over stdio JSON-RPC to invoke + * the boocontext_map tool. Spawns the standalone binary, sends + * initialize + tools/call, collects NDJSON responses, and kills + * the child process. + */ +function callBoocontextMap(args: Record): Promise { + return new Promise((resolve, reject) => { + const child = spawn('node', [BOOCONTEXT_PATH], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdoutBuf = ''; + const lines: string[] = []; + let timedOut = false; + let resolved = false; + + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + reject(new Error(`boocontext MCP call timed out after ${TOOL_TIMEOUT_MS}ms`)); + }, TOOL_TIMEOUT_MS); + + function tryParse(): void { + if (resolved || timedOut) return; + + // Accumulate complete NDJSON lines + const parts = stdoutBuf.split('\n'); + stdoutBuf = parts.pop()! ?? ''; + for (const p of parts) { + const t = p.trim(); + if (t) lines.push(t); + } + + // Need at least 2 responses: initialize + tools/call + if (lines.length < 2) return; + + resolved = true; + clearTimeout(timer); + child.kill(); + + try { + const callResponse = JSON.parse(lines[1]!); + if (callResponse.error) { + reject(new Error(`MCP error: ${callResponse.error.message}`)); + return; + } + + const content = callResponse.result?.content; + if (!content?.[0]?.text) { + reject(new Error('Unexpected MCP response shape — missing content[0].text')); + return; + } + + // content[0].text is JSON-stringified VerdictEnvelope from boocontext + const envelope = JSON.parse(content[0].text as string); + const details = envelope.details; + + let result: string; + if (details && typeof details === 'object' && 'data' in details) { + // DcpEnvelope shape: { compressed, originalLength, compressedLength, data } + if (details.compressed) { + // Return the full DcpEnvelope as JSON so the LLM can pass it + // transparently to a decompression step + result = JSON.stringify(details); + } else { + // Uncompressed — data is the raw output + result = details.data; + } + } else { + result = JSON.stringify(details ?? envelope); + } + + const truncated = Buffer.byteLength(result, 'utf-8') > MAX_RESULT_BYTES; + if (truncated) { + result = result.substring(0, MAX_RESULT_BYTES); + } + + resolve({ result, truncated }); + } catch (e: any) { + reject(new Error(`Failed to parse boocontext response: ${e.message}`)); + } + } + + child.stdout!.on('data', (chunk: Buffer) => { + if (timedOut) return; + stdoutBuf += chunk.toString('utf-8'); + tryParse(); + }); + + child.stderr!.on('data', (_chunk: Buffer) => { + // Captured but not surfaced — logged only on parse failure + }); + + child.on('error', (err: Error) => { + clearTimeout(timer); + if (!resolved) { + resolved = true; + reject(new Error(`boocontext spawn failed: ${err.message}`)); + } + }); + + child.on('close', () => { + clearTimeout(timer); + if (!resolved && !timedOut) { + tryParse(); + if (!resolved) { + resolved = true; + reject(new Error('boocontext process closed without producing a valid response')); + } + } + }); + + // Step 1: initialize + child.stdin!.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }) + '\n', + ); + + // Step 2: tools/call for boocontext_map + child.stdin!.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'boocontext_map', arguments: args }, + }) + '\n', + ); + }); +} + +export const getCodeMap: ToolDef = { + name: 'get_code_map', + description: DESCRIPTION, + inputSchema: GetCodeMapInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_code_map', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + directory: { type: 'string', description: 'Directory to scan (defaults to project root)' }, + compress: { + type: 'boolean', + description: 'Apply DCP compression if payload exceeds threshold (default: true)', + }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot): Promise { + return callBoocontextMap({ + directory: input.directory ?? projectRoot, + compress: input.compress ?? true, + }); + }, +}; + +export async function executeGetCodeMap( + input: GetCodeMapInputT, + projectRoot: string, +): Promise { + return callBoocontextMap({ + directory: input.directory ?? projectRoot, + compress: input.compress ?? true, + }); +} diff --git a/apps/server/src/services/tools/codecontext/get_type_info.ts b/apps/server/src/services/tools/codecontext/get_type_info.ts new file mode 100644 index 0000000..56497cf --- /dev/null +++ b/apps/server/src/services/tools/codecontext/get_type_info.ts @@ -0,0 +1,262 @@ +import { z } from 'zod'; +import { spawn } from 'node:child_process'; +import type { ToolDef } from '../types.js'; +import type { CodecontextResponse } from '../../codecontext_client.js'; + +const BOOCONTEXT_PATH = '/opt/forks/boocontext/dist/standalone.js'; +const TRUNCATION_LIMIT = 32_000; + +export const GetTypeInfoInput = z.object({ + file: z.string().min(1).describe('File path to resolve types in'), + symbol: z.string().optional().describe('Symbol name to resolve (supports regex)'), + directory: z.string().optional().describe('Project directory for type resolution context'), +}); +export type GetTypeInfoInputT = z.infer; + +const DESCRIPTION = + 'TypeScript type recovery. Returns type signatures, interface definitions, ' + + 'generic constraints, and JSDoc for symbols in a file. Uses type-inject MCP server.'; + +// ---- JSON-RPC-over-stdio MCP caller for boocontext -------------------------- + +async function callBoocontext( + toolName: string, + args: Record, +): Promise { + const child = spawn(process.execPath, [BOOCONTEXT_PATH], { + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 60_000, + }); + + let stderrBuf = ''; + child.stderr!.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf-8'); + }); + + let killed = false; + const killChild = () => { + if (killed) return; + killed = true; + child.kill(); + }; + + try { + // Read one complete JSON-RPC response from stdout (handles both + // Content-Length framed and newline-delimited transport). + async function readResponse(timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout reading boocontext response')); + }, timeoutMs); + + let buf = ''; + + const cleanup = () => { + clearTimeout(timer); + child.stdout!.removeListener('data', onData); + child.stdout!.removeListener('end', onEnd); + child.stdout!.removeListener('error', onError); + }; + + const onData = (chunk: Buffer) => { + buf += chunk.toString('utf-8'); + + const msg = tryExtractMessage(buf); + if (msg !== null) { + cleanup(); + resolve(msg); + return; + } + + if (buf.length > 1_024 * 1_024) { + cleanup(); + reject(new Error('Boocontext response exceeded 1 MB')); + } + }; + + const onEnd = () => { + cleanup(); + if (buf.trim()) { + try { + resolve(JSON.parse(buf.trim())); + } catch { + reject(new Error('Boocontext stream ended with incomplete data')); + } + } else { + reject(new Error('Boocontext stream ended unexpectedly')); + } + }; + + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + child.stdout!.on('data', onData); + child.stdout!.on('end', onEnd); + child.stdout!.on('error', onError); + }); + } + + // Wait for the process to be fully spawned. + await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('spawn', () => resolve()); + }); + + // Step 1 — MCP initialize + let reqId = 0; + reqId++; + child.stdin!.write( + JSON.stringify({ jsonrpc: '2.0', id: reqId, method: 'initialize' }) + '\n', + ); + + const initResp = await readResponse() as { error?: { message: string } }; + if (initResp.error) { + throw new Error(`Boocontext init failed: ${initResp.error.message}`); + } + + // Step 2 — tools/call + reqId++; + child.stdin!.write( + JSON.stringify({ + jsonrpc: '2.0', + id: reqId, + method: 'tools/call', + params: { name: toolName, arguments: args }, + }) + '\n', + ); + + const callResp = await readResponse() as { + error?: { message: string }; + result?: { content?: Array<{ type: string; text: string }> }; + }; + if (callResp.error) { + throw new Error(`Boocontext tool call failed: ${callResp.error.message}`); + } + + // Extract text from the MCP tool result shape: + // { content: [{ type: "text", text: "…" }] } + const content = callResp.result?.content; + let text: string; + if (Array.isArray(content) && content.length > 0 && content[0]!.type === 'text') { + text = content[0]!.text; + } else { + text = JSON.stringify(callResp.result); + } + + // Inline truncation at 32 KB. + if (text.length > TRUNCATION_LIMIT) { + const omitted = text.length - TRUNCATION_LIMIT; + return { + result: + text.slice(0, TRUNCATION_LIMIT) + + `\n\n[truncated, ${omitted} chars omitted; narrow with file or symbol filter]`, + truncated: true, + }; + } + + return { result: text, truncated: false }; + } finally { + killChild(); + // Give the process a moment to release resources. + await new Promise((resolve) => { + const timer = setTimeout(resolve, 2_000); + child.on('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } +} + +/** + * Attempt to extract one complete JSON-RPC message from the head of a + * buffer. Handles both Content-Length framed and newline-delimited + * formats. Returns `null` when more data is needed. + */ +function tryExtractMessage(buf: string): unknown | null { + // --- Content-Length framed --- + const headerEnd = buf.indexOf('\r\n\r\n'); + if (headerEnd !== -1) { + const header = buf.substring(0, headerEnd); + const lengthMatch = header.match(/Content-Length:\s*(\d+)/i); + if (lengthMatch) { + const contentLength = parseInt(lengthMatch[1]!, 10); + const bodyStart = headerEnd + 4; + if (buf.length >= bodyStart + contentLength) { + const jsonStr = buf.substring(bodyStart, bodyStart + contentLength); + return JSON.parse(jsonStr); + } + return null; // need more data + } + // Has \r\n\r\n but no Content-Length — junk segment; skip and retry. + return tryExtractMessage(buf.substring(headerEnd + 4)); + } + + // --- Newline-delimited --- + const nlIndex = buf.indexOf('\n'); + if (nlIndex !== -1) { + const line = buf.substring(0, nlIndex).trim(); + if (line && line.startsWith('{')) { + return JSON.parse(line); + } + // Non-JSON line (e.g. stderr echo), skip and continue. + return tryExtractMessage(buf.substring(nlIndex + 1)); + } + + return null; // need more data +} + +// ---- ToolDef ---------------------------------------------------------------- + +export const getTypeInfo: ToolDef = { + name: 'get_type_info', + description: DESCRIPTION, + inputSchema: GetTypeInfoInput, + jsonSchema: { + type: 'function', + function: { + name: 'get_type_info', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + file: { type: 'string', description: 'File path to resolve types in' }, + symbol: { + type: 'string', + description: 'Symbol name to resolve (supports regex)', + }, + directory: { + type: 'string', + description: 'Project directory for type resolution context', + }, + }, + required: ['file'], + additionalProperties: false, + }, + }, + }, + async execute(input): Promise { + const args: Record = { file: input.file }; + if (input.symbol) args['symbol'] = input.symbol; + return callBoocontext('boocontext_types', args); + }, +}; + +/** + * Standalone execute function matching the `execute` shape returned by + * `makeCodecontextTool` — useful for direct callers and tests. + * + * Note: unlike the HTTP-backed codecontext tools this does NOT accept a + * `fetcher` override because it communicates over stdio rather than HTTP. + */ +export async function executeGetTypeInfo( + input: GetTypeInfoInputT, + _projectPath?: string, +): Promise { + const args: Record = { file: input.file }; + if (input.symbol) args['symbol'] = input.symbol; + return callBoocontext('boocontext_types', args); +} diff --git a/apps/server/src/services/tools/codecontext/index.ts b/apps/server/src/services/tools/codecontext/index.ts index 219507b..1f00871 100644 --- a/apps/server/src/services/tools/codecontext/index.ts +++ b/apps/server/src/services/tools/codecontext/index.ts @@ -13,3 +13,8 @@ export { getBlastRadius } from './get_blast_radius.js'; export { getHotFiles } from './get_hot_files.js'; export { getRoutes } from './get_routes.js'; export { getMiddleware } from './get_middleware.js'; +// v2.8.14-domain2-phase1: boocontext-backed tools. +export { getCodeHealth } from './get_code_health.js'; +export { getCodeImpact } from './get_code_impact.js'; +export { getTypeInfo } from './get_type_info.js'; +export { getCodeMap } from './get_code_map.js'; diff --git a/apps/server/src/services/tools/registry.ts b/apps/server/src/services/tools/registry.ts index a351602..59e331b 100644 --- a/apps/server/src/services/tools/registry.ts +++ b/apps/server/src/services/tools/registry.ts @@ -19,6 +19,10 @@ import { getHotFiles, getRoutes, getMiddleware, + getCodeHealth, + getCodeImpact, + getTypeInfo, + getCodeMap, } from './codecontext/index.js'; // v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired // with the pause-on-pending-grant branch in inference/tool-phase.ts and the @@ -75,6 +79,12 @@ export let ALL_TOOLS: ToolDef[] = [ // v2.6.x: read a tab's transcript by its session-scoped tab number. // Read-only; uses the ToolExecCtx 4th arg for DB/session access. readTabByNumber as ToolDef, + // v2.8.14-domain2-phase1: boocontext-backed tools. Backed by the boocontext + // MCP server. All read-only. Health, impact, types, map analysis. + getCodeHealth as ToolDef, + getCodeImpact as ToolDef, + getTypeInfo as ToolDef, + getCodeMap as ToolDef, ].sort((a, b) => a.name.localeCompare(b.name)); export let TOOLS_BY_NAME: Record> = Object.fromEntries(