UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

1,004 lines (1,003 loc) • 43 kB
/** * Tool Discovery Service * Automatically discovers and registers tools from external MCP servers * Handles tool validation, transformation, and lifecycle management */ import { EventEmitter } from "events"; import { mcpLogger } from "../utils/logger.js"; import { globalCircuitBreakerManager, CircuitBreakerOpenError, } from "./mcpCircuitBreaker.js"; import { isObject, isNullish } from "../utils/typeUtils.js"; import { validateToolName, validateToolDescription, } from "../utils/parameterValidation.js"; import { withTimeout } from "../utils/errorHandling.js"; import { coerceType } from "../utils/toolCallRepair.js"; import { extractMcpErrorText } from "../utils/mcpErrorText.js"; import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; import { tracers } from "../telemetry/tracers.js"; import { withSpan } from "../telemetry/withSpan.js"; const mcpTracer = tracers.mcp; /** * JSON-stringify a value for a Langfuse input/output preview attribute, * truncated to a hard cap to stay under span attribute size limits. The * returned string is guaranteed to be ≤ maxLen characters; when truncated, * the last character is replaced with an ellipsis. */ function safeJsonStringify(value, maxLen) { if (maxLen <= 0) { return ""; } try { const str = JSON.stringify(value); if (typeof str !== "string") { return ""; } if (str.length <= maxLen) { return str; } return str.slice(0, Math.max(0, maxLen - 1)) + "…"; } catch { return ""; } } /** * Match property names that commonly hold secrets. Values under these keys * are replaced with `[REDACTED]` before serialization. Case-insensitive. * Conservative list — anything matching *here* is masked; the rest of the * structure is preserved so Langfuse still gets a meaningful preview. */ const SENSITIVE_KEY_PATTERN = /^(password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|authorization|auth|bearer|credential|cookie|session[_-]?id|private[_-]?key|client[_-]?secret|refresh[_-]?token|x-api-key)$/i; /** * Walk a value, producing a structurally-equivalent copy with sensitive-key * values masked. Unlike `transformParamsForLogging` (which collapses objects * to a "N params" string), this preserves non-sensitive content so Langfuse * input/output previews stay useful. Bounded depth guards against cycles. */ function redactForPreview(value, depth = 0) { if (depth > 10) { return "[...]"; } if (value === null || value === undefined) { return value; } if (typeof value !== "object") { return value; } if (Array.isArray(value)) { return value.map((v) => redactForPreview(v, depth + 1)); } const out = {}; for (const [k, v] of Object.entries(value)) { if (SENSITIVE_KEY_PATTERN.test(k)) { out[k] = "[REDACTED]"; } else { out[k] = redactForPreview(v, depth + 1); } } return out; } /** * Default timeout for MCP tool execution operations in milliseconds. * Configurable via MCP_TOOL_TIMEOUT env var. */ const DEFAULT_TOOL_TIMEOUT = Math.max(5000, Number(process.env.MCP_TOOL_TIMEOUT) || 60000); /** * ToolDiscoveryService * Handles automatic tool discovery and registration from external MCP servers */ export class ToolDiscoveryService extends EventEmitter { serverToolStorage = new Map(); toolRegistry = new Map(); serverTools = new Map(); discoveryInProgress = new Set(); /** Optional normalizer applied to every tool output before it is returned. */ outputNormalizer; constructor() { super(); } /** * Attach a McpOutputNormalizer. * When set, every raw callTool() result is passed through the normalizer * before being returned. Oversized outputs are replaced with compact * surrogates according to the configured strategy. */ setOutputNormalizer(normalizer) { this.outputNormalizer = normalizer; } /** * Discover tools from an external MCP server */ async discoverTools(serverId, client, timeout = DEFAULT_TOOL_TIMEOUT) { return withSpan({ name: "neurolink.mcp.discoverTools", tracer: tracers.mcp, attributes: { "mcp.server_id": serverId }, }, async (span) => { const startTime = Date.now(); try { // Prevent concurrent discovery for same server if (this.discoveryInProgress.has(serverId)) { return { success: false, error: `Discovery already in progress for server: ${serverId}`, toolCount: 0, tools: [], duration: Date.now() - startTime, serverId, }; } this.discoveryInProgress.add(serverId); mcpLogger.info(`[ToolDiscoveryService] Starting tool discovery for server: ${serverId}`); // Create circuit breaker for tool discovery const circuitBreaker = globalCircuitBreakerManager.getBreaker(`tool-discovery-${serverId}`, { failureThreshold: 2, resetTimeout: 60000, operationTimeout: timeout, }); // Discover tools with circuit breaker protection const tools = await circuitBreaker.execute(async () => { return await this.performToolDiscovery(serverId, client, timeout); }); // Register discovered tools const registeredTools = await this.registerDiscoveredTools(serverId, tools); span.setAttribute("mcp.tools_discovered", registeredTools.length); const result = { success: true, toolCount: registeredTools.length, tools: registeredTools, duration: Date.now() - startTime, serverId, }; // Emit discovery completed event this.emit("discoveryCompleted", { serverId, toolCount: registeredTools.length, duration: result.duration, timestamp: new Date(), }); mcpLogger.info(`[ToolDiscoveryService] Discovery completed for ${serverId}: ${registeredTools.length} tools`); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage, }); span.recordException(error instanceof Error ? error : new Error(errorMessage)); mcpLogger.error(`[ToolDiscoveryService] Discovery failed for ${serverId}:`, error); // Emit discovery failed event this.emit("discoveryFailed", { serverId, error: errorMessage, timestamp: new Date(), }); return { success: false, error: errorMessage, toolCount: 0, tools: [], duration: Date.now() - startTime, serverId, }; } finally { this.discoveryInProgress.delete(serverId); } }); } /** * Perform the actual tool discovery */ async performToolDiscovery(serverId, client, timeout) { // List tools from the MCP server const result = await this.raceWithTimeout(client.listTools(), timeout, "Tool discovery timeout"); if (!result || !result.tools) { throw new Error("No tools returned from server"); } mcpLogger.debug(`[ToolDiscoveryService] Discovered ${result.tools.length} tools from ${serverId}`); return result.tools; } /** * Register discovered tools */ async registerDiscoveredTools(serverId, tools) { const registeredTools = []; // Clear existing tools for this server this.clearServerTools(serverId); for (const tool of tools) { try { const toolInfo = await this.createToolInfo(serverId, tool); const validation = this.validateTool(toolInfo); if (!validation.isValid) { mcpLogger.warn(`[ToolDiscoveryService] Skipping invalid tool ${tool.name} from ${serverId}:`, validation.errors); continue; } // Apply validation metadata if (validation.metadata) { toolInfo.metadata = { ...toolInfo.metadata, ...validation.metadata, }; } // Register the tool const toolKey = this.createToolKey(serverId, tool.name); this.toolRegistry.set(toolKey, toolInfo); if (!this.serverToolStorage.has(serverId)) { this.serverToolStorage.set(serverId, []); } const serverTools = this.serverToolStorage.get(serverId); if (!serverTools) { throw new Error(`Server tools storage not found for ${serverId}`); } // Add tool if not already present if (!serverTools.find((t) => t.name === tool.name)) { serverTools.push({ name: tool.name, description: tool.description || "", inputSchema: tool.inputSchema, }); } // Track server tools (legacy) if (!this.serverTools.has(serverId)) { this.serverTools.set(serverId, new Set()); } const serverToolSet = this.serverTools.get(serverId); if (serverToolSet) { serverToolSet.add(tool.name); } registeredTools.push(toolInfo); // Emit tool registered event this.emit("toolRegistered", { serverId, toolName: tool.name, toolInfo, timestamp: new Date(), }); mcpLogger.debug(`[ToolDiscoveryService] Registered tool: ${tool.name} from ${serverId}`); } catch (error) { mcpLogger.error(`[ToolDiscoveryService] Failed to register tool ${tool.name} from ${serverId}:`, error); } } return registeredTools; } /** * Create tool info from MCP tool definition */ async createToolInfo(serverId, tool) { return { name: tool.name, description: tool.description || "No description provided", serverId, inputSchema: tool.inputSchema, isAvailable: true, stats: { totalCalls: 0, successfulCalls: 0, failedCalls: 0, averageExecutionTime: 0, lastExecutionTime: 0, }, metadata: { category: this.inferToolCategory(tool), version: "1.0.0", deprecated: false, }, }; } /** * Infer tool category from tool definition */ inferToolCategory(tool) { const name = tool.name.toLowerCase(); const description = (tool.description || "").toLowerCase(); // Common patterns for categorization if (name.includes("git") || description.includes("git")) { return "version-control"; } if (name.includes("file") || name.includes("read") || name.includes("write")) { return "file-system"; } if (name.includes("api") || name.includes("http") || name.includes("request")) { return "api"; } if (name.includes("data") || name.includes("query") || name.includes("search")) { return "data"; } if (name.includes("auth") || name.includes("login") || name.includes("token")) { return "authentication"; } if (name.includes("deploy") || name.includes("build") || name.includes("ci")) { return "deployment"; } return "general"; } /** * Validate a tool */ validateTool(toolInfo) { const errors = []; const warnings = []; // Use centralized validation for name const nameError = validateToolName(toolInfo.name); if (nameError) { errors.push(nameError.message); } // Use centralized validation for description const descriptionError = validateToolDescription(toolInfo.description); if (descriptionError) { warnings.push(descriptionError.message); } if (!toolInfo.serverId) { errors.push("Server ID is required"); } // Schema validation if (toolInfo.inputSchema) { try { JSON.stringify(toolInfo.inputSchema); } catch { errors.push("Input schema is not valid JSON"); } } // Infer metadata const metadata = { category: typeof toolInfo.metadata?.category === "string" ? toolInfo.metadata.category : "general", complexity: this.inferComplexity(toolInfo), requiresAuth: this.inferAuthRequirement(toolInfo), isDeprecated: typeof toolInfo.metadata?.deprecated === "boolean" ? toolInfo.metadata.deprecated : false, }; return { isValid: errors.length === 0, errors, warnings, metadata, }; } /** * Infer tool complexity */ inferComplexity(toolInfo) { const schema = toolInfo.inputSchema; if (!schema || !schema.properties) { return "simple"; } const propertyCount = Object.keys(schema.properties).length; if (propertyCount <= 2) { return "simple"; } else if (propertyCount <= 5) { return "moderate"; } else { return "complex"; } } /** * Infer if tool requires authentication */ inferAuthRequirement(toolInfo) { const name = toolInfo.name.toLowerCase(); const description = toolInfo.description.toLowerCase(); return (name.includes("auth") || name.includes("login") || name.includes("token") || description.includes("authentication") || description.includes("credentials") || description.includes("permission")); } /** * Execute a tool */ async executeTool(toolName, serverId, client, parameters, options = {}) { const startTime = Date.now(); try { const toolKey = this.createToolKey(serverId, toolName); const toolInfo = this.toolRegistry.get(toolKey); if (!toolInfo) { throw new Error(`Tool '${toolName}' not found for server '${serverId}'`); } if (!toolInfo.isAvailable) { throw new Error(`Tool '${toolName}' is not available`); } // Validate input parameters if requested. Validation coerces // recoverable mismatches (numeric strings for number params, "true" // for booleans, JSON-encoded objects/arrays) instead of rejecting — // a rejection here costs the agent loop a full model round-trip. let effectiveParameters = parameters; if (options.validateInput !== false) { effectiveParameters = this.validateToolParameters(toolInfo, parameters); } mcpLogger.debug(`[ToolDiscoveryService] Executing tool: ${toolName} on ${serverId}`, { parameters: effectiveParameters, }); // Create circuit breaker for tool execution const effectiveTimeout = options.timeout || DEFAULT_TOOL_TIMEOUT; const circuitBreaker = globalCircuitBreakerManager.getBreaker(`tool-execution-${serverId}-${toolName}`, { failureThreshold: 3, resetTimeout: 30000, operationTimeout: effectiveTimeout, }); // Execute tool with circuit breaker protection. `recordResolvedFailure` // lets the callback below tell the breaker that a *resolved* MCP // isError result is a failure — the MCP client does not throw on a // protocol error, so without this the breaker would only ever see a // clean resolve and could never open for a tool that fails this way. let isErrorResultDetected = false; const result = await circuitBreaker.execute(async (recordResolvedFailure) => { return mcpTracer.startActiveSpan("neurolink.mcp.callTool", { kind: SpanKind.CLIENT, attributes: { "mcp.server_id": serverId, "mcp.tool_name": toolName, "mcp.timeout_ms": effectiveTimeout, // Curator P1-4: Langfuse observations rely on ai.*/gen_ai.* // attributes for tool name and I/O previews. Provide them so // the SPAN observation in Langfuse is legible without // timestamp-joining against the parent ai.toolCall. Redact // parameters via the existing secret-stripping helper so // tokens/credentials/paths don't leave the process. "ai.tool.name": toolName, "gen_ai.tool.name": toolName, "gen_ai.request": safeJsonStringify({ name: toolName, arguments: redactForPreview(effectiveParameters), }, 2048), }, }, async (callSpan) => { try { const timeout = effectiveTimeout; // Pass the timeout as MCP RequestOptions too: without it the // SDK applies its own DEFAULT_REQUEST_TIMEOUT_MSEC (60s), so a // configured server timeout above 60s never took effect — the // SDK aborted first. The SDK timeout also cancels the transport // request and sends a cancellation notification, which the // outer Promise.race below (kept as a backstop) cannot do. const callResult = await withTimeout(client.callTool({ name: toolName, arguments: effectiveParameters, }, undefined, { timeout: effectiveTimeout }), timeout, new Error(`Tool execution timeout: ${toolName}`)); // Curator P0-1/P0-2: the MCP client does NOT throw on protocol // errors — it returns { isError: true, content: [...] }. Detect // that pattern so the span status reflects reality. const resultObj = callResult; if (resultObj && resultObj.isError === true) { const errorText = extractMcpErrorText(resultObj); callSpan.setStatus({ code: SpanStatusCode.ERROR, message: errorText || `Tool ${toolName} returned isError`, }); // NLK: count a resolved isError result as a breaker failure // and label completion telemetry as error below — without // throwing, so the protocol result is still returned as-is. isErrorResultDetected = true; recordResolvedFailure(errorText || `Tool ${toolName} returned isError`); } else { callSpan.setStatus({ code: SpanStatusCode.OK }); } // ── MCP output normalization ────────────────────────────────── // Intercept here — after receive, before cache, before memory, // before LLM context injection. Returns a compact surrogate when // the payload exceeds mcp.outputLimits.maxBytes. let resultForPreview = callResult; let resultForReturn = callResult; if (this.outputNormalizer) { try { const normalized = await this.outputNormalizer.normalize(callResult, { toolName, serverId }); callSpan.setAttribute("mcp.output.strategy", normalized.isExternalized ? "externalize" : "inline"); if (normalized.isExternalized) { callSpan.setAttribute("mcp.output.original_bytes", normalized.originalBytes); } resultForPreview = normalized.result; resultForReturn = normalized.result; } catch (normErr) { mcpLogger.warn(`[ToolDiscoveryService] McpOutputNormalizer failed for ` + `${toolName}: ${normErr instanceof Error ? normErr.message : String(normErr)} ` + `— returning raw result`); } } // ── end normalization ───────────────────────────────────────── // Curator P1-4: build gen_ai.response AFTER normalization so // large payloads use the compact surrogate instead of the raw // result (avoids redundant stringify + memory hit on payloads // that were specifically externalized to Redis). Redact via the // same secret-stripping path used for request parameters. callSpan.setAttribute("gen_ai.response", safeJsonStringify(redactForPreview(resultForPreview), 2048)); return resultForReturn; } catch (err) { callSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message, }); callSpan.recordException(err); throw err; } finally { callSpan.end(); } }); }); const duration = Date.now() - startTime; // Update tool statistics. A resolved isError result is a failed call // for completion telemetry purposes even though the wrapper below // still returns success:true / data:result unchanged — flipping // `success` here would make externalServerManager.executeTool() throw // instead of returning the resolved MCP error, which is exactly the // "thrown transport error" this fix must not introduce. this.updateToolStats(toolKey, !isErrorResultDetected, duration); // Validate output if requested if (options.validateOutput !== false) { this.validateToolOutput(result); } mcpLogger.debug(`[ToolDiscoveryService] Tool execution completed: ${toolName}`, { duration, hasContent: !!result?.content, }); return { success: true, data: result, isErrorResult: isErrorResultDetected, duration, metadata: { toolName, serverId, timestamp: Date.now(), }, }; } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); // Update tool statistics const toolKey = this.createToolKey(serverId, toolName); this.updateToolStats(toolKey, false, duration); // Circuit breaker open errors: return a structured isError result with // actionable details so AI models understand the tool is temporarily // unavailable and should NOT retry until the cooldown expires. if (error instanceof CircuitBreakerOpenError) { mcpLogger.warn(`[ToolDiscoveryService] Tool blocked by circuit breaker: ${toolName} on ${serverId}`, { breakerState: error.breakerState, retryAfter: error.retryAfter, retryAfterMs: error.retryAfterMs, failureCount: error.failureCount, }); return { success: false, error: error.message, data: { isError: true, content: [ { type: "text", text: `TOOL TEMPORARILY UNAVAILABLE: "${toolName}" has been disabled after ` + `${error.failureCount} failures. ` + `This is a circuit breaker protection — do NOT retry this tool. ` + `It will become available again after ${Math.ceil(error.retryAfterMs / 1000)} seconds ` + `(at ${error.retryAfter}). ` + `Instead, inform the user that the operation failed and suggest trying again later.`, }, ], }, duration, metadata: { toolName, serverId, timestamp: Date.now(), circuitBreaker: { state: error.breakerState, retryAfter: error.retryAfter, retryAfterMs: error.retryAfterMs, failureCount: error.failureCount, }, }, }; } mcpLogger.error(`[ToolDiscoveryService] Tool execution failed: ${toolName}`, error); return { success: false, error: errorMessage, duration, metadata: { toolName, serverId, timestamp: Date.now(), }, }; } } /** * Validate tool parameters */ validateToolParameters(toolInfo, parameters) { if (!toolInfo.inputSchema) { return parameters; // No schema to validate against } const schema = toolInfo.inputSchema; const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {}; const requiredProps = Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []; // The thrown message is fed back to the MODEL as the tool result, so it // restates the full contract — a bare "missing X" made weaker models // guess again and burn another loop step per attempt. const contract = () => Object.entries(properties) .map(([key, prop]) => `${key}${requiredProps.includes(key) ? "" : "?"}: ${prop.type ?? "any"}`) .join(", "); // Basic validation - check required properties const missing = requiredProps.filter((prop) => !(prop in parameters)); if (missing.length > 0) { throw new Error(`Missing required parameter${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}. ` + `Expected arguments: { ${contract()} }; received keys: [${Object.keys(parameters).join(", ")}]`); } // Type validation for properties — coerce recoverable mismatches first // (numeric strings, "true"/"false", JSON-encoded objects/arrays) so a // sloppy-but-unambiguous call executes instead of failing back to the // model. Only genuinely wrong types still throw. let coerced; for (const [propName, propSchema] of Object.entries(properties)) { if (propName in parameters) { const originalValue = parameters[propName]; const coercedValue = coerceType(originalValue, propSchema); if (coercedValue !== originalValue) { mcpLogger.debug(`[ToolDiscoveryService] Coerced parameter '${propName}' for tool '${toolInfo.name}': ${typeof originalValue} → ${typeof coercedValue}`); coerced = coerced ?? { ...parameters }; coerced[propName] = coercedValue; } this.validateParameterType(propName, (coerced ? coerced[propName] : originalValue), propSchema); } } return coerced ?? parameters; } /** * Validate parameter type */ validateParameterType(name, value, schema) { if (!schema.type) { return; // No type constraint } const expectedType = schema.type; const actualType = typeof value; switch (expectedType) { case "string": if (actualType !== "string") { throw new Error(`Parameter '${name}' must be a string, got ${actualType}`); } break; case "number": if (actualType !== "number") { throw new Error(`Parameter '${name}' must be a number, got ${actualType}`); } break; case "integer": // coerceType treats "integer" as distinct from "number"; without // this case an uncoercible value ("3.7", "abc") passed through to // the MCP server unvalidated. if (actualType !== "number" || !Number.isInteger(value)) { throw new Error(`Parameter '${name}' must be an integer, got ${actualType === "number" ? String(value) : actualType}`); } break; case "boolean": if (actualType !== "boolean") { throw new Error(`Parameter '${name}' must be a boolean, got ${actualType}`); } break; case "array": if (!Array.isArray(value)) { throw new Error(`Parameter '${name}' must be an array, got ${actualType}`); } break; case "object": if (actualType !== "object" || value === null || Array.isArray(value)) { throw new Error(`Parameter '${name}' must be an object, got ${actualType}`); } break; } } /** * Validate tool output with enhanced type safety */ validateToolOutput(result) { // GENERIC ERROR HANDLING FOR ALL MCP TOOLS // Different MCP servers return different error formats, so we should be permissive // and let the AI handle any response format instead of throwing errors // Only throw for truly invalid responses (null/undefined) if (isNullish(result)) { mcpLogger.debug("[ToolDiscoveryService] Tool returned null/undefined, treating as empty response"); // Even null responses can be valid for some tools - don't throw return; } // Log what we received for debugging, but don't validate specific formats mcpLogger.debug("[ToolDiscoveryService] Tool response received", { type: typeof result, isArray: Array.isArray(result), isObject: isObject(result), hasKeys: isObject(result) ? Object.keys(result).length : 0, fullResponse: result, // Log the complete response, not a truncated sample }); // COMPLETELY PERMISSIVE APPROACH: // - Any response format is valid (objects, strings, arrays, booleans, numbers) // - Even error responses are passed to the AI to handle // - The AI can interpret error messages and retry with different approaches // - This works with any MCP server regardless of their response format // No validation or throwing - let the AI handle everything return; } /** * Update tool statistics */ updateToolStats(toolKey, success, duration) { const toolInfo = this.toolRegistry.get(toolKey); if (!toolInfo) { return; } toolInfo.stats.totalCalls++; toolInfo.lastCalled = new Date(); toolInfo.stats.lastExecutionTime = duration; if (success) { toolInfo.stats.successfulCalls++; } else { toolInfo.stats.failedCalls++; } // Update average execution time const totalTime = toolInfo.stats.averageExecutionTime * (toolInfo.stats.totalCalls - 1) + duration; toolInfo.stats.averageExecutionTime = totalTime / toolInfo.stats.totalCalls; } /** * Get tool by name and server */ getTool(toolName, serverId) { const toolKey = this.createToolKey(serverId, toolName); return this.toolRegistry.get(toolKey); } /** * Get all tools for a server */ getServerTools(serverId) { const serverTools = this.serverToolStorage.get(serverId); if (serverTools) { return serverTools.map((tool) => ({ name: tool.name, description: tool.description, serverId, inputSchema: tool.inputSchema, isAvailable: true, stats: { totalCalls: 0, successfulCalls: 0, failedCalls: 0, averageExecutionTime: 0, lastExecutionTime: 0, }, })); } // Fallback to legacy storage const tools = []; const serverToolNames = this.serverTools.get(serverId); if (serverToolNames) { for (const toolName of serverToolNames) { const toolKey = this.createToolKey(serverId, toolName); const toolInfo = this.toolRegistry.get(toolKey); if (toolInfo) { tools.push(toolInfo); } } } return tools; } /** * Get all registered tools */ getAllTools() { const allTools = []; // Add tools from server-based storage (preferred) for (const [serverId, serverTools] of this.serverToolStorage.entries()) { for (const tool of serverTools) { allTools.push({ name: tool.name, description: tool.description, serverId, inputSchema: tool.inputSchema, isAvailable: true, stats: { totalCalls: 0, successfulCalls: 0, failedCalls: 0, averageExecutionTime: 0, lastExecutionTime: 0, }, }); } } // Fallback to legacy storage for any tools not in server-based storage const legacyTools = Array.from(this.toolRegistry.values()).filter((tool) => !allTools.some((t) => t.name === tool.name && t.serverId === tool.serverId)); return [...allTools, ...legacyTools]; } /** * Clear tools for a server */ clearServerTools(serverId) { const serverTools = this.serverToolStorage.get(serverId); if (serverTools) { // Emit unregistered events for server-based tools for (const tool of serverTools) { this.emit("toolUnregistered", { serverId, toolName: tool.name, timestamp: new Date(), }); } this.serverToolStorage.delete(serverId); } // Legacy cleanup const serverToolNames = this.serverTools.get(serverId); if (serverToolNames) { for (const toolName of serverToolNames) { const toolKey = this.createToolKey(serverId, toolName); this.toolRegistry.delete(toolKey); // Emit tool unregistered event (only if not already emitted above) if (!serverTools || !serverTools.find((t) => t.name === toolName)) { this.emit("toolUnregistered", { serverId, toolName, timestamp: new Date(), }); } } this.serverTools.delete(serverId); } mcpLogger.debug(`[ToolDiscoveryService] Cleared tools for server: ${serverId}`); } /** * Update tool availability */ updateToolAvailability(toolName, serverId, isAvailable) { const toolKey = this.createToolKey(serverId, toolName); const toolInfo = this.toolRegistry.get(toolKey); if (toolInfo) { toolInfo.isAvailable = isAvailable; mcpLogger.debug(`[ToolDiscoveryService] Updated availability for ${toolName}: ${isAvailable}`); } } /** * Create tool key for registry */ createToolKey(serverId, toolName) { return `${serverId}:${toolName}`; } /** * Race an operation against a timeout, clearing the timer on either * outcome. A bare `Promise.race([operation, timeoutPromise])` leaves the * timeout's `setTimeout` handle ref'd and pending until it fires even * after `operation` already won the race — on the (common) success path * that stray timer holds the event loop open for the full timeout * duration for no reason. */ async raceWithTimeout(operation, timeout, message) { let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(message)); }, timeout); }); try { return await Promise.race([operation, timeoutPromise]); } finally { clearTimeout(timeoutId); } } /** * Destroy the tool discovery service and clean up all resources * This method should be called when the service is no longer needed * to prevent memory leaks from accumulated event listeners * * @example * ```typescript * const service = new ToolDiscoveryService(); * // ... use the service ... * service.destroy(); // Clean up when done * ``` */ destroy() { mcpLogger.debug("[ToolDiscoveryService] Starting cleanup..."); // Clear all event listeners to prevent memory leaks this.removeAllListeners(); // Clear all internal data structures this.serverToolStorage.clear(); this.toolRegistry.clear(); this.serverTools.clear(); this.discoveryInProgress.clear(); mcpLogger.debug("[ToolDiscoveryService] Destroyed and cleaned up"); } /** * Reset statistics for all tools * This clears execution counts, timing data, and other statistics * while preserving the tool registrations themselves */ resetStatistics() { for (const toolInfo of this.toolRegistry.values()) { toolInfo.stats = { totalCalls: 0, successfulCalls: 0, failedCalls: 0, averageExecutionTime: 0, lastExecutionTime: 0, }; toolInfo.lastCalled = undefined; } mcpLogger.debug("[ToolDiscoveryService] Statistics reset for all tools"); } /** * Get the count of active event listeners * Useful for monitoring potential memory leaks */ getListenerCount() { const events = [ "discoveryCompleted", "discoveryFailed", "toolRegistered", "toolUnregistered", ]; let total = 0; for (const event of events) { total += this.listenerCount(event); } return total; } /** * Get discovery statistics */ getStatistics() { const toolsByServer = {}; const toolsByCategory = {}; let availableTools = 0; let unavailableTools = 0; for (const toolInfo of this.toolRegistry.values()) { // Count by server toolsByServer[toolInfo.serverId] = (toolsByServer[toolInfo.serverId] || 0) + 1; // Count by category const category = typeof toolInfo.metadata?.category === "string" ? toolInfo.metadata.category : "unknown"; toolsByCategory[category] = (toolsByCategory[category] || 0) + 1; // Count availability if (toolInfo.isAvailable) { availableTools++; } else { unavailableTools++; } } return { totalTools: this.toolRegistry.size, availableTools, unavailableTools, totalServers: this.serverTools.size, toolsByServer, toolsByCategory, }; } }