Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions __tests__/mcp-tool-registry.test.ts
Original file line number Diff line number Diff line change
@@ -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<Name>`
* 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<string, unknown>)[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/);
});
});
8 changes: 5 additions & 3 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions src/mcp/tool-types.ts
Original file line number Diff line number Diff line change
@@ -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<string, PropertySchema>;
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.',
};
Loading