UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

1,124 lines (1,123 loc) 67.5 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 { HITLUserRejectedError, HITLTimeoutError } from "../hitl/hitlErrors.js"; import { detectCategory } from "../utils/mcpDefaults.js"; import { isObject, isNonNullObject } from "../utils/typeUtils.js"; import { TelemetryService } from "../telemetry/telemetryService.js"; import { tracers } from "../telemetry/tracers.js"; import { SpanStatusCode } from "@opentelemetry/api"; /** * Recursively substitute environment variables in strings * Replaces ${VAR_NAME} with the value from process.env.VAR_NAME * @param value - Value to process (string, object, array, or primitive) * @returns Processed value with environment variables substituted */ function substituteEnvVariables(value) { if (typeof value === "string") { // Replace ${VAR_NAME} with process.env.VAR_NAME return value.replace(/\$\{([^}]+)\}/g, (match, varName) => { const envValue = process.env[varName.trim()]; if (envValue === undefined) { mcpLogger.warn(`[ExternalServerManager] Environment variable ${varName} is not defined, using empty string`); return ""; } return envValue; }); } if (Array.isArray(value)) { return value.map((item) => substituteEnvVariables(item)); } if (isNonNullObject(value)) { const result = {}; for (const [key, val] of Object.entries(value)) { result[key] = substituteEnvVariables(val); } return result; } return value; } /** * Sensitive CLI flag patterns whose following value should be masked in logs. */ const SENSITIVE_ARG_PATTERNS = /^--(api-key|token|secret|password|key|figma-api-key|access-token|auth|credential)$/i; /** * Redact values that follow sensitive flags in a CLI args array. * Handles: "--api-key sk-abc", "--api-key=sk-abc", consecutive flags. * E.g. ["--api-key", "sk-abc123"] → ["--api-key", "[REDACTED]"] * ["--api-key=sk-abc123"] → ["--api-key=[REDACTED]"] */ function redactSensitiveArgs(args) { if (!args || args.length === 0) { return args; } const redacted = [...args]; for (let i = 0; i < redacted.length; i++) { // Handle --flag=value inline form const eqIdx = redacted[i].indexOf("="); if (eqIdx !== -1) { const flag = redacted[i].substring(0, eqIdx); if (SENSITIVE_ARG_PATTERNS.test(flag)) { redacted[i] = `${flag}=[REDACTED]`; } continue; } // Handle --flag value (two-part form) if (SENSITIVE_ARG_PATTERNS.test(redacted[i]) && i + 1 < redacted.length && !redacted[i + 1].startsWith("--")) { redacted[i + 1] = "[REDACTED]"; i++; // skip the redacted value } } return redacted; } /** * 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 * Supports both stdio transport (requires command) and HTTP transport (requires url) */ function isValidExternalMCPServerConfig(config) { if (!isNonNullObject(config)) { return false; } const record = config; // Validate blockedTools array contains only strings if (record.blockedTools !== undefined) { if (!Array.isArray(record.blockedTools)) { return false; } if (!record.blockedTools.every((item) => typeof item === "string")) { return false; } } // Must have either command (for stdio) or url (for HTTP/SSE transport) const hasCommand = typeof record.command === "string"; const hasUrl = typeof record.url === "string"; if (!hasCommand && !hasUrl) { return false; } return ((record.command === undefined || 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.headers === undefined || isNonNullObject(record.headers)) && (record.httpOptions === undefined || isNonNullObject(record.httpOptions)) && (record.retryConfig === undefined || isNonNullObject(record.retryConfig)) && (record.rateLimiting === undefined || isNonNullObject(record.rateLimiting)) && (record.metadata === undefined || isNonNullObject(record.metadata))); } /** * ExternalServerManager * Core class for managing external MCP servers */ export class ExternalServerManager extends EventEmitter { servers = new Map(); config; isShuttingDown = false; toolDiscovery; enableMainRegistryIntegration; hitlManager; // Optional HITL manager for safety mechanisms constructor(config = {}, options = {}) { super(); // Set defaults for configuration // Default timeout increased to 60s and made configurable via MCP_CLIENT_TIMEOUT // to accommodate MCP server startup latency (especially concurrent stdio servers) const defaultMcpTimeout = Math.max(5000, Number(process.env.MCP_CLIENT_TIMEOUT) || 60000); this.config = { maxServers: config.maxServers ?? 10, defaultTimeout: config.defaultTimeout ?? defaultMcpTimeout, defaultHealthCheckInterval: config.defaultHealthCheckInterval ?? 30000, enableAutoRestart: config.enableAutoRestart ?? true, maxRestartAttempts: config.maxRestartAttempts ?? 3, restartBackoffMultiplier: config.restartBackoffMultiplier ?? 2, enablePerformanceMonitoring: config.enablePerformanceMonitoring ?? true, logLevel: config.logLevel ?? "info", }; // Disable main tool registry integration by default to prevent automatic tool execution this.enableMainRegistryIntegration = options.enableMainRegistryIntegration ?? false; // Initialize tool discovery service this.toolDiscovery = new ToolDiscoveryService(); // Forward tool discovery events this.toolDiscovery.on("toolRegistered", (event) => { this.emit("toolDiscovered", { ...event, serverName: this.getServerName(event.serverId), }); }); this.toolDiscovery.on("toolUnregistered", (event) => { this.emit("toolRemoved", { ...event, serverName: this.getServerName(event.serverId), }); }); // Handle process cleanup process.on("SIGINT", () => this.shutdown()); process.on("SIGTERM", () => this.shutdown()); process.on("beforeExit", () => this.shutdown()); } /** * Attach a McpOutputNormalizer to the underlying ToolDiscoveryService. * All tool outputs will be measured and (if oversized) replaced with compact * surrogates before being returned to callers. */ setOutputNormalizer(normalizer) { this.toolDiscovery.setOutputNormalizer(normalizer); mcpLogger.debug("[ExternalServerManager] MCP output normalizer attached to ToolDiscoveryService"); } /** * Set HITL manager for human-in-the-loop safety mechanisms * @param hitlManager - HITL manager instance (optional, can be undefined to disable) */ setHITLManager(hitlManager) { this.hitlManager = hitlManager; if (hitlManager && hitlManager.isEnabled()) { mcpLogger.info("[ExternalServerManager] HITL safety mechanisms enabled for external tool execution"); } else { mcpLogger.debug("[ExternalServerManager] HITL safety mechanisms disabled or not configured"); } } /** * Get current HITL manager */ getHITLManager() { return this.hitlManager; } /** * Resolve the human-readable server name for an event payload. * Falls back to serverId if the instance or config.name isn't available. */ getServerName(serverId) { const instance = this.servers.get(serverId); return instance?.config?.name || serverId; } /** * Load MCP server configurations from .mcp-config.json file with parallel loading support * Automatically registers servers found in the configuration * @param configPath Optional path to config file (defaults to .mcp-config.json in cwd) * @param options Loading options including parallel support * @returns Promise resolving to { serversLoaded, errors } */ async loadMCPConfiguration(configPath, options = {}) { if (options.parallel) { return this.loadMCPConfigurationParallel(configPath); } return this.loadMCPConfigurationSequential(configPath); } /** * Load MCP servers in parallel for improved performance * @param configPath Optional path to config file (defaults to .mcp-config.json in cwd) * @returns Promise resolving to batch operation result */ async loadMCPConfigurationParallel(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 in PARALLEL mode 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: [] }; } // Create promises for all servers to start them concurrently const serverPromises = Object.entries(config.mcpServers).map(async ([serverId, serverConfig]) => { 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: typeof serverConfig.description === "string" ? serverConfig.description : `External MCP server: ${serverId}`, transport: typeof serverConfig.transport === "string" ? serverConfig.transport : "stdio", status: "initializing", tools: [], command: typeof serverConfig.command === "string" ? serverConfig.command : undefined, args: Array.isArray(serverConfig.args) ? serverConfig.args : [], env: isNonNullObject(serverConfig.env) ? substituteEnvVariables(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, // HTTP transport-specific fields headers: isNonNullObject(serverConfig.headers) ? substituteEnvVariables(serverConfig.headers) : undefined, httpOptions: isNonNullObject(serverConfig.httpOptions) ? serverConfig.httpOptions : undefined, retryConfig: isNonNullObject(serverConfig.retryConfig) ? serverConfig.retryConfig : undefined, rateLimiting: isNonNullObject(serverConfig.rateLimiting) ? serverConfig.rateLimiting : undefined, blockedTools: Array.isArray(serverConfig.blockedTools) ? serverConfig.blockedTools : undefined, metadata: safeMetadataConversion(serverConfig.metadata), }; const result = await this.addServer(serverId, externalConfig); return { serverId, result }; } catch (error) { const errorMsg = `Failed to load MCP server ${serverId}: ${error instanceof Error ? error.message : String(error)}`; mcpLogger.warn(`[ExternalServerManager] ${errorMsg}`); return { serverId, error: errorMsg }; } }); // Start all servers concurrently and wait for completion const results = await Promise.allSettled(serverPromises); // Process results to count successes and collect errors let serversLoaded = 0; const errors = []; for (const result of results) { if (result.status === "fulfilled") { const { serverId, result: serverResult, error } = result.value; if (serverResult && serverResult.success) { serversLoaded++; mcpLogger.debug(`[ExternalServerManager] Successfully loaded MCP server in parallel: ${serverId}`); } else if (error) { errors.push(error); } else if (serverResult && !serverResult.success) { const errorMsg = `Failed to load server ${serverId}: ${serverResult.error}`; errors.push(errorMsg); mcpLogger.warn(`[ExternalServerManager] ${errorMsg}`); } } else { // Promise.allSettled rejected - this shouldn't happen with our error handling const errorMsg = `Unexpected error during parallel loading: ${result.reason}`; errors.push(errorMsg); mcpLogger.error(`[ExternalServerManager] ${errorMsg}`); } } mcpLogger.info(`[ExternalServerManager] PARALLEL MCP configuration loading complete: ${serversLoaded} servers loaded, ${errors.length} errors`); return { serversLoaded, errors }; } catch (error) { const errorMsg = `Failed to load MCP configuration in parallel mode: ${error instanceof Error ? error.message : String(error)}`; mcpLogger.error(`[ExternalServerManager] ${errorMsg}`); return { serversLoaded: 0, errors: [errorMsg] }; } } /** * Load MCP servers sequentially (original implementation for backward compatibility) * @param configPath Optional path to config file (defaults to .mcp-config.json in cwd) * @returns Promise resolving to batch operation result */ async loadMCPConfigurationSequential(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: typeof serverConfig.description === "string" ? serverConfig.description : `External MCP server: ${serverId}`, transport: typeof serverConfig.transport === "string" ? serverConfig.transport : "stdio", status: "initializing", tools: [], command: typeof serverConfig.command === "string" ? serverConfig.command : undefined, args: Array.isArray(serverConfig.args) ? serverConfig.args : [], env: isNonNullObject(serverConfig.env) ? substituteEnvVariables(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, // HTTP transport-specific fields headers: isNonNullObject(serverConfig.headers) ? substituteEnvVariables(serverConfig.headers) : undefined, httpOptions: isNonNullObject(serverConfig.httpOptions) ? serverConfig.httpOptions : undefined, retryConfig: isNonNullObject(serverConfig.retryConfig) ? serverConfig.retryConfig : undefined, rateLimiting: isNonNullObject(serverConfig.rateLimiting) ? serverConfig.rateLimiting : undefined, blockedTools: Array.isArray(serverConfig.blockedTools) ? serverConfig.blockedTools : 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 (!["stdio", "sse", "websocket", "http"].includes(config.transport)) { errors.push("Transport must be one of: stdio, sse, websocket, http"); } // Transport-specific validation if (config.transport === "stdio") { // stdio transport requires command if (!config.command || typeof config.command !== "string") { errors.push("Command is required and must be a string for stdio transport"); } if (!Array.isArray(config.args)) { errors.push("Args must be an array"); } } else if (config.transport === "sse" || config.transport === "websocket" || config.transport === "http") { // HTTP-based transports require URL if (!config.url || typeof config.url !== "string") { 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 blockedTools: config.blockedTools, // Preserve top-level operational fields so startServer can read them timeout: config.timeout, retries: config.retries, healthCheckInterval: config.healthCheckInterval, autoRestart: config.autoRestart, cwd: config.cwd, url: config.url, metadata: { category: "external", ...(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.timeout, retries: serverInfo.retries, healthCheckInterval: serverInfo.healthCheckInterval, autoRestart: serverInfo.autoRestart, cwd: serverInfo.cwd, url: serverInfo.url, blockedTools: serverInfo.blockedTools, metadata: safeMetadataConversion(serverInfo.metadata), // HTTP transport-specific fields headers: serverInfo.headers, httpOptions: serverInfo.httpOptions, retryConfig: serverInfo.retryConfig, rateLimiting: serverInfo.rateLimiting, }; 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); if (!finalInstance) { throw new Error(`Server ${serverId} not found after registration`); } // 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}`); // Capture name before deletion removes the instance const serverName = this.getServerName(serverId); // Stop the server await this.stopServer(serverId); // Remove from registry this.servers.delete(serverId); // Emit event this.emit("disconnected", { serverId, serverName, 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; const span = tracers.mcp.startSpan("neurolink.mcp.server.start", { attributes: { "mcp.server_id": serverId, "mcp.transport": config.transport, "mcp.command_name": config.command ? config.command.split(/[\\/]/).pop() || "" : "", "mcp.command_present": Boolean(config.command), }, }); try { this.updateServerStatus(serverId, "connecting"); mcpLogger.debug(`[ExternalServerManager] Starting server: ${serverId}`, { command: config.command, args: redactSensitiveArgs(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, serverName: this.getServerName(serverId), toolCount: instance.toolsMap.size, timestamp: new Date(), }); span.setAttribute("mcp.tool_count", instance.toolsMap.size); span.setStatus({ code: SpanStatusCode.OK }); 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); span.recordException(error instanceof Error ? error : new Error(String(error))); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); throw error; } finally { span.end(); } } /** * Stop an external MCP server */ async stopServer(serverId) { const instance = this.servers.get(serverId); if (!instance) { return; } const span = tracers.mcp.startSpan("neurolink.mcp.server.stop", { attributes: { "mcp.server_id": serverId, }, }); 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"); span.setStatus({ code: SpanStatusCode.OK }); mcpLogger.info(`[ExternalServerManager] Server stopped: ${serverId}`); } catch (error) { mcpLogger.error(`[ExternalServerManager] Error stopping server ${serverId}:`, error); this.updateServerStatus(serverId, "failed"); span.recordException(error instanceof Error ? error : new Error(String(error))); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); } finally { span.end(); } } /** * 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, serverName: this.getServerName(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, serverName: this.getServerName(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, serverName: this.getServerName(serverId), reason, timestamp: new Date(), }); // Attempt restart if enabled — prefer server-specific setting, fall back to global if ((instance.config.autoRestart ?? 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})`); if (instance.restartTimer) { return; } // already scheduled instance.restartTimer = setTimeout(async () => { const restartSpan = tracers.mcp.startSpan("neurolink.mcp.server.restart", { attributes: { "mcp.server_id": serverId, "mcp.restart_attempt": instance.reconnectAttempts, "mcp.restart_delay_ms": delay, }, }); try { await this.stopServer(serverId); await this.startServer(serverId); // Reset restart attempts on successful restart instance.reconnectAttempts = 0; restartSpan.setStatus({ code: SpanStatusCode.OK }); } catch (error) { mcpLogger.error(`[ExternalServerManager] Restart failed for ${serverId}:`, error); restartSpan.recordException(error instanceof Error ? error : new Error(String(error))); restartSpan.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); this.scheduleRestart(serverId); // Try again } finally { restartSpan.end(); } }, 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, serverName: this.getServerName(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))); }