@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.4 kB
JavaScript
/**
* 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)));
}