UNPKG

@juspay/neurolink

Version:

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

662 lines (661 loc) 25 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 } from "./mcpCircuitBreaker.js"; import { isObject, isNullish, } from "../utils/typeUtils.js"; import { validateToolName, validateToolDescription, } from "../utils/parameterValidation.js"; /** * 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(); constructor() { super(); } /** * Discover tools from an external MCP server */ async discoverTools(serverId, client, timeout = 10000) { 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); 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); 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 listToolsPromise = client.listTools(); const timeoutPromise = this.createTimeoutPromise(timeout, "Tool discovery timeout"); const result = await Promise.race([listToolsPromise, timeoutPromise]); 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); // 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()); } this.serverTools.get(serverId).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 if (options.validateInput !== false) { this.validateToolParameters(toolInfo, parameters); } mcpLogger.debug(`[ToolDiscoveryService] Executing tool: ${toolName} on ${serverId}`, { parameters, }); // Create circuit breaker for tool execution const circuitBreaker = globalCircuitBreakerManager.getBreaker(`tool-execution-${serverId}-${toolName}`, { failureThreshold: 3, resetTimeout: 30000, operationTimeout: options.timeout || 30000, }); // Execute tool with circuit breaker protection const result = await circuitBreaker.execute(async () => { const timeout = options.timeout || 30000; const executePromise = client.callTool({ name: toolName, arguments: parameters, }); const timeoutPromise = this.createTimeoutPromise(timeout, `Tool execution timeout: ${toolName}`); return await Promise.race([executePromise, timeoutPromise]); }); const duration = Date.now() - startTime; // Update tool statistics this.updateToolStats(toolKey, true, 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, 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); 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; // No schema to validate against } // Basic validation - check required properties const schema = toolInfo.inputSchema; if (schema.required && Array.isArray(schema.required)) { for (const requiredProp of schema.required) { if (typeof requiredProp === "string" && !(requiredProp in parameters)) { throw new Error(`Missing required parameter: ${requiredProp}`); } } } // Type validation for properties if (schema.properties) { for (const [propName, propSchema] of Object.entries(schema.properties)) { if (propName in parameters) { this.validateParameterType(propName, parameters[propName], propSchema); } } } } /** * 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 "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}`; } /** * Create timeout promise */ createTimeoutPromise(timeout, message) { return new Promise((_, reject) => { setTimeout(() => { reject(new Error(message)); }, timeout); }); } /** * 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, }; } }