@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
JavaScript
/**
* 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,
};
}
}