From 79d121542eec6b4acacc248963ad12d76f3d6230 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:51:35 +0200 Subject: [PATCH] feat: adapt mcp tools to operation registry --- docs/architecture.md | 4 +- llms-full.txt | 4 +- roadmap.md | 9 +- src/mcp/index.ts | 402 ++++++--------------------- src/operations/index.ts | 169 ++++++----- tests/helpers/mcp.ts | 5 + tests/operation-registry.e2e.test.ts | 53 +++- tests/operation-registry.test.ts | 48 ++-- 8 files changed, 284 insertions(+), 410 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 17f38c2..d1d826a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ Core (shared computation) | result builders used by MCP, CLI, and operation descriptors |\ | \-> Operation Registry - | typed descriptors, input schemas, CLI/MCP names, result wrappers + | typed descriptors, input schemas, MCP adapter, result wrappers v MCP (stdio) CLI (terminal/CI) | 17 tools, 2 prompts, | 18 commands with text + JSON @@ -81,7 +81,7 @@ runOperation(operation, codebaseGraph, input, context) ## Key Design Decisions - **Dual interface**: MCP stdio for LLM agents, CLI subcommands for humans/CI. Both consume `src/core/`. -- **Operation registry foundation**: Analysis operations now have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. CLI/MCP handlers still call `src/core/` directly until the adapter migration lands. +- **Operation registry foundation**: Analysis operations now have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration consumes those descriptors; CLI commands still call `src/core/` directly until the CLI adapter migration lands. - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. - **Monorepo import resolution**: Root `tsconfig.json` path aliases and local `package.json` package names resolve to source files before graph construction. diff --git a/llms-full.txt b/llms-full.txt index 2dbd42f..6f93ffa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -28,7 +28,7 @@ Core (shared computation) | result builders used by MCP, CLI, and operation descriptors |\ | \-> Operation Registry - | typed descriptors, input schemas, CLI/MCP names, result wrappers + | typed descriptors, input schemas, MCP adapter, result wrappers v MCP (stdio) + CLI | MCP: 17 tools, 2 prompts, 3 resources for LLM agents @@ -81,7 +81,7 @@ runOperation(operation, codebaseGraph, input, context) ## Key Design Decisions - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. -- **Operation registry foundation**: Analysis operations have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. CLI/MCP handlers still call `src/core/` directly until the adapter migration lands. +- **Operation registry foundation**: Analysis operations have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration consumes those descriptors; CLI commands still call `src/core/` directly until the CLI adapter migration lands. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. - **Dead export detection**: Cross-references parsed exports against edge symbol lists. May miss `import *` or re-exports. - **Graceful degradation**: Non-git dirs get churn=0, no-test codebases get coverage=false. Never crashes. diff --git a/roadmap.md b/roadmap.md index 30c56a2..a3da14c 100644 --- a/roadmap.md +++ b/roadmap.md @@ -267,14 +267,17 @@ Collapse CLI + MCP operation duplication into one descriptor registry before add - Add `runOperation(...)` discriminated result/error wrapper for descriptor-level tests. - Type MCP next-step hint keys against the operation-name union. - Add CH-P1-01 coverage for registry -> CLI JSON -> MCP JSON overview parity. +- Reuse registry descriptors and input shapes in MCP tool registration. +- Route MCP operation success/error envelopes through `runOperation(...)` while preserving existing `nextSteps` and `isError` contracts. +- Add MCP registry parity coverage for representative operation descriptor runs. **Remaining:** -- Reuse registry schemas in CLI coercion and MCP tool registration. -- Move shared CLI/MCP failures over descriptor-level validation errors. +- Reuse registry schemas in CLI coercion. +- Move CLI failures over descriptor-level validation errors. - Use one graph-load pipeline with progress callbacks. - Move text/SARIF/markdown formatting over result objects into formatters. -- Expand CH-P1-01 from overview to every operation. +- Expand CH-P1-01 from overview/representative MCP operations to every operation. - Add CH-P1-02 coverage for success, invalid input, parse failure, and cache reuse through registry adapters. ### Type/Shape Layer diff --git a/src/mcp/index.ts b/src/mcp/index.ts index f73480f..6198810 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -1,341 +1,108 @@ import { createRequire } from "module"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import type { CodebaseGraph } from "../types/index.js"; const require = createRequire(import.meta.url); const pkg = require("../../package.json") as { version: string }; -import { getHints } from "./hints.js"; +import { getHintsForOperation } from "./hints.js"; import { getIndexedHead, getRoot } from "../server/graph-store.js"; import { runCheck } from "../rules/check.js"; import { formatJson } from "../rules/format.js"; import { - computeOverview, - computeFileContext, - computeHotspots, - computeSearch, - computeChanges, - computeDependents, - computeModuleStructure, - computeForces, - computeDeadExports, - computeOpportunities, - computeGroups, - computeSymbolContext, - computeProcesses, - computeClusters, - impactAnalysis, - renameSymbol, -} from "../core/index.js"; - -/** Register all MCP tools on a server instance. Shared by stdio and HTTP transports. */ -export function registerTools(server: McpServer, graph: CodebaseGraph): void { - // Tool 1: codebase_overview - server.tool( - "codebase_overview", - "Get a high-level overview of the codebase: total files, modules, top-depended files, and key metrics. Use when: first exploring a codebase, 'what does this project look like'. Not for: module details (use get_module_structure) or data flow (use analyze_forces)", - { depth: z.number().optional().describe("Module depth (default: 1)") }, - async (_params) => { - const overview = { - ...computeOverview(graph), - nextSteps: getHints("codebase_overview"), - }; - - return { content: [{ type: "text" as const, text: JSON.stringify(overview, null, 2) }] }; - } - ); - - // Tool 2: file_context - server.tool( - "file_context", - "Get detailed context for a specific file: exports, imports, dependents, and all metrics. Use when: 'tell me about this file', understanding a file before modifying it. Not for: symbol-level detail (use symbol_context)", - { filePath: z.string().describe("Relative path to the file") }, - async ({ filePath: rawFilePath }) => { - const result = computeFileContext(graph, rawFilePath); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: JSON.stringify(result) }], - isError: true, - }; - } - - const context = { ...result, nextSteps: getHints("file_context") }; - return { content: [{ type: "text" as const, text: JSON.stringify(context, null, 2) }] }; - } - ); - - // Tool 3: get_dependents - server.tool( - "get_dependents", - "Get all files that import a given file, with transitive dependents. File-level blast radius. Use when: 'what breaks if I change this file'. Not for: symbol-level impact (use impact_analysis)", - { - filePath: z.string().describe("Relative path to the file"), - depth: z.number().optional().describe("Max traversal depth (default: 2)"), - }, - async ({ filePath, depth }) => { - const result = computeDependents(graph, filePath, depth); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: JSON.stringify(result) }], - isError: true, - }; - } - return { - content: [{ type: "text" as const, text: JSON.stringify({ ...result, nextSteps: getHints("get_dependents") }, null, 2) }], - }; - } - ); - - // Tool 4: find_hotspots - server.tool( - "find_hotspots", - "Rank files by any metric: coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage. Use when: 'what are the riskiest files', 'which files need tests', 'most complex files'. Not for: module-level analysis (use get_module_structure)", - { - metric: z - .enum(["coupling", "pagerank", "fan_in", "fan_out", "betweenness", "tension", "escape_velocity", "churn", "complexity", "blast_radius", "coverage"]) - .describe("Metric to rank by"), - limit: z.number().optional().describe("Number of results (default: 10)"), - }, - async ({ metric, limit }) => { - const result = { - ...computeHotspots(graph, metric, limit), - nextSteps: getHints("find_hotspots"), - }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } - ); - - // Tool 5: get_module_structure - server.tool( - "get_module_structure", - "Get module/directory structure with cross-module dependencies, cohesion scores, and circular deps. Use when: 'how are modules organized', 'what depends on what module'. Not for: emergent clusters (use get_clusters) or file-level metrics (use find_hotspots)", - { depth: z.number().optional().describe("Module depth (default: 2)") }, - async (_params) => { - const result = { - ...computeModuleStructure(graph), - nextSteps: getHints("get_module_structure"), - }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } - ); - - // Tool 6: analyze_forces - server.tool( - "analyze_forces", - "Analyze module health: find misplaced files (tension), bridge files connecting otherwise-disconnected modules, and extraction candidates. Use when: 'what is architecturally wrong', 'which modules are coupled', 'what files should be moved'. Not for: file-level metrics (use find_hotspots)", - { - cohesionThreshold: z.number().optional().describe("Min cohesion to be 'COHESIVE' (default: 0.6)"), - tensionThreshold: z.number().optional().describe("Min tension to flag (default: 0.3)"), - escapeThreshold: z.number().optional().describe("Min escape velocity to flag (default: 0.5)"), - }, - async ({ cohesionThreshold, tensionThreshold, escapeThreshold }) => { - const result = { - ...computeForces(graph, cohesionThreshold, tensionThreshold, escapeThreshold), - nextSteps: getHints("analyze_forces"), - }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } - ); - - // Tool 7: find_dead_exports - server.tool( - "find_dead_exports", - "Find unused exports across the codebase — exports that no other file imports. Use when: cleaning up dead code, reducing API surface. Not for: finding used exports (use file_context)", - { - module: z.string().optional().describe("Filter by module path (default: all modules)"), - limit: z.number().optional().describe("Max results (default: 20)"), - }, - async ({ module, limit }) => { - const result = { - ...computeDeadExports(graph, module, limit), - nextSteps: getHints("find_dead_exports"), - }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } - ); + operationList, + operations, + parseOperationInput, + runOperation, + type Operation, +} from "../operations/index.js"; + +interface OperationToolOptions { + successPayload?: (data: TResult) => unknown; + errorNextSteps?: string[]; +} - // Tool 8: find_opportunities - server.tool( - "find_opportunities", - "Rank code quality and refactoring opportunities with evidence, confidence, and suggested follow-up commands. Use when: 'what should I improve', 'find refactoring opportunities', 'what needs tests'. Not for: raw metrics only (use find_hotspots or analyze_forces)", - { - limit: z.number().optional().describe("Max opportunities (default: 20)"), - }, - async ({ limit }) => { - const result = { - ...computeOpportunities(graph, limit), - nextSteps: getHints("find_opportunities"), - }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - }, - ); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} - // Tool 9: get_groups - server.tool( - "get_groups", - "Get top-level directory groups with aggregate metrics: files, LOC, importance (PageRank), coupling. Use when: 'what are the main areas of this codebase', high-level grouping overview. Not for: detailed module metrics (use get_module_structure)", - {}, - async () => { - const computed = computeGroups(graph); - if (computed.groups.length === 0) { - return { content: [{ type: "text" as const, text: JSON.stringify({ message: "No groups found.", nextSteps: getHints("get_groups") }) }] }; - } - const result = { ...computed, nextSteps: getHints("get_groups") }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - }, - ); +function jsonToolResult(payload: unknown, isError = false): CallToolResult { + const result: CallToolResult = { + content: [{ type: "text", text: JSON.stringify(payload, null, isError ? undefined : 2) }], + }; + if (isError) result.isError = true; + return result; +} - // Tool 9: symbol_context - server.tool( - "symbol_context", - "Find all callers and callees of a function, class, or method with importance metrics. Use when: 'who calls X', 'trace this function', 'what depends on this symbol'. Not for: text search (use search) or file-level dependencies (use get_dependents)", - { name: z.string().describe("Symbol name (e.g., 'AuthService', 'getUserById')") }, - async ({ name: symbolName }) => { - const result = computeSymbolContext(graph, symbolName); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: JSON.stringify(result) }], - isError: true, - }; - } - return { - content: [{ type: "text" as const, text: JSON.stringify({ ...result, nextSteps: getHints("symbol_context") }, null, 2) }], - }; - } - ); +function withNextSteps( + operation: Operation, + payload: unknown, +): unknown { + if (!isRecord(payload)) return payload; + return { ...payload, nextSteps: getHintsForOperation(operation.name) }; +} - // Tool 10: search - server.tool( - "search", - "Search files and symbols by keyword. Returns ranked results with symbol locations. Use when: 'find files related to auth', 'where is getUserById defined'. Not for: structured call graph queries (use symbol_context)", - { - query: z.string().describe("Search query (supports camelCase, snake_case splitting)"), - limit: z.number().optional().describe("Max results (default: 20)"), - }, - async ({ query, limit }) => { - const result = { - ...computeSearch(graph, query, limit), - nextSteps: getHints("search"), - }; - return { - content: [{ - type: "text" as const, - text: JSON.stringify(result, null, 2), - }], - }; - } - ); +function errorPayload( + result: { ok: false; error: string; data?: unknown }, + nextSteps?: string[], +): Record { + const payload = isRecord(result.data) && typeof result.data.error === "string" + ? result.data + : { error: result.error }; + return nextSteps ? { ...payload, nextSteps } : payload; +} - // Tool 11: detect_changes - server.tool( - "detect_changes", - "Detect changed files from git diff with risk metrics per file. Use when: starting a review, triaging changes, 'what changed'. Not for: symbol-level impact (use impact_analysis)", - { - scope: z.enum(["staged", "unstaged", "all"]).optional().describe("Git diff scope (default: all)"), - }, - async ({ scope }) => { - const result = computeChanges(graph, scope, getRoot()); - if ("error" in result) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - ...result, - nextSteps: ["Ensure you are in a git repository"], - }), - }], - isError: true, - }; +function registerOperationTool( + server: McpServer, + graph: CodebaseGraph, + operation: Operation, + options: OperationToolOptions = {}, +): void { + server.tool( + operation.mcpTool, + operation.description, + operation.inputShape, + async (rawInput) => { + const parsed = parseOperationInput(operation, rawInput); + if (!parsed.ok) { + return jsonToolResult({ error: parsed.error }, true); } - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - ...result, - nextSteps: getHints("detect_changes"), - }, null, 2), - }], - }; - } - ); - // Tool 12: impact_analysis - server.tool( - "impact_analysis", - "Analyze blast radius of changing a specific function or class. Symbol-level, depth-grouped, with risk labels (WILL BREAK / LIKELY / MAY NEED TESTING). Use when: 'what breaks if I change getUserById'. Not for: file-level dependencies (use get_dependents)", - { - symbol: z.string().describe("Symbol name or qualified name (e.g., 'getUserById' or 'UserService.getUserById')"), - }, - async ({ symbol }) => { - const result = impactAnalysis(graph, symbol); - if (result.notFound) { - return { - content: [{ type: "text" as const, text: JSON.stringify({ error: `Symbol not found: ${symbol}` }) }], - isError: true, - }; + const result = runOperation(operation, graph, parsed.data, { rootDir: getRoot() }); + if (!result.ok) { + return jsonToolResult(errorPayload(result, options.errorNextSteps), true); } - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ ...result, nextSteps: getHints("impact_analysis") }, null, 2), - }], - }; - } - ); - - // Tool 13: rename_symbol - server.tool( - "rename_symbol", - "Read-only: find all reference locations for a symbol across the codebase, with confidence levels. Does not modify files. Use when: planning a rename, finding all usages. Not for: call graph analysis (use symbol_context)", - { - oldName: z.string().describe("Current symbol name"), - newName: z.string().describe("New symbol name"), - dryRun: z.boolean().optional().describe("If true, only report references without renaming (default: true)"), - }, - async ({ oldName, newName, dryRun }) => { - const result = renameSymbol(graph, oldName, newName, dryRun ?? true); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ ...result, nextSteps: getHints("rename_symbol") }, null, 2), - }], - }; - } - ); - // Tool 14: get_processes - server.tool( - "get_processes", - "Trace execution flows from entry points through the call graph. Returns step-by-step paths showing how requests flow through the codebase. Use when: 'how does this app start', 'trace request flow', 'what are the entry points'. Not for: static file dependencies (use get_dependents)", - { - entryPoint: z.string().optional().describe("Filter by entry point symbol name"), - limit: z.number().optional().describe("Max processes to return (default: all)"), + const payload = options.successPayload ? options.successPayload(result.data) : result.data; + return jsonToolResult(withNextSteps(operation, payload)); }, - async ({ entryPoint, limit }) => { - const result = { - ...computeProcesses(graph, entryPoint, limit), - nextSteps: getHints("get_processes"), - }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } ); +} - // Tool 15: get_clusters - server.tool( - "get_clusters", - "Get community-detected clusters of related files using Louvain algorithm. Discovers emergent groupings that may differ from directory structure. Use when: 'what files are related', 'find natural groupings', 'which files change together'. Not for: directory-based modules (use get_module_structure)", - { - minFiles: z.number().optional().describe("Filter clusters with at least N files (default: 0)"), - }, - async ({ minFiles }) => { - const result = { - ...computeClusters(graph, minFiles), - nextSteps: getHints("get_clusters"), - }; - return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; - } - ); +/** Register all MCP tools on a server instance. Shared by stdio and HTTP transports. */ +export function registerTools(server: McpServer, graph: CodebaseGraph): void { + registerOperationTool(server, graph, operations.overview); + registerOperationTool(server, graph, operations.fileContext); + registerOperationTool(server, graph, operations.dependents); + registerOperationTool(server, graph, operations.hotspots); + registerOperationTool(server, graph, operations.moduleStructure); + registerOperationTool(server, graph, operations.forces); + registerOperationTool(server, graph, operations.deadExports); + registerOperationTool(server, graph, operations.opportunities); + registerOperationTool(server, graph, operations.groups, { + successPayload: (computed) => computed.groups.length === 0 ? { message: "No groups found." } : computed, + }); + registerOperationTool(server, graph, operations.symbolContext); + registerOperationTool(server, graph, operations.search); + registerOperationTool(server, graph, operations.changes, { + errorNextSteps: ["Ensure you are in a git repository"], + }); + registerOperationTool(server, graph, operations.impact); + registerOperationTool(server, graph, operations.rename); + registerOperationTool(server, graph, operations.processes); + registerOperationTool(server, graph, operations.clusters); // MCP Prompts server.prompt( @@ -406,12 +173,7 @@ export function registerTools(server: McpServer, graph: CodebaseGraph): void { totalFiles: graph.stats.totalFiles, totalFunctions: graph.stats.totalFunctions, modules: [...graph.moduleMetrics.keys()], - availableTools: [ - "codebase_overview", "file_context", "get_dependents", "find_hotspots", - "get_module_structure", "analyze_forces", "find_dead_exports", "find_opportunities", "get_groups", - "symbol_context", "search", "detect_changes", "impact_analysis", "rename_symbol", - "get_processes", "get_clusters", "check", - ], + availableTools: [...operationList.map((operation) => operation.mcpTool), "check"], indexedHead, gettingStarted: [ "Call codebase_overview for a high-level map", diff --git a/src/operations/index.ts b/src/operations/index.ts index ba483ac..65844c0 100644 --- a/src/operations/index.ts +++ b/src/operations/index.ts @@ -51,6 +51,7 @@ export interface Operation { cliCommand: string; mcpTool: string; description: string; + inputShape: z.ZodRawShape; inputSchema: z.ZodType; run: (graph: CodebaseGraph, input: TInput, context: OperationContext) => TResult; } @@ -59,55 +60,77 @@ export type OperationRunResult = | { ok: true; data: TResult } | { ok: false; error: string; data?: TResult }; -const overviewInputSchema = z.object({ - depth: z.number().int().positive().optional(), -}).strict(); -const fileContextInputSchema = z.object({ filePath: z.string().min(1) }).strict(); -const dependentsInputSchema = z.object({ - filePath: z.string().min(1), - depth: z.number().int().positive().optional(), -}).strict(); -const hotspotsInputSchema = z.object({ - metric: z.enum(HOTSPOT_METRICS), - limit: z.number().int().positive().optional(), -}).strict(); -const moduleStructureInputSchema = z.object({ - depth: z.number().int().positive().optional(), -}).strict(); -const forcesInputSchema = z.object({ - cohesionThreshold: z.number().optional(), - tensionThreshold: z.number().optional(), - escapeThreshold: z.number().optional(), -}).strict(); -const deadExportsInputSchema = z.object({ - module: z.string().min(1).optional(), - limit: z.number().int().positive().optional(), -}).strict(); -const opportunitiesInputSchema = z.object({ - limit: z.number().int().positive().optional(), -}).strict(); -const emptyInputSchema = z.object({}).strict(); -const symbolContextInputSchema = z.object({ name: z.string().min(1) }).strict(); -const searchInputSchema = z.object({ - query: z.string().min(1), - limit: z.number().int().positive().optional(), -}).strict(); -const changesInputSchema = z.object({ - scope: z.enum(CHANGE_SCOPES).optional(), -}).strict(); -const impactInputSchema = z.object({ symbol: z.string().min(1) }).strict(); -const renameInputSchema = z.object({ - oldName: z.string().min(1), - newName: z.string().min(1), - dryRun: z.boolean().optional(), -}).strict(); -const processesInputSchema = z.object({ - entryPoint: z.string().min(1).optional(), - limit: z.number().int().positive().optional(), -}).strict(); -const clustersInputSchema = z.object({ - minFiles: z.number().int().positive().optional(), -}).strict(); +const overviewInputShape = { + depth: z.number().int().positive().optional().describe("Module depth (default: 1)"), +} satisfies z.ZodRawShape; +const overviewInputSchema = z.object(overviewInputShape).strict(); +const fileContextInputShape = { + filePath: z.string().min(1).describe("Relative path to the file"), +} satisfies z.ZodRawShape; +const fileContextInputSchema = z.object(fileContextInputShape).strict(); +const dependentsInputShape = { + filePath: z.string().min(1).describe("Relative path to the file"), + depth: z.number().int().positive().optional().describe("Max traversal depth (default: 2)"), +} satisfies z.ZodRawShape; +const dependentsInputSchema = z.object(dependentsInputShape).strict(); +const hotspotsInputShape = { + metric: z.enum(HOTSPOT_METRICS).describe("Metric to rank by"), + limit: z.number().int().positive().optional().describe("Number of results (default: 10)"), +} satisfies z.ZodRawShape; +const hotspotsInputSchema = z.object(hotspotsInputShape).strict(); +const moduleStructureInputShape = { + depth: z.number().int().positive().optional().describe("Module depth (default: 2)"), +} satisfies z.ZodRawShape; +const moduleStructureInputSchema = z.object(moduleStructureInputShape).strict(); +const forcesInputShape = { + cohesionThreshold: z.number().optional().describe("Min cohesion to be 'COHESIVE' (default: 0.6)"), + tensionThreshold: z.number().optional().describe("Min tension to flag (default: 0.3)"), + escapeThreshold: z.number().optional().describe("Min escape velocity to flag (default: 0.5)"), +} satisfies z.ZodRawShape; +const forcesInputSchema = z.object(forcesInputShape).strict(); +const deadExportsInputShape = { + module: z.string().min(1).optional().describe("Filter by module path (default: all modules)"), + limit: z.number().int().positive().optional().describe("Max results (default: 20)"), +} satisfies z.ZodRawShape; +const deadExportsInputSchema = z.object(deadExportsInputShape).strict(); +const opportunitiesInputShape = { + limit: z.number().int().positive().optional().describe("Max opportunities (default: 20)"), +} satisfies z.ZodRawShape; +const opportunitiesInputSchema = z.object(opportunitiesInputShape).strict(); +const emptyInputShape = {} satisfies z.ZodRawShape; +const emptyInputSchema = z.object(emptyInputShape).strict(); +const symbolContextInputShape = { + name: z.string().min(1).describe("Symbol name (e.g., 'AuthService', 'getUserById')"), +} satisfies z.ZodRawShape; +const symbolContextInputSchema = z.object(symbolContextInputShape).strict(); +const searchInputShape = { + query: z.string().min(1).describe("Search query (supports camelCase, snake_case splitting)"), + limit: z.number().int().positive().optional().describe("Max results (default: 20)"), +} satisfies z.ZodRawShape; +const searchInputSchema = z.object(searchInputShape).strict(); +const changesInputShape = { + scope: z.enum(CHANGE_SCOPES).optional().describe("Git diff scope (default: all)"), +} satisfies z.ZodRawShape; +const changesInputSchema = z.object(changesInputShape).strict(); +const impactInputShape = { + symbol: z.string().min(1).describe("Symbol name or qualified name (e.g., 'getUserById' or 'UserService.getUserById')"), +} satisfies z.ZodRawShape; +const impactInputSchema = z.object(impactInputShape).strict(); +const renameInputShape = { + oldName: z.string().min(1).describe("Current symbol name"), + newName: z.string().min(1).describe("New symbol name"), + dryRun: z.boolean().optional().describe("If true, only report references without renaming (default: true)"), +} satisfies z.ZodRawShape; +const renameInputSchema = z.object(renameInputShape).strict(); +const processesInputShape = { + entryPoint: z.string().min(1).optional().describe("Filter by entry point symbol name"), + limit: z.number().int().positive().optional().describe("Max processes to return (default: all)"), +} satisfies z.ZodRawShape; +const processesInputSchema = z.object(processesInputShape).strict(); +const clustersInputShape = { + minFiles: z.number().int().positive().optional().describe("Filter clusters with at least N files (default: 0)"), +} satisfies z.ZodRawShape; +const clustersInputSchema = z.object(clustersInputShape).strict(); type OverviewInput = z.infer; type FileContextInput = z.infer; @@ -131,7 +154,8 @@ export const operations = { name: "overview", cliCommand: "overview", mcpTool: "codebase_overview", - description: "High-level codebase snapshot: files, functions, modules, dependencies.", + description: "Get a high-level overview of the codebase: total files, modules, top-depended files, and key metrics. Use when: first exploring a codebase, 'what does this project look like'. Not for: module details (use get_module_structure) or data flow (use analyze_forces)", + inputShape: overviewInputShape, inputSchema: overviewInputSchema, run: (graph: CodebaseGraph, _input: OverviewInput) => computeOverview(graph), } satisfies Operation>, @@ -139,7 +163,8 @@ export const operations = { name: "fileContext", cliCommand: "file", mcpTool: "file_context", - description: "Detailed file context: exports, imports, dependents, and metrics.", + description: "Get detailed context for a specific file: exports, imports, dependents, and all metrics. Use when: 'tell me about this file', understanding a file before modifying it. Not for: symbol-level detail (use symbol_context)", + inputShape: fileContextInputShape, inputSchema: fileContextInputSchema, run: (graph: CodebaseGraph, input: FileContextInput) => computeFileContext(graph, input.filePath), } satisfies Operation>, @@ -147,7 +172,8 @@ export const operations = { name: "dependents", cliCommand: "dependents", mcpTool: "get_dependents", - description: "File-level blast radius with direct and transitive dependents.", + description: "Get all files that import a given file, with transitive dependents. File-level blast radius. Use when: 'what breaks if I change this file'. Not for: symbol-level impact (use impact_analysis)", + inputShape: dependentsInputShape, inputSchema: dependentsInputSchema, run: (graph: CodebaseGraph, input: DependentsInput) => computeDependents(graph, input.filePath, input.depth), } satisfies Operation>, @@ -155,7 +181,8 @@ export const operations = { name: "hotspots", cliCommand: "hotspots", mcpTool: "find_hotspots", - description: "Rank files or modules by architectural risk metric.", + description: "Rank files by any metric: coupling, pagerank, fan_in, fan_out, betweenness, tension, escape_velocity, churn, complexity, blast_radius, coverage. Use when: 'what are the riskiest files', 'which files need tests', 'most complex files'. Not for: module-level analysis (use get_module_structure)", + inputShape: hotspotsInputShape, inputSchema: hotspotsInputSchema, run: (graph: CodebaseGraph, input: HotspotsInput) => computeHotspots(graph, input.metric, input.limit), } satisfies Operation>, @@ -163,7 +190,8 @@ export const operations = { name: "moduleStructure", cliCommand: "modules", mcpTool: "get_module_structure", - description: "Module structure with cohesion, cross-module dependencies, and circular dependencies.", + description: "Get module/directory structure with cross-module dependencies, cohesion scores, and circular deps. Use when: 'how are modules organized', 'what depends on what module'. Not for: emergent clusters (use get_clusters) or file-level metrics (use find_hotspots)", + inputShape: moduleStructureInputShape, inputSchema: moduleStructureInputSchema, run: (graph: CodebaseGraph, _input: ModuleStructureInput) => computeModuleStructure(graph), } satisfies Operation>, @@ -171,7 +199,8 @@ export const operations = { name: "forces", cliCommand: "forces", mcpTool: "analyze_forces", - description: "Architectural force analysis for tension, bridge files, and extraction candidates.", + description: "Analyze module health: find misplaced files (tension), bridge files connecting otherwise-disconnected modules, and extraction candidates. Use when: 'what is architecturally wrong', 'which modules are coupled', 'what files should be moved'. Not for: file-level metrics (use find_hotspots)", + inputShape: forcesInputShape, inputSchema: forcesInputSchema, run: (graph: CodebaseGraph, input: ForcesInput) => computeForces(graph, input.cohesionThreshold, input.tensionThreshold, input.escapeThreshold), @@ -180,7 +209,8 @@ export const operations = { name: "deadExports", cliCommand: "dead-exports", mcpTool: "find_dead_exports", - description: "Unused exports across the codebase.", + description: "Find unused exports across the codebase - exports that no other file imports. Use when: cleaning up dead code, reducing API surface. Not for: finding used exports (use file_context)", + inputShape: deadExportsInputShape, inputSchema: deadExportsInputSchema, run: (graph: CodebaseGraph, input: DeadExportsInput) => computeDeadExports(graph, input.module, input.limit), } satisfies Operation>, @@ -188,7 +218,8 @@ export const operations = { name: "opportunities", cliCommand: "opportunities", mcpTool: "find_opportunities", - description: "Ranked code quality and refactoring opportunities.", + description: "Rank code quality and refactoring opportunities with evidence, confidence, and suggested follow-up commands. Use when: 'what should I improve', 'find refactoring opportunities', 'what needs tests'. Not for: raw metrics only (use find_hotspots or analyze_forces)", + inputShape: opportunitiesInputShape, inputSchema: opportunitiesInputSchema, run: (graph: CodebaseGraph, input: OpportunitiesInput) => computeOpportunities(graph, input.limit), } satisfies Operation>, @@ -196,7 +227,8 @@ export const operations = { name: "groups", cliCommand: "groups", mcpTool: "get_groups", - description: "Top-level directory groups with aggregate metrics.", + description: "Get top-level directory groups with aggregate metrics: files, LOC, importance (PageRank), coupling. Use when: 'what are the main areas of this codebase', high-level grouping overview. Not for: detailed module metrics (use get_module_structure)", + inputShape: emptyInputShape, inputSchema: emptyInputSchema, run: (graph: CodebaseGraph, _input: EmptyInput) => computeGroups(graph), } satisfies Operation>, @@ -204,7 +236,8 @@ export const operations = { name: "symbolContext", cliCommand: "symbol", mcpTool: "symbol_context", - description: "Symbol callers, callees, and importance metrics.", + description: "Find all callers and callees of a function, class, or method with importance metrics. Use when: 'who calls X', 'trace this function', 'what depends on this symbol'. Not for: text search (use search) or file-level dependencies (use get_dependents)", + inputShape: symbolContextInputShape, inputSchema: symbolContextInputSchema, run: (graph: CodebaseGraph, input: SymbolContextInput) => computeSymbolContext(graph, input.name), } satisfies Operation>, @@ -212,7 +245,8 @@ export const operations = { name: "search", cliCommand: "search", mcpTool: "search", - description: "Keyword search across files and symbols.", + description: "Search files and symbols by keyword. Returns ranked results with symbol locations. Use when: 'find files related to auth', 'where is getUserById defined'. Not for: structured call graph queries (use symbol_context)", + inputShape: searchInputShape, inputSchema: searchInputSchema, run: (graph: CodebaseGraph, input: SearchInput) => computeSearch(graph, input.query, input.limit), } satisfies Operation>, @@ -220,7 +254,8 @@ export const operations = { name: "changes", cliCommand: "changes", mcpTool: "detect_changes", - description: "Git diff analysis with changed files, symbols, and risk metrics.", + description: "Detect changed files from git diff with risk metrics per file. Use when: starting a review, triaging changes, 'what changed'. Not for: symbol-level impact (use impact_analysis)", + inputShape: changesInputShape, inputSchema: changesInputSchema, run: (graph: CodebaseGraph, input: ChangesInput, context: OperationContext) => computeChanges(graph, input.scope, context.rootDir), @@ -229,7 +264,8 @@ export const operations = { name: "impact", cliCommand: "impact", mcpTool: "impact_analysis", - description: "Symbol-level blast radius with depth-grouped impact levels.", + description: "Analyze blast radius of changing a specific function or class. Symbol-level, depth-grouped, with risk labels (WILL BREAK / LIKELY / MAY NEED TESTING). Use when: 'what breaks if I change getUserById'. Not for: file-level dependencies (use get_dependents)", + inputShape: impactInputShape, inputSchema: impactInputSchema, run: (graph: CodebaseGraph, input: ImpactInput) => impactAnalysis(graph, input.symbol), } satisfies Operation>, @@ -237,7 +273,8 @@ export const operations = { name: "rename", cliCommand: "rename", mcpTool: "rename_symbol", - description: "Read-only rename reference planning for a symbol.", + description: "Read-only: find all reference locations for a symbol across the codebase, with confidence levels. Does not modify files. Use when: planning a rename, finding all usages. Not for: call graph analysis (use symbol_context)", + inputShape: renameInputShape, inputSchema: renameInputSchema, run: (graph: CodebaseGraph, input: RenameInput) => renameSymbol(graph, input.oldName, input.newName, input.dryRun ?? true), @@ -246,7 +283,8 @@ export const operations = { name: "processes", cliCommand: "processes", mcpTool: "get_processes", - description: "Execution flows from entry points through the call graph.", + description: "Trace execution flows from entry points through the call graph. Returns step-by-step paths showing how requests flow through the codebase. Use when: 'how does this app start', 'trace request flow', 'what are the entry points'. Not for: static file dependencies (use get_dependents)", + inputShape: processesInputShape, inputSchema: processesInputSchema, run: (graph: CodebaseGraph, input: ProcessesInput) => computeProcesses(graph, input.entryPoint, input.limit), } satisfies Operation>, @@ -254,7 +292,8 @@ export const operations = { name: "clusters", cliCommand: "clusters", mcpTool: "get_clusters", - description: "Community-detected file clusters.", + description: "Get community-detected clusters of related files using Louvain algorithm. Discovers emergent groupings that may differ from directory structure. Use when: 'what files are related', 'find natural groupings', 'which files change together'. Not for: directory-based modules (use get_module_structure)", + inputShape: clustersInputShape, inputSchema: clustersInputSchema, run: (graph: CodebaseGraph, input: ClustersInput) => computeClusters(graph, input.minFiles), } satisfies Operation>, diff --git a/tests/helpers/mcp.ts b/tests/helpers/mcp.ts index 609b8f3..6ab3e0f 100644 --- a/tests/helpers/mcp.ts +++ b/tests/helpers/mcp.ts @@ -11,6 +11,7 @@ export interface ToolPayload { } export interface FixtureMcp { + listTools(): Promise; callTool(name: string, args?: Record): Promise>; callToolWithMeta(name: string, args?: Record): Promise; } @@ -54,6 +55,10 @@ export async function createFixtureMcp(rootDir = getFixtureSrcPath()): Promise { + const result = await client.listTools(); + return result.tools.map((tool) => tool.name); + }, async callTool(name: string, args: Record = {}): Promise> { const result = await client.callTool({ name, arguments: args }); return parsePayload(firstTextContent(result)); diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts index b9cd9bc..1861889 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -4,8 +4,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; import type { OverviewResult } from "../src/core/index.js"; -import { operations, runOperation } from "../src/operations/index.js"; -import { createFixtureMcp } from "./helpers/mcp.js"; +import { operations, runOperation, type Operation } from "../src/operations/index.js"; +import type { CodebaseGraph } from "../src/types/index.js"; +import { createFixtureMcp, type FixtureMcp } from "./helpers/mcp.js"; import { getFixturePipeline, getFixtureSrcPath } from "./helpers/pipeline.js"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -58,6 +59,27 @@ function normalizeOverviewPayload(payload: Record): OverviewPar }; } +function withoutNextSteps(payload: Record): Record { + const normalized = { ...payload }; + delete normalized.nextSteps; + return normalized; +} + +async function expectMcpMatchesRegistry( + operation: Operation, + input: TInput, + mcpArgs: Record, + graph: CodebaseGraph, + mcp: FixtureMcp, +): Promise { + const registryResult = runOperation(operation, graph, input); + expect(registryResult.ok).toBe(true); + if (!registryResult.ok) throw new Error(registryResult.error); + + const mcpPayload = await mcp.callTool(operation.mcpTool, mcpArgs); + expect(withoutNextSteps(mcpPayload)).toEqual(registryResult.data); +} + function runCliOverview(): Record { const res = spawnSync("node", [cli, "overview", getFixtureSrcPath(), "--json", "--force"], { cwd: repoRoot, @@ -92,4 +114,31 @@ describe("operation registry chained parity", () => { expect(normalizeOverviewPayload(cliPayload)).toEqual(registryOverview); expect(normalizeOverviewPayload(mcpPayload)).toEqual(registryOverview); }); + + it("CH-P1-01: registry-adapted MCP tools match descriptor runs", async () => { + const { codebaseGraph } = getFixturePipeline(); + const mcp = await createFixtureMcp(); + + await expectMcpMatchesRegistry( + operations.fileContext, + { filePath: "index.ts" }, + { filePath: "index.ts" }, + codebaseGraph, + mcp, + ); + await expectMcpMatchesRegistry( + operations.hotspots, + { metric: "coupling", limit: 3 }, + { metric: "coupling", limit: 3 }, + codebaseGraph, + mcp, + ); + await expectMcpMatchesRegistry( + operations.impact, + { symbol: "getUserById" }, + { symbol: "getUserById" }, + codebaseGraph, + mcp, + ); + }); }); diff --git a/tests/operation-registry.test.ts b/tests/operation-registry.test.ts index 7476f18..ad56713 100644 --- a/tests/operation-registry.test.ts +++ b/tests/operation-registry.test.ts @@ -12,30 +12,32 @@ import { runOperation, type OperationName, } from "../src/operations/index.js"; +import { createFixtureMcp } from "./helpers/mcp.js"; import { getFixturePipeline } from "./helpers/pipeline.js"; const expectedOperations: Array<{ name: OperationName; cliCommand: string; mcpTool: string; + inputKeys: string[]; sampleInput: Record; }> = [ - { name: "overview", cliCommand: "overview", mcpTool: "codebase_overview", sampleInput: {} }, - { name: "fileContext", cliCommand: "file", mcpTool: "file_context", sampleInput: { filePath: "index.ts" } }, - { name: "dependents", cliCommand: "dependents", mcpTool: "get_dependents", sampleInput: { filePath: "index.ts", depth: 2 } }, - { name: "hotspots", cliCommand: "hotspots", mcpTool: "find_hotspots", sampleInput: { metric: "coupling", limit: 3 } }, - { name: "moduleStructure", cliCommand: "modules", mcpTool: "get_module_structure", sampleInput: {} }, - { name: "forces", cliCommand: "forces", mcpTool: "analyze_forces", sampleInput: { cohesionThreshold: 0.6 } }, - { name: "deadExports", cliCommand: "dead-exports", mcpTool: "find_dead_exports", sampleInput: { limit: 5 } }, - { name: "opportunities", cliCommand: "opportunities", mcpTool: "find_opportunities", sampleInput: { limit: 5 } }, - { name: "groups", cliCommand: "groups", mcpTool: "get_groups", sampleInput: {} }, - { name: "symbolContext", cliCommand: "symbol", mcpTool: "symbol_context", sampleInput: { name: "getUserById" } }, - { name: "search", cliCommand: "search", mcpTool: "search", sampleInput: { query: "auth", limit: 5 } }, - { name: "changes", cliCommand: "changes", mcpTool: "detect_changes", sampleInput: { scope: "all" } }, - { name: "impact", cliCommand: "impact", mcpTool: "impact_analysis", sampleInput: { symbol: "getUserById" } }, - { name: "rename", cliCommand: "rename", mcpTool: "rename_symbol", sampleInput: { oldName: "getUserById", newName: "findUserById" } }, - { name: "processes", cliCommand: "processes", mcpTool: "get_processes", sampleInput: { entryPoint: "main" } }, - { name: "clusters", cliCommand: "clusters", mcpTool: "get_clusters", sampleInput: { minFiles: 2 } }, + { name: "overview", cliCommand: "overview", mcpTool: "codebase_overview", inputKeys: ["depth"], sampleInput: {} }, + { name: "fileContext", cliCommand: "file", mcpTool: "file_context", inputKeys: ["filePath"], sampleInput: { filePath: "index.ts" } }, + { name: "dependents", cliCommand: "dependents", mcpTool: "get_dependents", inputKeys: ["filePath", "depth"], sampleInput: { filePath: "index.ts", depth: 2 } }, + { name: "hotspots", cliCommand: "hotspots", mcpTool: "find_hotspots", inputKeys: ["metric", "limit"], sampleInput: { metric: "coupling", limit: 3 } }, + { name: "moduleStructure", cliCommand: "modules", mcpTool: "get_module_structure", inputKeys: ["depth"], sampleInput: {} }, + { name: "forces", cliCommand: "forces", mcpTool: "analyze_forces", inputKeys: ["cohesionThreshold", "tensionThreshold", "escapeThreshold"], sampleInput: { cohesionThreshold: 0.6 } }, + { name: "deadExports", cliCommand: "dead-exports", mcpTool: "find_dead_exports", inputKeys: ["module", "limit"], sampleInput: { limit: 5 } }, + { name: "opportunities", cliCommand: "opportunities", mcpTool: "find_opportunities", inputKeys: ["limit"], sampleInput: { limit: 5 } }, + { name: "groups", cliCommand: "groups", mcpTool: "get_groups", inputKeys: [], sampleInput: {} }, + { name: "symbolContext", cliCommand: "symbol", mcpTool: "symbol_context", inputKeys: ["name"], sampleInput: { name: "getUserById" } }, + { name: "search", cliCommand: "search", mcpTool: "search", inputKeys: ["query", "limit"], sampleInput: { query: "auth", limit: 5 } }, + { name: "changes", cliCommand: "changes", mcpTool: "detect_changes", inputKeys: ["scope"], sampleInput: { scope: "all" } }, + { name: "impact", cliCommand: "impact", mcpTool: "impact_analysis", inputKeys: ["symbol"], sampleInput: { symbol: "getUserById" } }, + { name: "rename", cliCommand: "rename", mcpTool: "rename_symbol", inputKeys: ["oldName", "newName", "dryRun"], sampleInput: { oldName: "getUserById", newName: "findUserById" } }, + { name: "processes", cliCommand: "processes", mcpTool: "get_processes", inputKeys: ["entryPoint", "limit"], sampleInput: { entryPoint: "main" } }, + { name: "clusters", cliCommand: "clusters", mcpTool: "get_clusters", inputKeys: ["minFiles"], sampleInput: { minFiles: 2 } }, ]; describe("operation registry", () => { @@ -50,6 +52,7 @@ describe("operation registry", () => { const operation = getOperation(expected.name); expect(operation.cliCommand).toBe(expected.cliCommand); expect(operation.mcpTool).toBe(expected.mcpTool); + expect(Object.keys(operation.inputShape)).toEqual(expected.inputKeys); expect(operation.inputSchema.safeParse(expected.sampleInput).success).toBe(true); expect(getOperationByCliCommand(expected.cliCommand)).toBe(operation); expect(getOperationByMcpTool(expected.mcpTool)).toBe(operation); @@ -90,4 +93,17 @@ describe("operation registry", () => { expect(getHintsForOperation("overview")).toEqual(getHints("codebase_overview")); expect(getHints("unknown_tool")).toEqual([]); }); + + it("registers MCP tools from the operation registry plus check", async () => { + const mcp = await createFixtureMcp(); + expect(await mcp.listTools()).toEqual([...operationList.map((operation) => operation.mcpTool), "check"]); + }); + + it("keeps registry-adapted MCP lookup errors in the existing envelope", async () => { + const mcp = await createFixtureMcp(); + const result = await mcp.callToolWithMeta("impact_analysis", { symbol: "MissingSymbol" }); + + expect(result.isError).toBe(true); + expect(result.payload).toEqual({ error: "Symbol not found: MissingSymbol" }); + }); });