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
140 changes: 140 additions & 0 deletions __tests__/mcp-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* codegraph_status output: project-root surfacing + multi-project list.
*
* Regression guard for the friction point where an agent calling MCP
* tools couldn't tell which project the server's default points at,
* so couldn't tell whether to start passing `projectPath` on later
* calls. status's first line must always answer that.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { ToolHandler } from '../src/mcp/tools';

function tempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'cg-mcp-status-'));
}

function cleanup(dir: string): void {
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
}

async function makeProject(dir: string, file: string): Promise<CodeGraph> {
fs.mkdirSync(path.join(dir, 'src'));
fs.writeFileSync(
path.join(dir, 'src', file),
`export function f(): number { return 1; }\n`
);
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: path.basename(dir), version: '0.0.0' })
);
const cg = await CodeGraph.init(dir, { config: { llm: { endpoint: '' } } });
await cg.indexAll({ summarize: false });
return cg;
}

describe('codegraph_status β€” project-root surfacing', () => {
let dirs: string[] = [];
let cgs: CodeGraph[] = [];
let handler: ToolHandler | null = null;

beforeEach(() => {
dirs = [];
cgs = [];
handler = null;
});

afterEach(() => {
// Close any cached projects the handler opened β€” otherwise we
// leak SQLite handles + leave WAL files in tmp.
handler?.closeAll();
for (const cg of cgs) {
try {
cg.close();
} catch {
/* idempotent β€” already closed by closeAll() */
}
}
for (const d of dirs) cleanup(d);
});

it('shows the project root labelled as "default" when the server has a default and projectPath is omitted', async () => {
const dir = tempDir();
dirs.push(dir);
const cg = await makeProject(dir, 'a.ts');
cgs.push(cg);

handler = new ToolHandler(cg);
const result = await handler.execute('codegraph_status', {});
const text = result.content[0]?.text ?? '';

expect(text).toMatch(new RegExp(`Project root.*\`${path.resolve(dir)}\``));
expect(text).toMatch(/default/);
expect(text).toMatch(/server CWD at startup/);
});

it('shows the project root labelled as "from `projectPath`" when projectPath is supplied', async () => {
const defaultDir = tempDir();
const otherDir = tempDir();
dirs.push(defaultDir, otherDir);
const defaultCg = await makeProject(defaultDir, 'a.ts');
cgs.push(defaultCg);
// Initialize the second project's .codegraph/ but don't keep our
// own handle β€” the ToolHandler will open it via projectPath and
// own its lifecycle through projectCache.
const tmpCg = await makeProject(otherDir, 'b.ts');
tmpCg.close();

handler = new ToolHandler(defaultCg);
const result = await handler.execute('codegraph_status', { projectPath: otherDir });
const text = result.content[0]?.text ?? '';

expect(text).toMatch(new RegExp(`Project root.*\`${path.resolve(otherDir)}\``));
expect(text).toMatch(/from `projectPath` argument/);
});

it('lists other projects the server has open under "Other projects this server has open"', async () => {
const defaultDir = tempDir();
const otherDir = tempDir();
dirs.push(defaultDir, otherDir);
const defaultCg = await makeProject(defaultDir, 'a.ts');
cgs.push(defaultCg);
const tmpCg = await makeProject(otherDir, 'b.ts');
tmpCg.close();

handler = new ToolHandler(defaultCg);
// Prime the cache with the second project.
await handler.execute('codegraph_status', { projectPath: otherDir });
// Now query the default β€” it should mention the other project.
const result = await handler.execute('codegraph_status', {});
const text = result.content[0]?.text ?? '';

expect(text).toMatch(/### Other projects this server has open/);
expect(text).toContain(path.resolve(otherDir));
});

it('throws an actionable error suggesting `projectPath` when no default and projectPath omitted', async () => {
handler = new ToolHandler(null);
const result = await handler.execute('codegraph_status', {});
expect(result.isError).toBe(true);
const text = result.content[0]?.text ?? '';
expect(text).toMatch(/No default codegraph project/);
expect(text).toMatch(/projectPath/);
expect(text).toMatch(/codegraph init/);
});

it('throws an actionable error pointing at the supplied path when projectPath has no .codegraph/', async () => {
const dir = tempDir();
dirs.push(dir);
// Note: we deliberately did NOT call makeProject β€” there's no .codegraph/.
handler = new ToolHandler(null);
const result = await handler.execute('codegraph_status', { projectPath: dir });
expect(result.isError).toBe(true);
const text = result.content[0]?.text ?? '';
expect(text).toMatch(/No \.codegraph\/ found/);
expect(text).toContain(dir);
});
});
82 changes: 82 additions & 0 deletions __tests__/mcp-tool-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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 β€” the message
// should point the agent at `projectPath` as a remediation since
// the MCP server has no default project.
expect(result.content[0]?.text).toMatch(/No default codegraph project/);
expect(result.content[0]?.text).toMatch(/projectPath/);
});
});
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