UNPKG

mcpdog

Version:

MCPDog - Universal MCP Server Manager with Web Interface

537 lines 21.8 kB
import { EventEmitter } from 'events'; export class ToolRouter extends EventEmitter { adapters = new Map(); toolRoutes = new Map(); toolsByServer = new Map(); lastStableToolsList = []; // Cache the last stable tool list lastStableToolsCount = 0; configManager; constructor(configManager) { super(); this.configManager = configManager; } // Check if tool is enabled isToolEnabled(serverName, toolName) { if (!this.configManager) return true; // If no config manager, default to enabled const config = this.configManager.getConfig(); const serverConfig = config.servers[serverName]; if (!serverConfig) return true; const toolsConfig = serverConfig.toolsConfig; if (!toolsConfig) return true; // No tool config, default to enabled const { mode, toolSettings } = toolsConfig; const toolSetting = toolSettings?.[toolName]; switch (mode) { case 'all': return toolSetting ? toolSetting.enabled : true; case 'whitelist': return toolSetting ? toolSetting.enabled : false; case 'blacklist': return toolSetting ? toolSetting.enabled : true; default: return true; } } addAdapter(adapter) { if (this.adapters.has(adapter.name)) { throw new Error(`Adapter ${adapter.name} already exists`); } this.adapters.set(adapter.name, adapter); // Listen for adapter events adapter.on('connected', () => { this.refreshToolRoutes(adapter.name).catch(error => { console.error(`Failed to refresh routes for ${adapter.name}:`, error); }); }); adapter.on('disconnected', () => { this.removeToolRoutes(adapter.name); }); adapter.on('tools-changed', () => { this.refreshToolRoutes(adapter.name).catch(error => { console.error(`Failed to refresh routes for ${adapter.name}:`, error); }); }); console.error(`Added adapter: ${adapter.name}`); } removeAdapter(serverName) { const adapter = this.adapters.get(serverName); if (!adapter) { return; } // Remove all related routes this.removeToolRoutes(serverName); // Disable adapter to prevent auto-reconnect if ('disable' in adapter && typeof adapter.disable === 'function') { adapter.disable(); } // Disconnect if (adapter.isConnected) { adapter.disconnect().catch(error => { console.error(`Error disconnecting ${serverName}:`, error); }); } // Remove event listeners adapter.removeAllListeners(); this.adapters.delete(serverName); console.error(`Removed adapter: ${serverName}`); } getAdapter(serverName) { return this.adapters.get(serverName); } getAllAdapters() { return Array.from(this.adapters.values()); } getConnectedAdapters() { return Array.from(this.adapters.values()).filter(adapter => adapter.isConnected); } async refreshToolRoutes(serverName) { const adapter = this.adapters.get(serverName); if (!adapter || !adapter.isConnected) { return; } try { // Get server's tool list const tools = await adapter.getTools(); // Remove old routes for this server this.removeToolRoutes(serverName); // Cache tool list this.toolsByServer.set(serverName, tools); // Create new routes for (const tool of tools) { const originalToolName = tool.name; let prefixedToolName = tool.name; // Check for name conflict const existingRoute = this.toolRoutes.get(originalToolName); if (existingRoute) { console.warn(`Tool name conflict: ${originalToolName} exists in both ${existingRoute.serverName} and ${serverName}`); // Resolve conflict by prefixing with server name prefixedToolName = `${serverName}-${originalToolName}`; console.log(`🔧 Resolving conflict: ${originalToolName} -> ${prefixedToolName}`); // Also prefix the original tool const originalRoute = this.toolRoutes.get(originalToolName); if (originalRoute) { const originalPrefixedName = `${originalRoute.serverName}-${originalToolName}`; this.toolRoutes.set(originalPrefixedName, { ...originalRoute, toolName: originalPrefixedName }); console.log(`🔧 Original tool renamed: ${originalToolName} -> ${originalPrefixedName}`); } } const route = { toolName: prefixedToolName, serverName, adapter }; this.toolRoutes.set(prefixedToolName, route); } console.error(`Refreshed ${tools.length} tool routes for ${serverName}`); this.emit('routes-updated', { serverName, toolCount: tools.length }); } catch (error) { console.error(`Failed to refresh tool routes for ${serverName}:`, error); this.emit('error', { error: error, context: `refresh-routes-${serverName}` }); } } removeToolRoutes(serverName) { // Remove all tool routes for this server const toolsToRemove = []; for (const [toolName, route] of this.toolRoutes) { if (route.serverName === serverName) { toolsToRemove.push(toolName); } } for (const toolName of toolsToRemove) { this.toolRoutes.delete(toolName); } // Clear cache this.toolsByServer.delete(serverName); if (toolsToRemove.length > 0) { console.error(`Removed ${toolsToRemove.length} tool routes for ${serverName}`); this.emit('routes-updated', { serverName, toolCount: 0 }); } } async getAllTools(forceRefresh = false) { const connectedAdapters = this.getConnectedAdapters(); // If no adapters are connected, return empty tool list (security fix) if (connectedAdapters.length === 0) { console.error(`📦 No adapters connected, returning empty tools list for security`); // Clear stable tool cache to ensure disabled tools are not leaked this.lastStableToolsList = []; this.lastStableToolsCount = 0; return []; } // Step 1: Collect tools from all servers, with server info const allServerTools = []; for (const adapter of connectedAdapters) { let serverTools = this.toolsByServer.get(adapter.name) || []; // If cache is empty or force refresh, try to get in real-time if (serverTools.length === 0 || forceRefresh) { try { console.error(`Real-time fetching tools from ${adapter.name}...`); const freshTools = await Promise.race([ adapter.getTools(), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 8000)) ]); if (freshTools.length > 0) { this.toolsByServer.set(adapter.name, freshTools); serverTools = freshTools; console.error(`✅ Got ${freshTools.length} tools from ${adapter.name}`); } } catch (error) { console.error(`⚠️ Failed to fetch tools from ${adapter.name}: ${error.message}`); // Continue with cached tools (if any) } } // Add enabled tools to the list for (const tool of serverTools) { if (this.isToolEnabled(adapter.name, tool.name)) { allServerTools.push({ tool, serverName: adapter.name }); } } } // Step 2: Detect tool name conflicts const toolNameCounts = new Map(); for (const { tool } of allServerTools) { const count = toolNameCounts.get(tool.name) || 0; toolNameCounts.set(tool.name, count + 1); } // Step 3: Resolve conflicts, generate final tool list const allTools = []; for (const { tool, serverName } of allServerTools) { const originalToolName = tool.name; let finalToolName = originalToolName; // If there's a conflict, use server name as prefix if (toolNameCounts.get(originalToolName) > 1) { finalToolName = `${serverName}-${originalToolName}`; } allTools.push({ ...tool, name: finalToolName, // Add server info to tool description description: `[${serverName}] ${tool.description}` }); } // If a reasonable number of tools are obtained, update the stable cache if (allTools.length >= this.lastStableToolsCount * 0.8) { // At least 80% of tools this.lastStableToolsList = [...allTools]; this.lastStableToolsCount = allTools.length; console.error(`💾 Updated stable tools cache: ${allTools.length} tools`); } // Security fix: always return currently available tools, do not use cache // This ensures disabled servers/tools are not visible to MCP clients return allTools; } getToolsByServer(serverName) { return this.toolsByServer.get(serverName) || []; } getEnabledToolsByServer(serverName) { const serverTools = this.toolsByServer.get(serverName) || []; return serverTools.filter(tool => this.isToolEnabled(serverName, tool.name)); } findToolRoute(toolName) { return this.toolRoutes.get(toolName); } async callTool(toolName, args) { const route = this.toolRoutes.get(toolName); if (!route) { // If tool not found, try to force refresh all tools console.error(`Tool ${toolName} not found, refreshing tools...`); await this.getAllTools(true); // Force refresh const refreshedRoute = this.toolRoutes.get(toolName); if (!refreshedRoute) { return { jsonrpc: '2.0', id: 0, error: { code: -32601, message: `Tool not found: ${toolName}`, data: { availableTools: Array.from(this.toolRoutes.keys()) } } }; } // Use refreshed route return this.callTool(toolName, args); } if (!route.adapter.isConnected) { return { jsonrpc: '2.0', id: 0, error: { code: -32000, message: `Server not connected: ${route.serverName}`, data: { serverName: route.serverName } } }; } try { console.error(`Routing tool call: ${toolName} -> ${route.serverName}`); // Extract the original tool name by removing the server prefix const originalToolName = toolName.includes('-') ? toolName.split('-').slice(1).join('-') // Handle nested dashes : toolName; if (originalToolName !== toolName) { console.error(`Stripping prefix: ${toolName} -> ${originalToolName}`); } const startTime = Date.now(); const response = await route.adapter.callTool(originalToolName, args); const duration = Date.now() - startTime; console.error(`Tool call completed: ${toolName} (${duration}ms)`); this.emit('tool-called', { serverName: route.serverName, toolName, args, result: response.result, duration }); return response; } catch (error) { console.error(`Tool call failed: ${toolName} -> ${route.serverName}:`, error); this.emit('tool-call-failed', { serverName: route.serverName, toolName, args, error: error }); return { jsonrpc: '2.0', id: 0, error: { code: -32000, message: `Tool call failed: ${error.message}`, data: { toolName, serverName: route.serverName, originalError: error.message } } }; } } async connectAll(options) { const { timeout = 8000, maxConcurrent = 3 } = options || {}; const adaptersToConnect = Array.from(this.adapters.values()) .filter(adapter => !adapter.isConnected); if (adaptersToConnect.length === 0) { console.error("[ROUTER] No adapters to connect."); return; } console.error(`[ROUTER] Starting parallel connection of ${adaptersToConnect.length} adapters (timeout: ${timeout}ms, max concurrent: ${maxConcurrent})`); // Batch parallel connection, limit concurrency const batches = []; for (let i = 0; i < adaptersToConnect.length; i += maxConcurrent) { batches.push(adaptersToConnect.slice(i, i + maxConcurrent)); } let connectedCount = 0; let failedCount = 0; for (const batch of batches) { const batchPromises = batch.map(async (adapter) => { const startTime = Date.now(); try { // Add timeout control for each connection await this.connectWithTimeout(adapter, timeout); const duration = Date.now() - startTime; console.error(`[ROUTER] ✅ ${adapter.name} connection successful (${duration}ms)`); connectedCount++; } catch (error) { const duration = Date.now() - startTime; console.error(`[ROUTER] ❌ ${adapter.name} connection failed (${duration}ms):`, error.message); failedCount++; } }); await Promise.allSettled(batchPromises); } console.error(`[ROUTER] Connection completed: ${connectedCount} successful, ${failedCount} failed`); } async connectWithTimeout(adapter, timeout) { return new Promise(async (resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error(`Connection timeout (${timeout}ms)`)); }, timeout); try { await adapter.connect(); clearTimeout(timeoutId); resolve(); } catch (error) { clearTimeout(timeoutId); reject(error); } }); } async disconnectAll() { const disconnectionPromises = Array.from(this.adapters.values()) .filter(adapter => adapter.isConnected) .map(async (adapter) => { try { await adapter.disconnect(); } catch (error) { console.error(`Failed to disconnect ${adapter.name}:`, error); } }); await Promise.allSettled(disconnectionPromises); } getRouteStatus() { const connectedAdapters = this.getConnectedAdapters(); const toolsByServer = {}; const toolNames = new Set(); const toolConflicts = []; for (const adapter of this.adapters.values()) { const tools = this.toolsByServer.get(adapter.name) || []; toolsByServer[adapter.name] = tools.length; for (const tool of tools) { if (toolNames.has(tool.name)) { toolConflicts.push(tool.name); } else { toolNames.add(tool.name); } } } return { totalAdapters: this.adapters.size, connectedAdapters: connectedAdapters.length, totalTools: this.toolRoutes.size, toolsByServer, toolConflicts: Array.from(new Set(toolConflicts)) }; } // Tool search and filtering searchTools(query) { const allTools = Array.from(this.toolsByServer.values()).flat(); const lowerQuery = query.toLowerCase(); return allTools.filter(tool => tool.name.toLowerCase().includes(lowerQuery) || tool.description.toLowerCase().includes(lowerQuery)); } // Enable/disable tools by server async enableServerTools(serverName, enabled) { if (enabled) { await this.refreshToolRoutes(serverName); } else { this.removeToolRoutes(serverName); } } // Manually reconnect specific server (for SIGKILL etc. issues) async forceReconnectServer(serverName) { const adapter = this.adapters.get(serverName); if (!adapter) { console.error(`Server ${serverName} not found`); return false; } try { console.error(`🔄 Force reconnecting server: ${serverName}`); // If adapter supports force reconnect, use dedicated method if ('forceReconnect' in adapter && typeof adapter.forceReconnect === 'function') { await adapter.forceReconnect(); } else { // Otherwise use standard reconnect process if (adapter.isConnected) { await adapter.disconnect(); } await adapter.connect(); } console.error(`✅ ${serverName} reconnected successfully`); return true; } catch (error) { console.error(`❌ Failed to reconnect ${serverName}:`, error.message); return false; } } // Get server health status getServerHealth() { const health = {}; for (const [name, adapter] of this.adapters) { const tools = this.toolsByServer.get(name) || []; let status = 'healthy'; if (!adapter.isConnected) { status = 'failed'; } else if (tools.length === 0) { status = 'unstable'; } health[name] = { connected: adapter.isConnected, toolCount: tools.length, lastSeen: new Date().toISOString(), status }; } return health; } // Auto-heal unhealthy servers async autoHealUnhealthyServers() { const health = this.getServerHealth(); for (const [serverName, healthInfo] of Object.entries(health)) { if (healthInfo.status === 'failed') { console.error(`🩹 Auto-healing failed server: ${serverName}`); await this.forceReconnectServer(serverName); } } } // Get crash statistics for all servers getAllCrashStats() { const stats = {}; for (const [name, adapter] of this.adapters) { if ('getCrashStats' in adapter && typeof adapter.getCrashStats === 'function') { stats[name] = adapter.getCrashStats(); } } return stats; } // Clear blacklist for specific server clearServerBlacklist(serverName) { const adapter = this.adapters.get(serverName); if (!adapter) { console.error(`Server ${serverName} not found`); return false; } if ('clearBlacklist' in adapter && typeof adapter.clearBlacklist === 'function') { adapter.clearBlacklist(); console.error(`🟢 Cleared blacklist for ${serverName}`); return true; } return false; } // Clear all server blacklists clearAllBlacklists() { console.error('🟢 Clearing all server blacklists...'); for (const [name, adapter] of this.adapters) { if ('clearBlacklist' in adapter && typeof adapter.clearBlacklist === 'function') { adapter.clearBlacklist(); } } } // Get number of connected servers (for MCPDogServer) getConnectedServerCount() { return this.getConnectedAdapters().length; } // Get total number of servers (for MCPDogServer) getTotalServerCount() { return this.adapters.size; } // Get tool distribution (for MCPDogServer) getToolDistribution() { const distribution = {}; for (const [serverName, tools] of this.toolsByServer) { distribution[serverName] = tools.length; } return distribution; } async updateServerTools(serverName) { console.log(`[ROUTER] Updating tools for server: ${serverName}`); await this.refreshToolRoutes(serverName); } } //# sourceMappingURL=tool-router.js.map