diff --git a/__tests__/mcp-tool-registry.test.ts b/__tests__/mcp-tool-registry.test.ts new file mode 100644 index 00000000..6ca9cef8 --- /dev/null +++ b/__tests__/mcp-tool-registry.test.ts @@ -0,0 +1,79 @@ +/** + * MCP tool registry: structural invariants. + * + * Guards against the failure mode where a future PR adds a + * ToolModule but forgets to implement the matching `handle` + * method on ToolHandler (or vice versa). + */ +import { describe, it, expect } from 'vitest'; +import { getToolModules, tools as registryTools } from '../src/mcp/tools/registry'; +import { ToolHandler, tools } from '../src/mcp/tools'; + +describe('MCP tool registry — single source of truth', () => { + it('every tool module has a non-empty name and description', () => { + for (const m of getToolModules()) { + expect(m.definition.name).toMatch(/^codegraph_[a-z_]+$/); + expect(m.definition.description.length).toBeGreaterThan(20); + } + }); + + it('handlerKey is a string starting with "handle"', () => { + for (const m of getToolModules()) { + expect(m.handlerKey).toMatch(/^handle[A-Z][A-Za-z]+$/); + } + }); + + it('every registered tool has a corresponding ToolHandler method', () => { + const handler = new ToolHandler(null); + for (const m of getToolModules()) { + const fn = (handler as unknown as Record)[m.handlerKey]; + expect(typeof fn).toBe('function'); + } + }); + + it('exported `tools` array exactly mirrors the registry', () => { + const fromRegistry = registryTools.map((t) => t.name).sort(); + const fromExport = tools.map((t) => t.name).sort(); + expect(fromExport).toEqual(fromRegistry); + }); + + it('all 9 main-line tools are registered (regression guard)', () => { + const expected = [ + 'codegraph_callees', + 'codegraph_callers', + 'codegraph_context', + 'codegraph_explore', + 'codegraph_files', + 'codegraph_impact', + 'codegraph_node', + 'codegraph_search', + 'codegraph_status', + ]; + const actual = getToolModules() + .map((m) => m.definition.name) + .sort(); + expect(actual).toEqual(expected); + }); + + it('execute() reports unknown-tool errors', async () => { + const handler = new ToolHandler(null); + const result = await handler.execute('codegraph_does_not_exist', {}); + expect(result.isError).toBe(true); + expect(result.content[0]?.text).toMatch(/Unknown tool/); + }); + + it('execute() actually dispatches to the registered handler (no broken `this` binding)', async () => { + // No CodeGraph instance is bound, so handlers that call + // `getCodeGraph()` will throw — the dispatch should catch it + // and return an error result. The point of this test is to + // confirm the registry lookup + `this[handlerKey](args)` chain + // reaches an actual method body, not that the body succeeds. + const handler = new ToolHandler(null); + const result = await handler.execute('codegraph_status', {}); + expect(result.isError).toBe(true); + // Generic tool-execution-failed envelope from execute()'s catch block. + expect(result.content[0]?.text).toMatch(/Tool execution failed/); + // Specifically because no CodeGraph was bound: + expect(result.content[0]?.text).toMatch(/CodeGraph not initialized/); + }); +}); diff --git a/src/mcp/index.ts b/src/mcp/index.ts index bc3552ae..c31284a8 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -18,7 +18,8 @@ import * as path from 'path'; import CodeGraph, { findNearestCodeGraphRoot } from '../index'; import { StdioTransport, JsonRpcRequest, JsonRpcNotification, ErrorCodes } from './transport'; -import { tools, ToolHandler } from './tools'; +import { ToolHandler } from './tools'; +import { getToolModule } from './tools/registry'; /** * Convert a file:// URI to a filesystem path. @@ -309,8 +310,9 @@ export class MCPServer { const toolName = params.name; const toolArgs = params.arguments || {}; - // Validate tool exists - const tool = tools.find(t => t.name === toolName); + // Validate tool exists — O(1) Map lookup against the registry, + // matches the path `ToolHandler.execute()` uses internally. + const tool = getToolModule(toolName)?.definition; if (!tool) { this.transport.sendError( request.id, diff --git a/src/mcp/tool-types.ts b/src/mcp/tool-types.ts new file mode 100644 index 00000000..90e94fe8 --- /dev/null +++ b/src/mcp/tool-types.ts @@ -0,0 +1,39 @@ +/** + * Shared MCP tool types. + * + * Lives in its own module so per-tool files in `./tools/` and + * the legacy class wrapper in `./tools.ts` can import the same + * type definitions without a circular dependency. + */ + +export interface PropertySchema { + type: string; + description: string; + enum?: string[]; + default?: unknown; +} + +export interface ToolDefinition { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} + +export interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +/** + * Shared `projectPath` schema property — every tool's inputSchema + * accepts it for cross-project queries. + */ +export const projectPathProperty: PropertySchema = { + type: 'string', + description: + 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.', +}; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 53713145..7a5b995a 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -11,6 +11,25 @@ import { writeFileSync, readFileSync, existsSync } from 'fs'; import { clamp, validatePathWithinRoot } from '../utils'; import { tmpdir } from 'os'; import { join } from 'path'; +import type { ToolDefinition, ToolResult } from './tool-types'; +import type { ToolHandlerLike } from './tools/types'; +import { getToolModule, tools as registryTools } from './tools/registry'; + +// Re-export shared types so existing consumers (`import { ToolDefinition, +// ToolResult } from './tools'`) keep working unchanged. +export type { ToolDefinition, ToolResult } from './tool-types'; + +/** + * The MCP `list_tools` array, derived from the per-tool registry + * (`./tools/.ts`). Adding a new tool no longer touches this + * array — drop a file in `./tools/` and add it to + * `./tools/registry.ts`. + * + * Typed as a mutable array (matching the original export shape) + * even though the underlying registry produces a readonly value; + * we slice() to materialize a fresh, mutable copy at module load. + */ +export const tools: ToolDefinition[] = registryTools.slice(); /** Maximum output length to prevent context bloat (characters) */ const MAX_OUTPUT_LENGTH = 15000; @@ -42,248 +61,6 @@ function markSessionConsulted(sessionId: string): void { } } -/** - * MCP Tool definition - */ -export interface ToolDefinition { - name: string; - description: string; - inputSchema: { - type: 'object'; - properties: Record; - required?: string[]; - }; -} - -interface PropertySchema { - type: string; - description: string; - enum?: string[]; - default?: unknown; -} - -/** - * Tool execution result - */ -export interface ToolResult { - content: Array<{ - type: 'text'; - text: string; - }>; - isError?: boolean; -} - -/** - * Common projectPath property for cross-project queries - */ -const projectPathProperty: PropertySchema = { - type: 'string', - description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.', -}; - -/** - * All CodeGraph MCP tools - * - * Designed for minimal context usage - use codegraph_context as the primary tool, - * and only use other tools for targeted follow-up queries. - * - * All tools support cross-project queries via the optional `projectPath` parameter. - */ -export const tools: ToolDefinition[] = [ - { - name: 'codegraph_search', - description: 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")', - }, - kind: { - type: 'string', - description: 'Filter by node kind', - enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'], - }, - limit: { - type: 'number', - description: 'Maximum results (default: 10)', - default: 10, - }, - projectPath: projectPathProperty, - }, - required: ['query'], - }, - }, - { - name: 'codegraph_context', - description: 'PRIMARY TOOL: Build comprehensive context for a task. Returns entry points, related symbols, and key code - often enough to understand the codebase without additional tool calls. NOTE: This provides CODE context, not product requirements. For new features, still clarify UX/behavior questions with the user before implementing.', - inputSchema: { - type: 'object', - properties: { - task: { - type: 'string', - description: 'Description of the task, bug, or feature to build context for', - }, - maxNodes: { - type: 'number', - description: 'Maximum symbols to include (default: 20)', - default: 20, - }, - includeCode: { - type: 'boolean', - description: 'Include code snippets for key symbols (default: true)', - default: true, - }, - projectPath: projectPathProperty, - }, - required: ['task'], - }, - }, - { - name: 'codegraph_callers', - description: 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.', - inputSchema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'Name of the function, method, or class to find callers for', - }, - limit: { - type: 'number', - description: 'Maximum number of callers to return (default: 20)', - default: 20, - }, - projectPath: projectPathProperty, - }, - required: ['symbol'], - }, - }, - { - name: 'codegraph_callees', - description: 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.', - inputSchema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'Name of the function, method, or class to find callees for', - }, - limit: { - type: 'number', - description: 'Maximum number of callees to return (default: 20)', - default: 20, - }, - projectPath: projectPathProperty, - }, - required: ['symbol'], - }, - }, - { - name: 'codegraph_impact', - description: 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.', - inputSchema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'Name of the symbol to analyze impact for', - }, - depth: { - type: 'number', - description: 'How many levels of dependencies to traverse (default: 2)', - default: 2, - }, - projectPath: projectPathProperty, - }, - required: ['symbol'], - }, - }, - { - name: 'codegraph_node', - description: 'Get detailed information about a specific code symbol. Use includeCode=true only when you need the full source code - otherwise just get location and signature to minimize context usage.', - inputSchema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'Name of the symbol to get details for', - }, - includeCode: { - type: 'boolean', - description: 'Include full source code (default: false to minimize context)', - default: false, - }, - projectPath: projectPathProperty, - }, - required: ['symbol'], - }, - }, - { - name: 'codegraph_explore', - description: 'Deep exploration tool — returns comprehensive context for a topic in a SINGLE call. Groups all relevant source code by file (contiguous sections, not snippets), includes a relationship map, and uses deeper graph traversal. Designed to replace multiple codegraph_node + file Read calls. Use this instead of codegraph_context when you need thorough understanding. IMPORTANT: Use specific symbol names, file names, or short code terms in your query — NOT natural language sentences. Before calling this, use codegraph_search to discover relevant symbol names, then include those names in your query. Bad: "how are agent prompts loaded and passed to the CLI". Good: "readAgentsFromDirectory createClaudeSession chat-manager agents.ts".', - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.', - }, - maxFiles: { - type: 'number', - description: 'Maximum number of files to include source code from (default: 12)', - default: 12, - }, - projectPath: projectPathProperty, - }, - required: ['query'], - }, - }, - { - name: 'codegraph_status', - description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.', - inputSchema: { - type: 'object', - properties: { - projectPath: projectPathProperty, - }, - }, - }, - { - name: 'codegraph_files', - description: 'REQUIRED for file/folder exploration. Get the project file structure from the CodeGraph index. Returns a tree view of all indexed files with metadata (language, symbol count). Much faster than Glob/filesystem scanning. Use this FIRST when exploring project structure, finding files, or understanding codebase organization.', - inputSchema: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.', - }, - pattern: { - type: 'string', - description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")', - }, - format: { - type: 'string', - description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)', - enum: ['tree', 'flat', 'grouped'], - default: 'tree', - }, - includeMetadata: { - type: 'boolean', - description: 'Include file metadata like language and symbol count (default: true)', - default: true, - }, - maxDepth: { - type: 'number', - description: 'Maximum directory depth to show (default: unlimited)', - }, - projectPath: projectPathProperty, - }, - }, - }, -]; /** * Tool handler that executes tools against a CodeGraph instance @@ -291,7 +68,7 @@ export const tools: ToolDefinition[] = [ * Supports cross-project queries via the projectPath parameter. * Other projects are opened on-demand and cached for performance. */ -export class ToolHandler { +export class ToolHandler implements ToolHandlerLike { // Cache of opened CodeGraph instances for cross-project queries private projectCache: Map = new Map(); @@ -404,32 +181,24 @@ export class ToolHandler { } /** - * Execute a tool by name + * Execute a tool by name. + * + * The dispatch table lives in `./tools/registry.ts` — this method + * just looks up the tool's `handlerKey` and invokes the matching + * `handle` method on this class. Adding a new tool means + * registering a `ToolModule` (one new file under `./tools/`, + * one entry in the registry) plus implementing + * `handle(args)` here. */ async execute(toolName: string, args: Record): Promise { try { - switch (toolName) { - case 'codegraph_search': - return await this.handleSearch(args); - case 'codegraph_context': - return await this.handleContext(args); - case 'codegraph_callers': - return await this.handleCallers(args); - case 'codegraph_callees': - return await this.handleCallees(args); - case 'codegraph_impact': - return await this.handleImpact(args); - case 'codegraph_explore': - return await this.handleExplore(args); - case 'codegraph_node': - return await this.handleNode(args); - case 'codegraph_status': - return await this.handleStatus(args); - case 'codegraph_files': - return await this.handleFiles(args); - default: - return this.errorResult(`Unknown tool: ${toolName}`); - } + const mod = getToolModule(toolName); + if (!mod) return this.errorResult(`Unknown tool: ${toolName}`); + // `implements ToolHandlerLike` makes this lookup type-safe: + // `mod.handlerKey` is constrained to `HandlerKey`, and every + // member of that union maps to an `(args) => Promise` + // method on `this` (verified at compile time, not at runtime). + return await this[mod.handlerKey](args); } catch (err) { return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`); } @@ -438,7 +207,7 @@ export class ToolHandler { /** * Handle codegraph_search */ - private async handleSearch(args: Record): Promise { + async handleSearch(args: Record): Promise { const query = this.validateString(args.query, 'query'); if (typeof query !== 'string') return query; @@ -463,7 +232,7 @@ export class ToolHandler { /** * Handle codegraph_context */ - private async handleContext(args: Record): Promise { + async handleContext(args: Record): Promise { const task = this.validateString(args.task, 'task'); if (typeof task !== 'string') return task; @@ -529,7 +298,7 @@ export class ToolHandler { /** * Handle codegraph_callers */ - private async handleCallers(args: Record): Promise { + async handleCallers(args: Record): Promise { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; @@ -564,7 +333,7 @@ export class ToolHandler { /** * Handle codegraph_callees */ - private async handleCallees(args: Record): Promise { + async handleCallees(args: Record): Promise { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; @@ -599,7 +368,7 @@ export class ToolHandler { /** * Handle codegraph_impact */ - private async handleImpact(args: Record): Promise { + async handleImpact(args: Record): Promise { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; @@ -650,7 +419,7 @@ export class ToolHandler { * then read contiguous file sections covering all symbols per file. * This replaces multiple codegraph_node + Read calls. */ - private async handleExplore(args: Record): Promise { + async handleExplore(args: Record): Promise { const query = this.validateString(args.query, 'query'); if (typeof query !== 'string') return query; @@ -936,7 +705,7 @@ export class ToolHandler { /** * Handle codegraph_node */ - private async handleNode(args: Record): Promise { + async handleNode(args: Record): Promise { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; @@ -962,7 +731,7 @@ export class ToolHandler { /** * Handle codegraph_status */ - private async handleStatus(args: Record): Promise { + async handleStatus(args: Record): Promise { const cg = this.getCodeGraph(args.projectPath as string | undefined); const stats = cg.getStats(); @@ -996,7 +765,7 @@ export class ToolHandler { /** * Handle codegraph_files - get project file structure from the index */ - private async handleFiles(args: Record): Promise { + async handleFiles(args: Record): Promise { const cg = this.getCodeGraph(args.projectPath as string | undefined); const pathFilter = args.path as string | undefined; const pattern = args.pattern as string | undefined; @@ -1364,13 +1133,13 @@ export class ToolHandler { return context.summary || 'No context found'; } - private textResult(text: string): ToolResult { + textResult(text: string): ToolResult { return { content: [{ type: 'text', text }], }; } - private errorResult(message: string): ToolResult { + errorResult(message: string): ToolResult { return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, diff --git a/src/mcp/tools/callees.ts b/src/mcp/tools/callees.ts new file mode 100644 index 00000000..3c0d9740 --- /dev/null +++ b/src/mcp/tools/callees.ts @@ -0,0 +1,27 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const CALLEES_TOOL: ToolModule = { + definition: { + name: 'codegraph_callees', + description: + 'Find all functions/methods that a specific symbol calls. Useful for understanding dependencies and code flow.', + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'Name of the function, method, or class to find callees for', + }, + limit: { + type: 'number', + description: 'Maximum number of callees to return (default: 20)', + default: 20, + }, + projectPath: projectPathProperty, + }, + required: ['symbol'], + }, + }, + handlerKey: 'handleCallees', +}; diff --git a/src/mcp/tools/callers.ts b/src/mcp/tools/callers.ts new file mode 100644 index 00000000..a5d33912 --- /dev/null +++ b/src/mcp/tools/callers.ts @@ -0,0 +1,27 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const CALLERS_TOOL: ToolModule = { + definition: { + name: 'codegraph_callers', + description: + 'Find all functions/methods that call a specific symbol. Useful for understanding usage patterns and impact of changes.', + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'Name of the function, method, or class to find callers for', + }, + limit: { + type: 'number', + description: 'Maximum number of callers to return (default: 20)', + default: 20, + }, + projectPath: projectPathProperty, + }, + required: ['symbol'], + }, + }, + handlerKey: 'handleCallers', +}; diff --git a/src/mcp/tools/context.ts b/src/mcp/tools/context.ts new file mode 100644 index 00000000..e8618671 --- /dev/null +++ b/src/mcp/tools/context.ts @@ -0,0 +1,32 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const CONTEXT_TOOL: ToolModule = { + definition: { + name: 'codegraph_context', + description: + 'PRIMARY TOOL: Build comprehensive context for a task. Returns entry points, related symbols, and key code - often enough to understand the codebase without additional tool calls. NOTE: This provides CODE context, not product requirements. For new features, still clarify UX/behavior questions with the user before implementing.', + inputSchema: { + type: 'object', + properties: { + task: { + type: 'string', + description: 'Description of the task, bug, or feature to build context for', + }, + maxNodes: { + type: 'number', + description: 'Maximum symbols to include (default: 20)', + default: 20, + }, + includeCode: { + type: 'boolean', + description: 'Include code snippets for key symbols (default: true)', + default: true, + }, + projectPath: projectPathProperty, + }, + required: ['task'], + }, + }, + handlerKey: 'handleContext', +}; diff --git a/src/mcp/tools/explore.ts b/src/mcp/tools/explore.ts new file mode 100644 index 00000000..d61b24e9 --- /dev/null +++ b/src/mcp/tools/explore.ts @@ -0,0 +1,28 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const EXPLORE_TOOL: ToolModule = { + definition: { + name: 'codegraph_explore', + description: + 'Deep exploration tool — returns comprehensive context for a topic in a SINGLE call. Groups all relevant source code by file (contiguous sections, not snippets), includes a relationship map, and uses deeper graph traversal. Designed to replace multiple codegraph_node + file Read calls. Use this instead of codegraph_context when you need thorough understanding. IMPORTANT: Use specific symbol names, file names, or short code terms in your query — NOT natural language sentences. Before calling this, use codegraph_search to discover relevant symbol names, then include those names in your query. Bad: "how are agent prompts loaded and passed to the CLI". Good: "readAgentsFromDirectory createClaudeSession chat-manager agents.ts".', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.', + }, + maxFiles: { + type: 'number', + description: 'Maximum number of files to include source code from (default: 12)', + default: 12, + }, + projectPath: projectPathProperty, + }, + required: ['query'], + }, + }, + handlerKey: 'handleExplore', +}; diff --git a/src/mcp/tools/files.ts b/src/mcp/tools/files.ts new file mode 100644 index 00000000..117b0676 --- /dev/null +++ b/src/mcp/tools/files.ts @@ -0,0 +1,40 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const FILES_TOOL: ToolModule = { + definition: { + name: 'codegraph_files', + description: + 'REQUIRED for file/folder exploration. Get the project file structure from the CodeGraph index. Returns a tree view of all indexed files with metadata (language, symbol count). Much faster than Glob/filesystem scanning. Use this FIRST when exploring project structure, finding files, or understanding codebase organization.', + inputSchema: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'Filter to files under this directory path (e.g., "src/components"). Returns all files if not specified.', + }, + pattern: { + type: 'string', + description: 'Filter files matching this glob pattern (e.g., "*.tsx", "**/*.test.ts")', + }, + format: { + type: 'string', + description: 'Output format: "tree" (hierarchical, default), "flat" (simple list), "grouped" (by language)', + enum: ['tree', 'flat', 'grouped'], + default: 'tree', + }, + includeMetadata: { + type: 'boolean', + description: 'Include file metadata like language and symbol count (default: true)', + default: true, + }, + maxDepth: { + type: 'number', + description: 'Maximum directory depth to show (default: unlimited)', + }, + projectPath: projectPathProperty, + }, + }, + }, + handlerKey: 'handleFiles', +}; diff --git a/src/mcp/tools/impact.ts b/src/mcp/tools/impact.ts new file mode 100644 index 00000000..45386e6b --- /dev/null +++ b/src/mcp/tools/impact.ts @@ -0,0 +1,27 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const IMPACT_TOOL: ToolModule = { + definition: { + name: 'codegraph_impact', + description: + 'Analyze the impact radius of changing a symbol. Shows what code could be affected by modifications.', + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'Name of the symbol to analyze impact for', + }, + depth: { + type: 'number', + description: 'How many levels of dependencies to traverse (default: 2)', + default: 2, + }, + projectPath: projectPathProperty, + }, + required: ['symbol'], + }, + }, + handlerKey: 'handleImpact', +}; diff --git a/src/mcp/tools/node.ts b/src/mcp/tools/node.ts new file mode 100644 index 00000000..fe61b254 --- /dev/null +++ b/src/mcp/tools/node.ts @@ -0,0 +1,27 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const NODE_TOOL: ToolModule = { + definition: { + name: 'codegraph_node', + description: + 'Get detailed information about a specific code symbol. Use includeCode=true only when you need the full source code - otherwise just get location and signature to minimize context usage.', + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'Name of the symbol to get details for', + }, + includeCode: { + type: 'boolean', + description: 'Include full source code (default: false to minimize context)', + default: false, + }, + projectPath: projectPathProperty, + }, + required: ['symbol'], + }, + }, + handlerKey: 'handleNode', +}; diff --git a/src/mcp/tools/registry.ts b/src/mcp/tools/registry.ts new file mode 100644 index 00000000..3219f88d --- /dev/null +++ b/src/mcp/tools/registry.ts @@ -0,0 +1,65 @@ +/** + * MCP tool registry. + * + * Adding a new MCP tool is: + * + * 1. Create `src/mcp/tools/.ts` exporting an + * `_TOOL: ToolModule` constant (definition + handlerKey). + * 2. Add **one** import line and **one** array entry to this file. + * 3. Add a `handle` method on `ToolHandler` in `../tools.ts`, + * and add the new key to `HandlerKey` in `./types.ts`. + * + * The third step is currently the only "shared method on a single + * class" surface that competing PRs can collide on. Extracting + * handler bodies into per-tool files (so step 3 also becomes a + * single-file addition) is left as a follow-up. + */ + +import type { ToolDefinition } from '../tool-types'; +import type { ToolModule } from './types'; + +import { CALLEES_TOOL } from './callees'; +import { CALLERS_TOOL } from './callers'; +import { CONTEXT_TOOL } from './context'; +import { EXPLORE_TOOL } from './explore'; +import { FILES_TOOL } from './files'; +import { IMPACT_TOOL } from './impact'; +import { NODE_TOOL } from './node'; +import { SEARCH_TOOL } from './search'; +import { STATUS_TOOL } from './status'; + +const ALL_TOOLS: readonly ToolModule[] = [ + CALLEES_TOOL, + CALLERS_TOOL, + CONTEXT_TOOL, + EXPLORE_TOOL, + FILES_TOOL, + IMPACT_TOOL, + NODE_TOOL, + SEARCH_TOOL, + STATUS_TOOL, +]; + +let byName: Map | null = null; +function ensureIndex(): Map { + if (byName) return byName; + byName = new Map(); + for (const t of ALL_TOOLS) byName.set(t.definition.name, t); + return byName; +} + +export function getToolModules(): readonly ToolModule[] { + return ALL_TOOLS; +} + +export function getToolModule(name: string): ToolModule | undefined { + return ensureIndex().get(name); +} + +/** + * The `tools[]` array advertised in MCP `list_tools`. Derived from + * the registry; sorted alphabetically by tool name for stable output. + */ +export const tools: readonly ToolDefinition[] = ALL_TOOLS + .map((t) => t.definition) + .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/src/mcp/tools/search.ts b/src/mcp/tools/search.ts new file mode 100644 index 00000000..c6678333 --- /dev/null +++ b/src/mcp/tools/search.ts @@ -0,0 +1,32 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const SEARCH_TOOL: ToolModule = { + definition: { + name: 'codegraph_search', + description: + 'Quick symbol search by name. Returns locations only (no code). Use codegraph_context instead for comprehensive task context.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Symbol name or partial name (e.g., "auth", "signIn", "UserService")', + }, + kind: { + type: 'string', + description: 'Filter by node kind', + enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'], + }, + limit: { + type: 'number', + description: 'Maximum results (default: 10)', + default: 10, + }, + projectPath: projectPathProperty, + }, + required: ['query'], + }, + }, + handlerKey: 'handleSearch', +}; diff --git a/src/mcp/tools/status.ts b/src/mcp/tools/status.ts new file mode 100644 index 00000000..84bebcc3 --- /dev/null +++ b/src/mcp/tools/status.ts @@ -0,0 +1,17 @@ +import { projectPathProperty } from '../tool-types'; +import type { ToolModule } from './types'; + +export const STATUS_TOOL: ToolModule = { + definition: { + name: 'codegraph_status', + description: + 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.', + inputSchema: { + type: 'object', + properties: { + projectPath: projectPathProperty, + }, + }, + }, + handlerKey: 'handleStatus', +}; diff --git a/src/mcp/tools/types.ts b/src/mcp/tools/types.ts new file mode 100644 index 00000000..6741d965 --- /dev/null +++ b/src/mcp/tools/types.ts @@ -0,0 +1,50 @@ +/** + * MCP tool registry types. + * + * Each tool ships its own self-contained `ToolModule` (definition + * + handler-key reference) so adding an MCP tool is a single-file + * addition for the metadata and dispatch entry. The actual handler + * bodies still live as methods on the `ToolHandler` class in + * `../tools.ts` (the helpers they call are tightly coupled and a + * full body extraction is left as a follow-up); each tool's + * `handlerKey` is the string name of the method to invoke. + * + * The registry (`./registry`) imports each module and exposes + * `tools[]` (for `list_tools`) plus a `getModule(name)` lookup + * used by `ToolHandler.execute`. + */ + +import type { ToolDefinition, ToolResult } from '../tool-types'; + +/** + * Names of methods on `ToolHandler` that can serve as tool handlers. + * Kept as a string union (not a `keyof ToolHandler` lookup) to + * avoid a circular import — the type list is the source of truth + * and is checked structurally at the call site in `execute()`. + */ +export type HandlerKey = + | 'handleSearch' + | 'handleContext' + | 'handleCallers' + | 'handleCallees' + | 'handleImpact' + | 'handleExplore' + | 'handleNode' + | 'handleStatus' + | 'handleFiles'; + +/** + * The minimum surface a `ToolHandler`-shaped object exposes for + * dispatch. Extending `HandlerKey` adds a new entry here too. + */ +export type ToolHandlerLike = { + [K in HandlerKey]: (args: Record) => Promise; +} & { + errorResult(message: string): ToolResult; +}; + +export interface ToolModule { + readonly definition: ToolDefinition; + /** Method name on `ToolHandler` that runs this tool. */ + readonly handlerKey: HandlerKey; +}