Init
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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 CodeGraph from '../index';
|
||||
import { StdioTransport, JsonRpcRequest, JsonRpcNotification, ErrorCodes } from './transport';
|
||||
import { tools, ToolHandler } from './tools';
|
||||
|
||||
/**
|
||||
* 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 | null = null;
|
||||
private projectPath: string;
|
||||
|
||||
constructor(projectPath: string) {
|
||||
this.projectPath = projectPath;
|
||||
this.transport = new StdioTransport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the MCP server
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Open CodeGraph for the project
|
||||
if (!CodeGraph.isInitialized(this.projectPath)) {
|
||||
throw new Error(`CodeGraph not initialized in ${this.projectPath}. Run 'codegraph init' first.`);
|
||||
}
|
||||
|
||||
this.cg = await CodeGraph.open(this.projectPath);
|
||||
this.toolHandler = new ToolHandler(this.cg);
|
||||
|
||||
// Start listening for messages
|
||||
this.transport.start(this.handleMessage.bind(this));
|
||||
|
||||
// Keep the process running
|
||||
process.on('SIGINT', () => this.stop());
|
||||
process.on('SIGTERM', () => this.stop());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
*/
|
||||
stop(): void {
|
||||
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> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
if (!this.toolHandler) {
|
||||
this.transport.sendError(
|
||||
request.id,
|
||||
ErrorCodes.InternalError,
|
||||
'Server not initialized'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* MCP Tool Definitions
|
||||
*
|
||||
* Defines the tools exposed by the CodeGraph MCP server.
|
||||
*/
|
||||
|
||||
import CodeGraph from '../index';
|
||||
import type { Node, SearchResult, Subgraph, TaskContext, NodeKind } from '../types';
|
||||
|
||||
/**
|
||||
* MCP Tool definition
|
||||
*/
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PropertySchema>;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* All CodeGraph MCP tools
|
||||
*/
|
||||
export const tools: ToolDefinition[] = [
|
||||
{
|
||||
name: 'codegraph_search',
|
||||
description: 'Search for code symbols (functions, classes, methods) by name or semantic similarity. Returns matching nodes with their locations and signatures.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query - can be a symbol name or natural language description',
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
description: 'Filter by node kind',
|
||||
enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'],
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return (default: 10)',
|
||||
default: 10,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'codegraph_context',
|
||||
description: 'Build relevant code context for a task or issue. Finds related symbols and their code, formatted for understanding the codebase.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: {
|
||||
type: 'string',
|
||||
description: 'Description of the task, bug, or feature to build context for',
|
||||
},
|
||||
maxNodes: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of code symbols to include (default: 20)',
|
||||
default: 20,
|
||||
},
|
||||
includeCode: {
|
||||
type: 'boolean',
|
||||
description: 'Include full code snippets (default: true)',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
required: ['symbol'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'codegraph_node',
|
||||
description: 'Get detailed information about a specific code symbol, including its full code.',
|
||||
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: true)',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
required: ['symbol'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'codegraph_status',
|
||||
description: 'Get the status of the CodeGraph index, including statistics about indexed files, nodes, and edges.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Tool handler that executes tools against a CodeGraph instance
|
||||
*/
|
||||
export class ToolHandler {
|
||||
constructor(private cg: CodeGraph) {}
|
||||
|
||||
/**
|
||||
* Execute a tool by name
|
||||
*/
|
||||
async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
|
||||
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_node':
|
||||
return await this.handleNode(args);
|
||||
case 'codegraph_status':
|
||||
return await this.handleStatus();
|
||||
default:
|
||||
return this.errorResult(`Unknown tool: ${toolName}`);
|
||||
}
|
||||
} catch (err) {
|
||||
return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle codegraph_search
|
||||
*/
|
||||
private async handleSearch(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const query = args.query as string;
|
||||
const kind = args.kind as string | undefined;
|
||||
const limit = (args.limit as number) || 10;
|
||||
|
||||
const results = this.cg.searchNodes(query, {
|
||||
limit,
|
||||
kinds: kind ? [kind as NodeKind] : undefined,
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
return this.textResult(`No results found for "${query}"`);
|
||||
}
|
||||
|
||||
const formatted = this.formatSearchResults(results);
|
||||
return this.textResult(formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle codegraph_context
|
||||
*/
|
||||
private async handleContext(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const task = args.task as string;
|
||||
const maxNodes = (args.maxNodes as number) || 20;
|
||||
const includeCode = args.includeCode !== false;
|
||||
|
||||
const context = await this.cg.buildContext(task, {
|
||||
maxNodes,
|
||||
includeCode,
|
||||
format: 'markdown',
|
||||
});
|
||||
|
||||
// buildContext returns string when format is 'markdown'
|
||||
if (typeof context === 'string') {
|
||||
return this.textResult(context);
|
||||
}
|
||||
|
||||
// If it returns TaskContext, format it
|
||||
return this.textResult(this.formatTaskContext(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle codegraph_callers
|
||||
*/
|
||||
private async handleCallers(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const symbol = args.symbol as string;
|
||||
const limit = (args.limit as number) || 20;
|
||||
|
||||
// First find the node by name
|
||||
const results = this.cg.searchNodes(symbol, { limit: 1 });
|
||||
if (results.length === 0 || !results[0]) {
|
||||
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
||||
}
|
||||
|
||||
const node = results[0].node;
|
||||
const callers = this.cg.getCallers(node.id);
|
||||
|
||||
if (callers.length === 0) {
|
||||
return this.textResult(`No callers found for "${symbol}"`);
|
||||
}
|
||||
|
||||
// Extract just the nodes from the { node, edge } tuples
|
||||
const callerNodes = callers.slice(0, limit).map(c => c.node);
|
||||
const formatted = this.formatNodeList(callerNodes, `Callers of ${symbol}`);
|
||||
return this.textResult(formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle codegraph_callees
|
||||
*/
|
||||
private async handleCallees(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const symbol = args.symbol as string;
|
||||
const limit = (args.limit as number) || 20;
|
||||
|
||||
// First find the node by name
|
||||
const results = this.cg.searchNodes(symbol, { limit: 1 });
|
||||
if (results.length === 0 || !results[0]) {
|
||||
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
||||
}
|
||||
|
||||
const node = results[0].node;
|
||||
const callees = this.cg.getCallees(node.id);
|
||||
|
||||
if (callees.length === 0) {
|
||||
return this.textResult(`No callees found for "${symbol}"`);
|
||||
}
|
||||
|
||||
// Extract just the nodes from the { node, edge } tuples
|
||||
const calleeNodes = callees.slice(0, limit).map(c => c.node);
|
||||
const formatted = this.formatNodeList(calleeNodes, `Callees of ${symbol}`);
|
||||
return this.textResult(formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle codegraph_impact
|
||||
*/
|
||||
private async handleImpact(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const symbol = args.symbol as string;
|
||||
const depth = (args.depth as number) || 2;
|
||||
|
||||
// First find the node by name
|
||||
const results = this.cg.searchNodes(symbol, { limit: 1 });
|
||||
if (results.length === 0 || !results[0]) {
|
||||
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
||||
}
|
||||
|
||||
const node = results[0].node;
|
||||
const impact = this.cg.getImpactRadius(node.id, depth);
|
||||
|
||||
const formatted = this.formatImpact(symbol, impact);
|
||||
return this.textResult(formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle codegraph_node
|
||||
*/
|
||||
private async handleNode(args: Record<string, unknown>): Promise<ToolResult> {
|
||||
const symbol = args.symbol as string;
|
||||
const includeCode = args.includeCode !== false;
|
||||
|
||||
// Find the node by name
|
||||
const results = this.cg.searchNodes(symbol, { limit: 1 });
|
||||
if (results.length === 0 || !results[0]) {
|
||||
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
||||
}
|
||||
|
||||
const node = results[0].node;
|
||||
let code: string | null = null;
|
||||
|
||||
if (includeCode) {
|
||||
code = await this.cg.getCode(node.id);
|
||||
}
|
||||
|
||||
const formatted = this.formatNodeDetails(node, code);
|
||||
return this.textResult(formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle codegraph_status
|
||||
*/
|
||||
private async handleStatus(): Promise<ToolResult> {
|
||||
const stats = this.cg.getStats();
|
||||
|
||||
const lines: string[] = [
|
||||
'## CodeGraph Status',
|
||||
'',
|
||||
`**Files indexed:** ${stats.fileCount}`,
|
||||
`**Total nodes:** ${stats.nodeCount}`,
|
||||
`**Total edges:** ${stats.edgeCount}`,
|
||||
`**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
'',
|
||||
'### Nodes by Kind:',
|
||||
];
|
||||
|
||||
for (const [kind, count] of Object.entries(stats.nodesByKind)) {
|
||||
if ((count as number) > 0) {
|
||||
lines.push(`- ${kind}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('', '### Languages:');
|
||||
for (const [lang, count] of Object.entries(stats.filesByLanguage)) {
|
||||
if ((count as number) > 0) {
|
||||
lines.push(`- ${lang}: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.textResult(lines.join('\n'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Formatting helpers
|
||||
// =========================================================================
|
||||
|
||||
private formatSearchResults(results: SearchResult[]): string {
|
||||
const lines: string[] = [`## Search Results (${results.length} found)`, ''];
|
||||
|
||||
for (const result of results) {
|
||||
const { node, score } = result;
|
||||
const location = node.startLine ? `:${node.startLine}` : '';
|
||||
lines.push(`### ${node.name} (${node.kind})`);
|
||||
lines.push(`**Location:** ${node.filePath}${location}`);
|
||||
lines.push(`**Score:** ${Math.round(score * 100)}%`);
|
||||
if (node.signature) lines.push(`**Signature:** ${node.signature}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private formatNodeList(nodes: Node[], title: string): string {
|
||||
const lines: string[] = [`## ${title} (${nodes.length} found)`, ''];
|
||||
|
||||
for (const node of nodes) {
|
||||
const location = node.startLine ? `:${node.startLine}` : '';
|
||||
lines.push(`- **${node.name}** (${node.kind}) - ${node.filePath}${location}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private formatImpact(symbol: string, impact: Subgraph): string {
|
||||
const nodeCount = impact.nodes.size;
|
||||
const edgeCount = impact.edges.length;
|
||||
|
||||
const lines: string[] = [
|
||||
`## Impact Analysis for "${symbol}"`,
|
||||
'',
|
||||
`**Nodes affected:** ${nodeCount}`,
|
||||
`**Relationships:** ${edgeCount}`,
|
||||
'',
|
||||
'### Affected Symbols:',
|
||||
'',
|
||||
];
|
||||
|
||||
// Group by file
|
||||
const byFile = new Map<string, Node[]>();
|
||||
for (const node of impact.nodes.values()) {
|
||||
const existing = byFile.get(node.filePath) || [];
|
||||
existing.push(node);
|
||||
byFile.set(node.filePath, existing);
|
||||
}
|
||||
|
||||
for (const [file, nodes] of byFile) {
|
||||
lines.push(`**${file}:**`);
|
||||
for (const node of nodes) {
|
||||
const location = node.startLine ? `:${node.startLine}` : '';
|
||||
lines.push(` - ${node.name} (${node.kind})${location}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private formatNodeDetails(node: Node, code: string | null): string {
|
||||
const location = node.startLine ? `:${node.startLine}` : '';
|
||||
const lines: string[] = [
|
||||
`## ${node.name} (${node.kind})`,
|
||||
'',
|
||||
`**Location:** ${node.filePath}${location}`,
|
||||
`**Language:** ${node.language}`,
|
||||
];
|
||||
|
||||
if (node.signature) {
|
||||
lines.push(`**Signature:** ${node.signature}`);
|
||||
}
|
||||
|
||||
if (node.docstring) {
|
||||
lines.push('', '### Documentation:', '', node.docstring);
|
||||
}
|
||||
|
||||
if (code) {
|
||||
lines.push('', '### Code:', '', '```' + node.language, code, '```');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private formatTaskContext(context: TaskContext): string {
|
||||
return context.summary || 'No context found';
|
||||
}
|
||||
|
||||
private textResult(text: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
private errorResult(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* MCP Stdio Transport
|
||||
*
|
||||
* Handles JSON-RPC 2.0 communication over stdin/stdout for MCP protocol.
|
||||
*/
|
||||
|
||||
import * as readline from 'readline';
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Request
|
||||
*/
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Response
|
||||
*/
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number | null;
|
||||
result?: unknown;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Error
|
||||
*/
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC 2.0 Notification (no id, no response expected)
|
||||
*/
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: '2.0';
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
// Standard JSON-RPC error codes
|
||||
export const ErrorCodes = {
|
||||
ParseError: -32700,
|
||||
InvalidRequest: -32600,
|
||||
MethodNotFound: -32601,
|
||||
InvalidParams: -32602,
|
||||
InternalError: -32603,
|
||||
} as const;
|
||||
|
||||
export type MessageHandler = (message: JsonRpcRequest | JsonRpcNotification) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Stdio Transport for MCP
|
||||
*
|
||||
* Reads JSON-RPC messages from stdin and writes responses to stdout.
|
||||
*/
|
||||
export class StdioTransport {
|
||||
private rl: readline.Interface | null = null;
|
||||
private messageHandler: MessageHandler | null = null;
|
||||
|
||||
/**
|
||||
* Start listening for messages on stdin
|
||||
*/
|
||||
start(handler: MessageHandler): void {
|
||||
this.messageHandler = handler;
|
||||
|
||||
this.rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
this.rl.on('line', async (line) => {
|
||||
await this.handleLine(line);
|
||||
});
|
||||
|
||||
this.rl.on('close', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.rl) {
|
||||
this.rl.close();
|
||||
this.rl = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a response
|
||||
*/
|
||||
send(response: JsonRpcResponse): void {
|
||||
const json = JSON.stringify(response);
|
||||
process.stdout.write(json + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification (no id)
|
||||
*/
|
||||
notify(method: string, params?: unknown): void {
|
||||
const notification: JsonRpcNotification = {
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(notification) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a success response
|
||||
*/
|
||||
sendResult(id: string | number, result: unknown): void {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an error response
|
||||
*/
|
||||
sendError(id: string | number | null, code: number, message: string, data?: unknown): void {
|
||||
this.send({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message, data },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming line of JSON
|
||||
*/
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
this.sendError(null, ErrorCodes.ParseError, 'Parse error: invalid JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate basic JSON-RPC structure
|
||||
if (!this.isValidMessage(parsed)) {
|
||||
this.sendError(null, ErrorCodes.InvalidRequest, 'Invalid Request: not a valid JSON-RPC 2.0 message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.messageHandler) {
|
||||
try {
|
||||
await this.messageHandler(parsed as JsonRpcRequest | JsonRpcNotification);
|
||||
} catch (err) {
|
||||
const message = parsed as JsonRpcRequest;
|
||||
if ('id' in message) {
|
||||
this.sendError(
|
||||
message.id,
|
||||
ErrorCodes.InternalError,
|
||||
`Internal error: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if message is a valid JSON-RPC 2.0 message
|
||||
*/
|
||||
private isValidMessage(msg: unknown): boolean {
|
||||
if (typeof msg !== 'object' || msg === null) return false;
|
||||
const obj = msg as Record<string, unknown>;
|
||||
if (obj.jsonrpc !== '2.0') return false;
|
||||
if (typeof obj.method !== 'string') return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user