Files
codegraph/src/mcp/index.ts
T
Colby McHenry b964d5909a fix: Stale lock recovery and MCP init retry
Fixes #47 — "database is locked" after crash and MCP "not initialized"
when project IS initialized.

- FileLock: treat locks older than 10 minutes as stale regardless of PID
  status, covering cases where PID was reused or kill signal check fails
- MCP server: log errors from tryInitializeDefault() to stderr instead of
  silently swallowing, so transient open failures are diagnosable
- MCP server: retryInitIfNeeded() properly cleans up failed instances
  before retrying, preventing resource leaks
- CLI: add 'codegraph unlock' command for manual lock file removal
2026-03-18 14:50:25 -05:00

312 lines
8.7 KiB
TypeScript

/**
* CodeGraph MCP Server
*
* Model Context Protocol server that exposes CodeGraph functionality
* as tools for AI assistants like Claude.
*
* @module mcp
*
* @example
* ```typescript
* import { MCPServer } from 'codegraph';
*
* const server = new MCPServer('/path/to/project');
* await server.start();
* ```
*/
import * as path from 'path';
import CodeGraph, { findNearestCodeGraphRoot } from '../index';
import { StdioTransport, JsonRpcRequest, JsonRpcNotification, ErrorCodes } from './transport';
import { tools, ToolHandler } from './tools';
import { initSentry, captureException } from '../sentry';
initSentry({ processName: 'codegraph-mcp' });
/**
* Convert a file:// URI to a filesystem path.
* Handles URL encoding and Windows drive letter paths.
*/
function fileUriToPath(uri: string): string {
try {
const url = new URL(uri);
let filePath = decodeURIComponent(url.pathname);
// On Windows, file:///C:/path produces pathname /C:/path — strip leading /
if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(filePath)) {
filePath = filePath.slice(1);
}
return path.resolve(filePath);
} catch {
// Fallback for non-standard URIs
return uri.replace(/^file:\/\/\/?/, '');
}
}
/**
* MCP Server Info
*/
const SERVER_INFO = {
name: 'codegraph',
version: '0.1.0',
};
/**
* MCP Protocol Version
*/
const PROTOCOL_VERSION = '2024-11-05';
/**
* MCP Server for CodeGraph
*
* Implements the Model Context Protocol to expose CodeGraph
* functionality as tools that can be called by AI assistants.
*/
export class MCPServer {
private transport: StdioTransport;
private cg: CodeGraph | null = null;
private toolHandler: ToolHandler;
private projectPath: string | null;
constructor(projectPath?: string) {
this.projectPath = projectPath || null;
this.transport = new StdioTransport();
// Create ToolHandler eagerly — cross-project queries work even without a default project
this.toolHandler = new ToolHandler(null);
}
/**
* Start the MCP server
*
* Note: CodeGraph initialization is deferred until the initialize request
* is received, which includes the rootUri from the client.
*/
async start(): Promise<void> {
// Start listening for messages immediately - don't check initialization yet
// We'll get the project path from the initialize request's rootUri
this.transport.start(this.handleMessage.bind(this));
// Keep the process running
process.on('SIGINT', () => this.stop());
process.on('SIGTERM', () => this.stop());
// When the parent process (Claude Code) exits, stdin closes.
// Detect this and shut down gracefully to prevent orphaned processes.
process.stdin.on('end', () => this.stop());
process.stdin.on('close', () => this.stop());
}
/**
* Try to initialize CodeGraph for the default project.
*
* Walks up parent directories to find the nearest .codegraph/ folder,
* similar to how git finds .git/ directories.
*
* If initialization fails, the error is recorded but the server continues
* to work — cross-project queries and retries on subsequent tool calls
* are still possible.
*/
private async tryInitializeDefault(projectPath: string): Promise<void> {
// Walk up parent directories to find nearest .codegraph/
const resolvedRoot = findNearestCodeGraphRoot(projectPath);
if (!resolvedRoot) {
this.projectPath = projectPath;
return;
}
this.projectPath = resolvedRoot;
try {
this.cg = await CodeGraph.open(resolvedRoot);
this.toolHandler.setDefaultCodeGraph(this.cg);
} catch (err) {
// Log the error so transient failures are diagnosable (see issue #47)
captureException(err);
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[CodeGraph MCP] Failed to open project at ${resolvedRoot}: ${msg}\n`);
}
}
/**
* Retry initialization of the default project if it previously failed.
* Called lazily on tool calls that need the default project.
* Re-walks parent directories each time so it picks up projects
* initialized after the MCP server started.
*/
private retryInitIfNeeded(): void {
// Already initialized successfully
if (this.toolHandler.hasDefaultCodeGraph()) return;
// No project path to retry with
if (!this.projectPath) return;
const resolvedRoot = findNearestCodeGraphRoot(this.projectPath);
if (!resolvedRoot) return;
try {
// Close any previously failed instance to avoid leaking resources
if (this.cg) {
try { this.cg.close(); } catch { /* ignore */ }
this.cg = null;
}
this.cg = CodeGraph.openSync(resolvedRoot);
this.projectPath = resolvedRoot;
this.toolHandler.setDefaultCodeGraph(this.cg);
} catch {
// Still failing — will retry on next tool call
}
}
/**
* Stop the server
*/
stop(): void {
// Close all cached cross-project connections first
this.toolHandler.closeAll();
// Close the main CodeGraph instance
if (this.cg) {
this.cg.close();
this.cg = null;
}
this.transport.stop();
process.exit(0);
}
/**
* Handle incoming JSON-RPC messages
*/
private async handleMessage(message: JsonRpcRequest | JsonRpcNotification): Promise<void> {
// Check if it's a request (has id) or notification (no id)
const isRequest = 'id' in message;
switch (message.method) {
case 'initialize':
if (isRequest) {
await this.handleInitialize(message as JsonRpcRequest);
}
break;
case 'initialized':
// Notification that client has finished initialization
// No action needed - the client is ready
break;
case 'tools/list':
if (isRequest) {
await this.handleToolsList(message as JsonRpcRequest);
}
break;
case 'tools/call':
if (isRequest) {
await this.handleToolsCall(message as JsonRpcRequest);
}
break;
case 'ping':
if (isRequest) {
this.transport.sendResult((message as JsonRpcRequest).id, {});
}
break;
default:
if (isRequest) {
this.transport.sendError(
(message as JsonRpcRequest).id,
ErrorCodes.MethodNotFound,
`Method not found: ${message.method}`
);
}
}
}
/**
* Handle initialize request
*/
private async handleInitialize(request: JsonRpcRequest): Promise<void> {
const params = request.params as {
rootUri?: string;
workspaceFolders?: Array<{ uri: string; name: string }>;
} | undefined;
// Extract project path from rootUri or workspaceFolders
let projectPath = this.projectPath;
if (params?.rootUri) {
projectPath = fileUriToPath(params.rootUri);
} else if (params?.workspaceFolders?.[0]?.uri) {
projectPath = fileUriToPath(params.workspaceFolders[0].uri);
}
// Fall back to current working directory if no path provided
if (!projectPath) {
projectPath = process.cwd();
}
// Try to initialize the default project (non-fatal if it fails)
await this.tryInitializeDefault(projectPath);
// We accept the client's protocol version but respond with our supported version
this.transport.sendResult(request.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
tools: {},
},
serverInfo: SERVER_INFO,
});
}
/**
* Handle tools/list request
*/
private async handleToolsList(request: JsonRpcRequest): Promise<void> {
this.transport.sendResult(request.id, {
tools: tools,
});
}
/**
* Handle tools/call request
*/
private async handleToolsCall(request: JsonRpcRequest): Promise<void> {
const params = request.params as {
name: string;
arguments?: Record<string, unknown>;
};
if (!params || !params.name) {
this.transport.sendError(
request.id,
ErrorCodes.InvalidParams,
'Missing tool name'
);
return;
}
const toolName = params.name;
const toolArgs = params.arguments || {};
// Validate tool exists
const tool = tools.find(t => t.name === toolName);
if (!tool) {
this.transport.sendError(
request.id,
ErrorCodes.InvalidParams,
`Unknown tool: ${toolName}`
);
return;
}
// If the default project isn't initialized yet, retry in case it was
// initialized after the MCP server started (e.g. user ran codegraph init)
this.retryInitIfNeeded();
const result = await this.toolHandler.execute(toolName, toolArgs);
this.transport.sendResult(request.id, result);
}
}
// Export for use in CLI
export { StdioTransport } from './transport';
export { tools, ToolHandler } from './tools';