UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, and professional CLI. Built-in tools operational, 58+ external MCP servers discoverable. Connect to filesystem, GitHub, database operations, and more. Build, test, and

1,038 lines (1,037 loc) 43.4 kB
/** * External MCP Server Manager * Handles lifecycle management of external MCP servers including: * - Process spawning and management * - Health monitoring and automatic restart * - Connection management and cleanup * - Tool discovery and registration */ import { EventEmitter } from "events"; import { mcpLogger } from "../utils/logger.js"; import { MCPClientFactory } from "./mcpClientFactory.js"; import { ToolDiscoveryService } from "./toolDiscoveryService.js"; import { toolRegistry } from "./toolRegistry.js"; import { detectCategory } from "../utils/mcpDefaults.js"; import { isObject, isNonNullObject } from "../utils/typeUtils.js"; /** * Type guard to validate if an object can be safely used as Record<string, JsonValue> */ function isValidJsonRecord(value) { if (!isObject(value)) { return false; } const record = value; return Object.values(record).every((val) => { // JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue } if (val === null || typeof val === "string" || typeof val === "number" || typeof val === "boolean") { return true; } if (Array.isArray(val)) { return val.every((item) => isValidJsonRecord(item) || typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null); } if (isNonNullObject(val)) { return isValidJsonRecord(val); } return false; }); } /** * Safely converts unknown metadata to Record<string, JsonValue> or returns undefined */ function safeMetadataConversion(metadata) { return isValidJsonRecord(metadata) ? metadata : undefined; } /** * Type guard to validate external MCP server configuration */ function isValidExternalMCPServerConfig(config) { if (!isNonNullObject(config)) { return false; } const record = config; return (typeof record.command === "string" && (record.args === undefined || Array.isArray(record.args)) && (record.env === undefined || isNonNullObject(record.env)) && (record.transport === undefined || typeof record.transport === "string") && (record.timeout === undefined || typeof record.timeout === "number") && (record.retries === undefined || typeof record.retries === "number") && (record.healthCheckInterval === undefined || typeof record.healthCheckInterval === "number") && (record.autoRestart === undefined || typeof record.autoRestart === "boolean") && (record.cwd === undefined || typeof record.cwd === "string") && (record.url === undefined || typeof record.url === "string") && (record.metadata === undefined || isNonNullObject(record.metadata))); } export class ExternalServerManager extends EventEmitter { servers = new Map(); config; isShuttingDown = false; toolDiscovery; enableMainRegistryIntegration; constructor(config = {}, options = {}) { super(); // Set defaults for configuration this.config = { maxServers: config.maxServers ?? 10, defaultTimeout: config.defaultTimeout ?? 10000, defaultHealthCheckInterval: config.defaultHealthCheckInterval ?? 30000, enableAutoRestart: config.enableAutoRestart ?? true, maxRestartAttempts: config.maxRestartAttempts ?? 3, restartBackoffMultiplier: config.restartBackoffMultiplier ?? 2, enablePerformanceMonitoring: config.enablePerformanceMonitoring ?? true, logLevel: config.logLevel ?? "info", }; // Enable main tool registry integration by default this.enableMainRegistryIntegration = options.enableMainRegistryIntegration ?? true; // Initialize tool discovery service this.toolDiscovery = new ToolDiscoveryService(); // Forward tool discovery events this.toolDiscovery.on("toolRegistered", (event) => { this.emit("toolDiscovered", event); }); this.toolDiscovery.on("toolUnregistered", (event) => { this.emit("toolRemoved", event); }); // Handle process cleanup process.on("SIGINT", () => this.shutdown()); process.on("SIGTERM", () => this.shutdown()); process.on("beforeExit", () => this.shutdown()); } /** * Load MCP server configurations from .mcp-config.json file * Automatically registers servers found in the configuration * @param configPath Optional path to config file (defaults to .mcp-config.json in cwd) * @returns Promise resolving to number of servers loaded */ async loadMCPConfiguration(configPath) { const fs = await import("fs"); const path = await import("path"); const finalConfigPath = configPath || path.join(process.cwd(), ".mcp-config.json"); if (!fs.existsSync(finalConfigPath)) { mcpLogger.debug(`[ExternalServerManager] No MCP config found at ${finalConfigPath}`); return { serversLoaded: 0, errors: [] }; } mcpLogger.debug(`[ExternalServerManager] Loading MCP configuration from ${finalConfigPath}`); try { const configContent = fs.readFileSync(finalConfigPath, "utf8"); const config = JSON.parse(configContent); if (!config.mcpServers || typeof config.mcpServers !== "object") { mcpLogger.debug("[ExternalServerManager] No mcpServers found in configuration"); return { serversLoaded: 0, errors: [] }; } let serversLoaded = 0; const errors = []; for (const [serverId, serverConfig] of Object.entries(config.mcpServers)) { try { // Validate and convert config format to MCPServerInfo if (!isValidExternalMCPServerConfig(serverConfig)) { throw new Error(`Invalid server config for ${serverId}: missing required properties or wrong types`); } const externalConfig = { id: serverId, name: serverId, description: `External MCP server: ${serverId}`, transport: typeof serverConfig.transport === "string" ? serverConfig.transport : "stdio", status: "initializing", tools: [], command: serverConfig.command, args: Array.isArray(serverConfig.args) ? serverConfig.args : [], env: isNonNullObject(serverConfig.env) ? serverConfig.env : {}, timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined, retries: typeof serverConfig.retries === "number" ? serverConfig.retries : undefined, healthCheckInterval: typeof serverConfig.healthCheckInterval === "number" ? serverConfig.healthCheckInterval : undefined, autoRestart: typeof serverConfig.autoRestart === "boolean" ? serverConfig.autoRestart : undefined, cwd: typeof serverConfig.cwd === "string" ? serverConfig.cwd : undefined, url: typeof serverConfig.url === "string" ? serverConfig.url : undefined, metadata: safeMetadataConversion(serverConfig.metadata), }; const result = await this.addServer(serverId, externalConfig); if (result.success) { serversLoaded++; mcpLogger.debug(`[ExternalServerManager] Successfully loaded MCP server: ${serverId}`); } else { const error = `Failed to load server ${serverId}: ${result.error}`; errors.push(error); mcpLogger.warn(`[ExternalServerManager] ${error}`); } } catch (error) { const errorMsg = `Failed to load MCP server ${serverId}: ${error instanceof Error ? error.message : String(error)}`; errors.push(errorMsg); mcpLogger.warn(`[ExternalServerManager] ${errorMsg}`); // Continue with other servers - don't let one failure break everything } } mcpLogger.info(`[ExternalServerManager] MCP configuration loading complete: ${serversLoaded} servers loaded, ${errors.length} errors`); return { serversLoaded, errors }; } catch (error) { const errorMsg = `Failed to load MCP configuration: ${error instanceof Error ? error.message : String(error)}`; mcpLogger.error(`[ExternalServerManager] ${errorMsg}`); return { serversLoaded: 0, errors: [errorMsg] }; } } /** * Validate external MCP server configuration */ validateConfig(config) { const errors = []; const warnings = []; const suggestions = []; // Required fields validation if (!config.id || typeof config.id !== "string") { errors.push("Server ID is required and must be a string"); } if (!config.command || typeof config.command !== "string") { errors.push("Command is required and must be a string"); } if (!Array.isArray(config.args)) { errors.push("Args must be an array"); } if (!["stdio", "sse", "websocket"].includes(config.transport)) { errors.push("Transport must be one of: stdio, sse, websocket"); } // URL validation for non-stdio transports if ((config.transport === "sse" || config.transport === "websocket") && !config.url) { errors.push(`URL is required for ${config.transport} transport`); } // Warnings for common issues if (config.timeout && config.timeout < 5000) { warnings.push("Timeout less than 5 seconds may cause connection issues"); } if (config.retries && config.retries > 5) { warnings.push("High retry count may slow down error recovery"); } // Suggestions for optimization if (!config.healthCheckInterval) { suggestions.push("Consider setting a health check interval for better reliability"); } if (config.autoRestart === undefined) { suggestions.push("Consider enabling auto-restart for production use"); } return { isValid: errors.length === 0, errors, warnings, suggestions, }; } /** * Convert MCPServerInfo format (keeping for backward compatibility) * Helper function for transitioning to zero-conversion architecture */ convertConfigToMCPServerInfo(serverId, config) { return { id: serverId, name: String(config.metadata?.title || serverId), description: `External MCP server (${config.transport})`, status: "initializing", transport: config.transport, command: config.command, args: config.args, env: config.env, tools: [], // Will be populated after server connection metadata: { category: "external", // Store additional ExternalMCPServerConfig fields in metadata timeout: config.timeout, retries: config.retries, healthCheckInterval: config.healthCheckInterval, autoRestart: config.autoRestart, cwd: config.cwd, url: config.url, ...(safeMetadataConversion(config.metadata) || {}), }, }; } async addServer(serverId, configOrServerInfo) { const startTime = Date.now(); try { // Use MCPServerInfo directly (zero-conversion architecture) const serverInfo = "transport" in configOrServerInfo && "command" in configOrServerInfo && !("tools" in configOrServerInfo) ? this.convertConfigToMCPServerInfo(serverId, configOrServerInfo) : configOrServerInfo; // Check server limit if (this.servers.size >= this.config.maxServers) { return { success: false, error: `Maximum number of servers (${this.config.maxServers}) reached`, serverId, duration: Date.now() - startTime, }; } // Validate configuration (for backward compatibility, create temporary config) const tempConfig = { id: serverId, name: serverInfo.name, description: serverInfo.description, transport: serverInfo.transport, status: serverInfo.status, tools: serverInfo.tools, command: serverInfo.command || "", args: serverInfo.args || [], env: serverInfo.env || {}, timeout: serverInfo.metadata?.timeout, retries: serverInfo.metadata?.retries, healthCheckInterval: serverInfo.metadata?.healthCheckInterval, autoRestart: serverInfo.metadata?.autoRestart, cwd: serverInfo.metadata?.cwd, url: serverInfo.metadata?.url, metadata: safeMetadataConversion(serverInfo.metadata), }; const validation = this.validateConfig(tempConfig); if (!validation.isValid) { return { success: false, error: `Configuration validation failed: ${validation.errors.join(", ")}`, serverId, duration: Date.now() - startTime, }; } // Check for duplicate server ID if (this.servers.has(serverId)) { return { success: false, error: `Server with ID '${serverId}' already exists`, serverId, duration: Date.now() - startTime, }; } mcpLogger.info(`[ExternalServerManager] Adding server: ${serverId}`, { command: serverInfo.command, transport: serverInfo.transport, }); // Create server instance as RuntimeMCPServerInfo (transition to zero-conversion) const instance = { ...serverInfo, process: null, client: null, transportInstance: null, status: "initializing", reconnectAttempts: 0, maxReconnectAttempts: this.config.maxRestartAttempts, toolsMap: new Map(), metrics: { totalConnections: 0, totalDisconnections: 0, totalErrors: 0, totalToolCalls: 0, averageResponseTime: 0, lastResponseTime: 0, }, config: tempConfig, }; // Store the instance this.servers.set(serverId, instance); // Start the server await this.startServer(serverId); const finalInstance = this.servers.get(serverId); // Convert RuntimeMCPServerInfo to ExternalMCPServerInstance for return const convertedInstance = { config: finalInstance.config, process: finalInstance.process, client: finalInstance.client, transport: finalInstance.transportInstance, status: finalInstance.status, lastError: finalInstance.lastError, startTime: finalInstance.startTime, lastHealthCheck: finalInstance.lastHealthCheck, reconnectAttempts: finalInstance.reconnectAttempts, maxReconnectAttempts: finalInstance.maxReconnectAttempts, tools: finalInstance.toolsMap, toolsArray: finalInstance.toolsArray, capabilities: finalInstance.capabilities, healthTimer: finalInstance.healthTimer, restartTimer: finalInstance.restartTimer, metrics: finalInstance.metrics, }; return { success: true, data: convertedInstance, serverId, duration: Date.now() - startTime, metadata: { timestamp: Date.now(), operation: "addServer", toolsDiscovered: finalInstance.tools.length, }, }; } catch (error) { mcpLogger.error(`[ExternalServerManager] Failed to add server ${serverId}:`, error); // Clean up if instance was created this.servers.delete(serverId); return { success: false, error: error instanceof Error ? error.message : String(error), serverId, duration: Date.now() - startTime, }; } } /** * Remove an external MCP server */ async removeServer(serverId) { const startTime = Date.now(); try { const instance = this.servers.get(serverId); if (!instance) { return { success: false, error: `Server '${serverId}' not found`, serverId, duration: Date.now() - startTime, }; } mcpLogger.info(`[ExternalServerManager] Removing server: ${serverId}`); // Stop the server await this.stopServer(serverId); // Remove from registry this.servers.delete(serverId); // Emit event this.emit("disconnected", { serverId, reason: "Manually removed", timestamp: new Date(), }); return { success: true, serverId, duration: Date.now() - startTime, metadata: { timestamp: Date.now(), operation: "removeServer", }, }; } catch (error) { mcpLogger.error(`[ExternalServerManager] Failed to remove server ${serverId}:`, error); return { success: false, error: error instanceof Error ? error.message : String(error), serverId, duration: Date.now() - startTime, }; } } /** * Start an external MCP server */ async startServer(serverId) { const instance = this.servers.get(serverId); if (!instance) { throw new Error(`Server '${serverId}' not found`); } const config = instance.config; try { this.updateServerStatus(serverId, "connecting"); mcpLogger.debug(`[ExternalServerManager] Starting server: ${serverId}`, { command: config.command, args: config.args, transport: config.transport, }); // Create MCP client using the factory const clientResult = await MCPClientFactory.createClient(config, config.timeout || this.config.defaultTimeout); if (!clientResult.success || !clientResult.client || !clientResult.transport) { throw new Error(`Failed to create MCP client: ${clientResult.error}`); } // Store client components instance.client = clientResult.client; instance.transportInstance = clientResult.transport; instance.process = clientResult.process || null; instance.capabilities = safeMetadataConversion(clientResult.capabilities); instance.startTime = new Date(); instance.lastHealthCheck = new Date(); instance.metrics.totalConnections++; // Handle process events if there's a process if (instance.process) { instance.process.on("error", (error) => { mcpLogger.error(`[ExternalServerManager] Process error for ${serverId}:`, error); this.handleServerError(serverId, error); }); instance.process.on("exit", (code, signal) => { mcpLogger.warn(`[ExternalServerManager] Process exited for ${serverId}`, { code, signal, }); this.handleServerDisconnection(serverId, `Process exited with code ${code}`); }); // Log stderr for debugging instance.process.stderr?.on("data", (data) => { const message = data.toString().trim(); if (message) { mcpLogger.debug(`[ExternalServerManager] ${serverId} stderr:`, message); } }); } this.updateServerStatus(serverId, "connected"); // Discover tools from the server await this.discoverServerTools(serverId); // Register tools with main registry if integration is enabled if (this.enableMainRegistryIntegration) { await this.registerServerToolsWithMainRegistry(serverId); } // Start health monitoring this.startHealthMonitoring(serverId); // Emit connected event this.emit("connected", { serverId, toolCount: instance.toolsMap.size, timestamp: new Date(), }); mcpLogger.info(`[ExternalServerManager] Server started successfully: ${serverId}`); } catch (error) { mcpLogger.error(`[ExternalServerManager] Failed to start server ${serverId}:`, error); this.updateServerStatus(serverId, "failed"); instance.lastError = error instanceof Error ? error.message : String(error); throw error; } } /** * Stop an external MCP server */ async stopServer(serverId) { const instance = this.servers.get(serverId); if (!instance) { return; } try { this.updateServerStatus(serverId, "stopping"); // Clear timers if (instance.healthTimer) { clearInterval(instance.healthTimer); instance.healthTimer = undefined; } if (instance.restartTimer) { clearTimeout(instance.restartTimer); instance.restartTimer = undefined; } // Unregister tools from main registry if integration is enabled if (this.enableMainRegistryIntegration) { this.unregisterServerToolsFromMainRegistry(serverId); } // Clear server tools from discovery service this.toolDiscovery.clearServerTools(serverId); // Close MCP client using factory cleanup if (instance.client && instance.transportInstance) { try { await MCPClientFactory.closeClient(instance.client, instance.transportInstance, instance.process || undefined); } catch (error) { mcpLogger.debug(`[ExternalServerManager] Error closing client for ${serverId}:`, error); } instance.client = null; instance.transportInstance = null; instance.process = null; } this.updateServerStatus(serverId, "stopped"); mcpLogger.info(`[ExternalServerManager] Server stopped: ${serverId}`); } catch (error) { mcpLogger.error(`[ExternalServerManager] Error stopping server ${serverId}:`, error); this.updateServerStatus(serverId, "failed"); } } /** * Update server status and emit events */ updateServerStatus(serverId, newStatus) { const instance = this.servers.get(serverId); if (!instance) { return; } const oldStatus = instance.status; // Map ExternalMCPServerStatus to MCPServerInfo status const mappedStatus = newStatus === "connecting" || newStatus === "restarting" ? "initializing" : newStatus === "stopping" || newStatus === "stopped" ? "stopping" : newStatus === "connected" ? "connected" : newStatus === "disconnected" ? "disconnected" : "failed"; instance.status = mappedStatus; // Emit status change event this.emit("statusChanged", { serverId, oldStatus, newStatus, timestamp: new Date(), }); mcpLogger.debug(`[ExternalServerManager] Status changed for ${serverId}: ${oldStatus} -> ${newStatus}`); } /** * Handle server errors */ handleServerError(serverId, error) { const instance = this.servers.get(serverId); if (!instance) { return; } instance.lastError = error.message; instance.metrics.totalErrors++; mcpLogger.error(`[ExternalServerManager] Server error for ${serverId}:`, error); // Emit failed event this.emit("failed", { serverId, error: error.message, timestamp: new Date(), }); // Attempt restart if enabled if (this.config.enableAutoRestart && !this.isShuttingDown) { this.scheduleRestart(serverId); } else { this.updateServerStatus(serverId, "failed"); } } /** * Handle server disconnection */ handleServerDisconnection(serverId, reason) { const instance = this.servers.get(serverId); if (!instance) { return; } instance.metrics.totalDisconnections++; mcpLogger.warn(`[ExternalServerManager] Server disconnected ${serverId}: ${reason}`); // Emit disconnected event this.emit("disconnected", { serverId, reason, timestamp: new Date(), }); // Attempt restart if enabled if (this.config.enableAutoRestart && !this.isShuttingDown) { this.scheduleRestart(serverId); } else { this.updateServerStatus(serverId, "disconnected"); } } /** * Schedule server restart with exponential backoff */ scheduleRestart(serverId) { const instance = this.servers.get(serverId); if (!instance) { return; } if (instance.reconnectAttempts >= instance.maxReconnectAttempts) { mcpLogger.error(`[ExternalServerManager] Max restart attempts reached for ${serverId}`); this.updateServerStatus(serverId, "failed"); return; } instance.reconnectAttempts++; this.updateServerStatus(serverId, "restarting"); const delay = Math.min(1000 * Math.pow(this.config.restartBackoffMultiplier, instance.reconnectAttempts - 1), 30000); mcpLogger.info(`[ExternalServerManager] Scheduling restart for ${serverId} in ${delay}ms (attempt ${instance.reconnectAttempts})`); instance.restartTimer = setTimeout(async () => { try { await this.stopServer(serverId); await this.startServer(serverId); // Reset restart attempts on successful restart instance.reconnectAttempts = 0; } catch (error) { mcpLogger.error(`[ExternalServerManager] Restart failed for ${serverId}:`, error); this.scheduleRestart(serverId); // Try again } }, delay); } /** * Start health monitoring for a server */ startHealthMonitoring(serverId) { const instance = this.servers.get(serverId); if (!instance || !this.config.enablePerformanceMonitoring) { return; } const interval = instance.config.healthCheckInterval ?? this.config.defaultHealthCheckInterval; instance.healthTimer = setInterval(async () => { await this.performHealthCheck(serverId); }, interval); } /** * Perform health check on a server */ async performHealthCheck(serverId) { const instance = this.servers.get(serverId); if (!instance || instance.status !== "connected") { return; } const startTime = Date.now(); try { // For now, simple process check let isHealthy = true; const issues = []; if (instance.process && instance.process.killed) { isHealthy = false; issues.push("Process is killed"); } const responseTime = Date.now() - startTime; instance.lastHealthCheck = new Date(); const health = { serverId, isHealthy, status: instance.status, checkedAt: new Date(), responseTime, toolCount: instance.toolsMap.size, issues, performance: { uptime: instance.startTime ? Date.now() - instance.startTime.getTime() : 0, averageResponseTime: instance.metrics.averageResponseTime, }, }; // Emit health check event this.emit("healthCheck", { serverId, health, timestamp: new Date(), }); if (!isHealthy) { mcpLogger.warn(`[ExternalServerManager] Health check failed for ${serverId}:`, issues); this.handleServerError(serverId, new Error(`Health check failed: ${issues.join(", ")}`)); } } catch (error) { mcpLogger.error(`[ExternalServerManager] Health check error for ${serverId}:`, error); this.handleServerError(serverId, error instanceof Error ? error : new Error(String(error))); } } /** * Get server instance - converted to ExternalMCPServerInstance for compatibility */ getServer(serverId) { const runtime = this.servers.get(serverId); if (!runtime) { return undefined; } return { config: runtime.config, process: runtime.process, client: runtime.client, transport: runtime.transportInstance, status: runtime.status, lastError: runtime.lastError, startTime: runtime.startTime, lastHealthCheck: runtime.lastHealthCheck, reconnectAttempts: runtime.reconnectAttempts, maxReconnectAttempts: runtime.maxReconnectAttempts, tools: runtime.toolsMap, toolsArray: runtime.toolsArray, capabilities: runtime.capabilities, healthTimer: runtime.healthTimer, restartTimer: runtime.restartTimer, metrics: runtime.metrics, }; } /** * Get all servers - converted to ExternalMCPServerInstance for compatibility */ getAllServers() { const converted = new Map(); for (const [serverId, runtime] of this.servers.entries()) { converted.set(serverId, { config: runtime.config, process: runtime.process, client: runtime.client, transport: runtime.transportInstance, status: runtime.status, lastError: runtime.lastError, startTime: runtime.startTime, lastHealthCheck: runtime.lastHealthCheck, reconnectAttempts: runtime.reconnectAttempts, maxReconnectAttempts: runtime.maxReconnectAttempts, tools: runtime.toolsMap, toolsArray: runtime.toolsArray, capabilities: runtime.capabilities, healthTimer: runtime.healthTimer, restartTimer: runtime.restartTimer, metrics: runtime.metrics, }); } return converted; } /** * List servers as MCPServerInfo - ZERO conversion needed */ listServers() { return Array.from(this.servers.values()); } /** * Get server statuses */ getServerStatuses() { const statuses = []; for (const [serverId, instance] of Array.from(this.servers.entries())) { const uptime = instance.startTime ? Date.now() - instance.startTime.getTime() : 0; statuses.push({ serverId, isHealthy: instance.status === "connected", status: instance.status, checkedAt: instance.lastHealthCheck || new Date(), toolCount: instance.toolsMap.size, issues: instance.lastError ? [instance.lastError] : [], performance: { uptime, averageResponseTime: instance.metrics.averageResponseTime, }, }); } return statuses; } /** * Shutdown all servers */ async shutdown() { if (this.isShuttingDown) { return; } this.isShuttingDown = true; mcpLogger.info("[ExternalServerManager] Shutting down all servers..."); const shutdownPromises = Array.from(this.servers.keys()).map((serverId) => this.stopServer(serverId).catch((error) => { mcpLogger.error(`[ExternalServerManager] Error shutting down ${serverId}:`, error); })); await Promise.all(shutdownPromises); this.servers.clear(); mcpLogger.info("[ExternalServerManager] All servers shut down"); } /** * Get manager statistics */ getStatistics() { let connectedServers = 0; let failedServers = 0; let totalTools = 0; let totalConnections = 0; let totalErrors = 0; for (const instance of Array.from(this.servers.values())) { if (instance.status === "connected") { connectedServers++; } else if (instance.status === "failed") { failedServers++; } totalTools += instance.toolsMap.size; totalConnections += instance.metrics.totalConnections; totalErrors += instance.metrics.totalErrors; } return { totalServers: this.servers.size, connectedServers, failedServers, totalTools, totalConnections, totalErrors, }; } /** * Discover tools from a server */ async discoverServerTools(serverId) { const instance = this.servers.get(serverId); if (!instance || !instance.client) { throw new Error(`Server '${serverId}' not found or not connected`); } try { mcpLogger.debug(`[ExternalServerManager] Discovering tools for server: ${serverId}`); const discoveryResult = await this.toolDiscovery.discoverTools(serverId, instance.client, this.config.defaultTimeout); if (discoveryResult.success) { instance.toolsMap.clear(); instance.toolsArray = undefined; instance.tools = []; for (const tool of discoveryResult.tools) { instance.toolsMap.set(tool.name, tool); instance.tools.push({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, }); } mcpLogger.info(`[ExternalServerManager] Discovered ${discoveryResult.toolCount} tools for ${serverId}`); } else { mcpLogger.warn(`[ExternalServerManager] Tool discovery failed for ${serverId}: ${discoveryResult.error}`); } } catch (error) { mcpLogger.error(`[ExternalServerManager] Tool discovery error for ${serverId}:`, error); } } /** * Register server tools with main tool registry for unified access * This enables external MCP tools to be accessed via the main toolRegistry.executeTool() */ async registerServerToolsWithMainRegistry(serverId) { const instance = this.servers.get(serverId); if (!instance) { throw new Error(`Server '${serverId}' not found`); } try { mcpLogger.debug(`[ExternalServerManager] Registering ${instance.toolsMap.size} tools with main registry for server: ${serverId}`); for (const [toolName, tool] of instance.toolsMap.entries()) { const toolId = `${serverId}.${toolName}`; const toolInfo = { name: toolName, description: tool.description || toolName, inputSchema: tool.inputSchema || {}, serverId: serverId, category: detectCategory({ isExternal: true, serverId }), }; // Register with main tool registry try { toolRegistry.registerTool(toolId, toolInfo, { execute: async (params, context) => { // Execute tool via ExternalServerManager for proper lifecycle management return await this.executeTool(serverId, toolName, params, { timeout: this.config.defaultTimeout }); }, }); mcpLogger.debug(`[ExternalServerManager] Registered tool with main registry: ${toolId}`); } catch (registrationError) { mcpLogger.warn(`[ExternalServerManager] Failed to register tool ${toolId} with main registry:`, registrationError); } } mcpLogger.info(`[ExternalServerManager] Successfully registered ${instance.toolsMap.size} tools with main registry for ${serverId}`); } catch (error) { mcpLogger.error(`[ExternalServerManager] Failed to register tools with main registry for ${serverId}:`, error); } } /** * Unregister server tools from main tool registry */ unregisterServerToolsFromMainRegistry(serverId) { const instance = this.servers.get(serverId); if (!instance || !this.enableMainRegistryIntegration) { return; } try { mcpLogger.debug(`[ExternalServerManager] Unregistering tools from main registry for server: ${serverId}`); for (const [toolName] of instance.toolsMap.entries()) { const toolId = `${serverId}.${toolName}`; try { toolRegistry.removeTool(toolId); mcpLogger.debug(`[ExternalServerManager] Unregistered tool from main registry: ${toolId}`); } catch (error) { mcpLogger.debug(`[ExternalServerManager] Failed to unregister tool ${toolId}:`, error); } } mcpLogger.debug(`[ExternalServerManager] Completed unregistering tools from main registry for ${serverId}`); } catch (error) { mcpLogger.error(`[ExternalServerManager] Error unregistering tools from main registry for ${serverId}:`, error); } } /** * Execute a tool on a specific server */ async executeTool(serverId, toolName, parameters, options) { const instance = this.servers.get(serverId); if (!instance) { throw new Error(`Server '${serverId}' not found`); } if (!instance.client) { throw new Error(`Server '${serverId}' is not connected`); } if (instance.status !== "connected") { throw new Error(`Server '${serverId}' is not in connected state: ${instance.status}`); } const startTime = Date.now(); try { // Execute tool through discovery service const result = await this.toolDiscovery.executeTool(toolName, serverId, instance.client, parameters, { timeout: options?.timeout || this.config.defaultTimeout, }); const duration = Date.now() - startTime; // Update metrics instance.metrics.totalToolCalls++; instance.metrics.lastResponseTime = duration; // Update average response time const totalTime = instance.metrics.averageResponseTime * (instance.metrics.totalToolCalls - 1) + duration; instance.metrics.averageResponseTime = totalTime / instance.metrics.totalToolCalls; if (result.success) { mcpLogger.debug(`[ExternalServerManager] Tool executed successfully: ${toolName} on ${serverId}`, { duration, }); return result.data; } else { throw new Error(result.error || "Tool execution failed"); } } catch (error) { const duration = Date.now() - startTime; instance.metrics.totalErrors++; mcpLogger.error(`[ExternalServerManager] Tool execution failed: ${toolName} on ${serverId}`, error); throw error; } } /** * Get all tools from all servers */ getAllTools() { return this.toolDiscovery.getAllTools(); } /** * Get tools for a specific server */ getServerTools(serverId) { return this.toolDiscovery.getServerTools(serverId); } /** * Get tool discovery service */ getToolDiscovery() { return this.toolDiscovery; } }