UNPKG

@gork-labs/secondbrain-mcp

Version:

Second Brain MCP Server - Agent team orchestration with dynamic tool discovery

347 lines (346 loc) 14.5 kB
import * as fs from 'fs'; import { spawn } from 'child_process'; import { logger } from '../utils/logger.js'; import { config } from '../utils/config.js'; import { VSCodeToolProxy } from './vscode-tool-proxy.js'; /** * Simplified Tool Proxy - Routes tool calls to appropriate MCP servers * Uses simple heuristics and lets MCP servers handle validation */ export class ToolProxy { static instance; mcpConnections = new Map(); serverConfigs = new Map(); successfulRoutes = new Map(); // Cache successful tool→server mappings vsCodeToolProxy; constructor() { this.initializeFromConfig(); this.vsCodeToolProxy = VSCodeToolProxy.getInstance(); } static getInstance() { if (!ToolProxy.instance) { ToolProxy.instance = new ToolProxy(); } return ToolProxy.instance; } initializeFromConfig() { try { // Read MCP configuration from the specified path const mcpConfigContent = fs.readFileSync(config.mcpConfigPath, 'utf-8'); const mcpConfig = JSON.parse(mcpConfigContent); // Extract server configurations if (mcpConfig.servers) { for (const [serverName, serverConfig] of Object.entries(mcpConfig.servers)) { if (serverConfig.type === 'stdio') { this.serverConfigs.set(serverName, { command: serverConfig.command, args: serverConfig.args || [], env: serverConfig.env || {}, type: serverConfig.type }); } } } logger.info('Initialized MCP tool proxy', { serversConfigured: this.serverConfigs.size, configPath: config.mcpConfigPath }); } catch (error) { logger.error('Failed to initialize MCP tool proxy from config', { configPath: config.mcpConfigPath, error: error instanceof Error ? error.message : String(error) }); // Fallback to basic configuration this.initializeFallbackConfig(); } } initializeFallbackConfig() { // Basic fallback configuration for essential tools this.serverConfigs.set('memory', { command: 'npx', args: ['-y', '@gorka/memory-mcp'], type: 'stdio' }); this.serverConfigs.set('time', { command: 'npx', args: ['-y', '@gorka/time-mcp'], type: 'stdio' }); logger.info('Initialized fallback MCP configuration'); } /** * Simple heuristic to guess which server might have a tool */ guessServerForTool(toolName) { // Check cache first const cachedServer = this.successfulRoutes.get(toolName); if (cachedServer && this.serverConfigs.has(cachedServer)) { return [cachedServer]; } const servers = []; // Git tools if (toolName.startsWith('git_') || toolName.includes('git')) { if (this.serverConfigs.has('git')) servers.push('git'); } // Memory/knowledge graph tools if (['create_entities', 'search_nodes', 'add_observations', 'create_relations', 'delete_entities', 'read_graph', 'open_nodes', 'delete_observations', 'delete_relations'].includes(toolName)) { if (this.serverConfigs.has('memory')) servers.push('memory'); } // Time tools if (toolName === 'get_current_time' || toolName.includes('time')) { if (this.serverConfigs.has('time')) servers.push('time'); } // If no specific match, try all servers if (servers.length === 0) { servers.push(...this.serverConfigs.keys()); } return servers; } async connectToServer(serverName) { const existingConnection = this.mcpConnections.get(serverName); if (existingConnection) { return existingConnection; } const serverConfig = this.serverConfigs.get(serverName); if (!serverConfig) { throw new Error(`No configuration found for server: ${serverName}`); } logger.info('Starting MCP server connection', { serverName, command: serverConfig.command }); const childProcess = spawn(serverConfig.command, serverConfig.args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...serverConfig.env } }); const connection = { process: childProcess, requestId: 1, pendingRequests: new Map() }; // Handle process errors childProcess.on('error', (error) => { logger.error('MCP server process error', { serverName, error: error.message }); this.mcpConnections.delete(serverName); }); childProcess.on('exit', (code, signal) => { logger.info('MCP server process exited', { serverName, code, signal }); this.mcpConnections.delete(serverName); }); // Set up JSON-RPC communication this.setupJsonRpcCommunication(connection, serverName); // Initialize the server with handshake await this.initializeServer(connection); this.mcpConnections.set(serverName, connection); logger.info('Successfully connected to MCP server', { serverName }); return connection; } setupJsonRpcCommunication(connection, serverName) { let buffer = ''; connection.process.stdout?.on('data', (data) => { buffer += data.toString(); // Process complete JSON-RPC messages let newlineIndex; while ((newlineIndex = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line) { try { const message = JSON.parse(line); this.handleJsonRpcMessage(connection, message, serverName); } catch (error) { logger.error('Failed to parse JSON-RPC message', { serverName, line, error: error instanceof Error ? error.message : String(error) }); } } } }); connection.process.stderr?.on('data', (data) => { logger.debug('MCP server stderr', { serverName, data: data.toString() }); }); } handleJsonRpcMessage(connection, message, serverName) { logger.debug('Received JSON-RPC message', { serverName, message }); if (message.id && connection.pendingRequests.has(message.id)) { const pending = connection.pendingRequests.get(message.id); connection.pendingRequests.delete(message.id); if (message.error) { pending.reject(new Error(`MCP Error: ${message.error.message || JSON.stringify(message.error)}`)); } else { pending.resolve(message.result); } } } async sendJsonRpcRequest(connection, method, params = {}) { const requestId = connection.requestId++; const request = { jsonrpc: '2.0', id: requestId, method, params }; return new Promise((resolve, reject) => { connection.pendingRequests.set(requestId, { resolve, reject }); // Set timeout for request setTimeout(() => { if (connection.pendingRequests.has(requestId)) { connection.pendingRequests.delete(requestId); reject(new Error(`Request timeout for method: ${method}`)); } }, 30000); // 30 second timeout const requestStr = JSON.stringify(request) + '\n'; connection.process.stdin?.write(requestStr); }); } async initializeServer(connection) { // Send initialize request await this.sendJsonRpcRequest(connection, 'initialize', { protocolVersion: '2024-11-05', capabilities: { tools: {} }, clientInfo: { name: 'secondbrain-mcp', version: '0.4.0' } }); // Send initialized notification const notification = { jsonrpc: '2.0', method: 'notifications/initialized' }; const requestStr = JSON.stringify(notification) + '\n'; connection.process.stdin?.write(requestStr); } /** * Execute a tool call - tries servers until one succeeds * Alias for backward compatibility */ async callTool(toolName, args) { return this.executeTool({ name: toolName, arguments: args }); } /** * Execute a tool call - tries servers until one succeeds */ async executeTool(toolCall) { // First check if this is a VS Code tool we can handle directly if (this.vsCodeToolProxy.isVSCodeTool(toolCall.name)) { logger.info('Executing VS Code tool locally', { toolName: toolCall.name, arguments: toolCall.arguments }); const result = await this.vsCodeToolProxy.executeVSCodeTool(toolCall.name, toolCall.arguments); return { content: result.content, isError: result.isError }; } // Check if this is a VS Code built-in tool that we can't handle const vscodeBuiltinTools = [ 'run_in_terminal', 'get_errors', 'list_code_usages', 'semantic_search' ]; if (vscodeBuiltinTools.includes(toolCall.name)) { return { content: `ERROR: Tool "${toolCall.name}" is a VS Code built-in tool that cannot be executed by sub-agents. Sub-agents have access to file system tools (read_file, list_dir, grep_search, file_search, create_file, replace_string_in_file) and external MCP server tools (git, memory, time, etc.). Please use available tools or ask the primary agent to execute this tool.`, isError: true }; } // Handle external MCP server tools const potentialServers = this.guessServerForTool(toolCall.name); let lastError = null; for (const serverName of potentialServers) { try { const connection = await this.connectToServer(serverName); logger.info('Attempting tool execution', { toolName: toolCall.name, serverName, arguments: toolCall.arguments }); // Execute the tool call const result = await this.sendJsonRpcRequest(connection, 'tools/call', { name: toolCall.name, arguments: toolCall.arguments }); // Success! Cache this route and return result this.successfulRoutes.set(toolCall.name, serverName); logger.info('Tool execution successful', { toolName: toolCall.name, serverName }); return { content: result.content || JSON.stringify(result), isError: false }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); logger.debug('Tool execution failed on server', { toolName: toolCall.name, serverName, error: lastError.message }); // Continue trying other servers } } // All servers failed const errorMessage = lastError?.message || 'Unknown error'; logger.error('Tool execution failed on all servers', { toolName: toolCall.name, serversAttempted: potentialServers, error: errorMessage }); return { content: `Error executing ${toolCall.name}: ${errorMessage}`, isError: true }; } /** * Get available tools for a specific chatmode (simplified - just tool names) */ getAvailableToolsForChatmode(chatmodeName) { // Return common tool names that agents can attempt to use // Includes both local VS Code tools and external MCP server tools const baseTools = ['get_current_time']; // Local VS Code tools that sub-agents can actually use const localVSCodeTools = this.vsCodeToolProxy.getAvailableVSCodeTools(); switch (chatmodeName.toLowerCase()) { case 'memory curator': return [...baseTools, ...localVSCodeTools, 'create_entities', 'search_nodes', 'add_observations', 'create_relations', 'read_graph']; case 'software engineer': case 'software architect': return [...baseTools, ...localVSCodeTools, 'git_status', 'git_diff', 'git_log', 'search_nodes', 'create_entities']; case 'database architect': case 'security engineer': return [...baseTools, ...localVSCodeTools, 'git_status', 'git_diff', 'search_nodes', 'create_entities']; default: // Most agents get basic memory and time tools plus file system access return [...baseTools, ...localVSCodeTools, 'search_nodes', 'create_entities']; } } /** * Cleanup connections when shutting down */ async disconnect() { for (const [serverName, connection] of this.mcpConnections) { try { connection.process.kill(); logger.info('Disconnected from MCP server', { serverName }); } catch (error) { logger.error('Error disconnecting from MCP server', { serverName, error: error instanceof Error ? error.message : String(error) }); } } this.mcpConnections.clear(); } }