UNPKG

@toolprint/hypertool-mcp

Version:

HyperTool MCP proxy server for routing requests between clients and multiple underlying MCP servers

683 lines • 26.6 kB
/** * Tool discovery service implementation */ import { EventEmitter } from "events"; import { ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js"; import { createChildLogger } from "../utils/logging.js"; const logger = createChildLogger({ module: "discovery/service" }); import { DEFAULT_DISCOVERY_CONFIG, } from "./types.js"; import { ToolCache } from "./cache.js"; import { ToolLookupManager } from "./lookup.js"; import { ToolHashUtils, ToolHashManager } from "./hashUtils.js"; import { output } from "../utils/output.js"; /** * Tool discovery engine implementation */ export class ToolDiscoveryEngine extends EventEmitter { connectionManager; config; cache; lookupManager; hashManager; isInitialized = false; isStarted = false; refreshTimer; serverStates = new Map(); discoveryStats; constructor(connectionManager) { super(); this.connectionManager = connectionManager; this.config = { ...DEFAULT_DISCOVERY_CONFIG }; this.cache = new ToolCache(this.config); this.lookupManager = new ToolLookupManager(); this.hashManager = new ToolHashManager(); this.discoveryStats = this.initializeStats(); this.setupConnectionEvents(); } /** * Initialize the discovery engine */ async initialize(config = {}) { if (this.isInitialized) { throw new Error("Tool discovery engine is already initialized"); } this.config = { ...DEFAULT_DISCOVERY_CONFIG, ...config }; this.cache = new ToolCache(this.config); this.isInitialized = true; // Initial discovery if auto-discovery is enabled if (this.config.autoDiscovery) { await this.discoverTools(); } this.emit("initialized", { config: this.config }); } /** * Discover tools from a specific server or all connected servers */ async discoverTools(serverName) { this.ensureInitialized(); const startTime = Date.now(); const servers = serverName ? [serverName] : this.connectionManager.getConnectedServers(); const allDiscoveredTools = []; for (const server of servers) { try { const tools = await this.discoverServerTools(server); allDiscoveredTools.push(...tools); this.updateServerState(server, { isConnected: true, lastDiscovery: new Date(), toolCount: tools.length, tools, lastError: undefined, }); } catch (error) { logger.error(`Failed to discover tools from server "${server}":`, error); this.updateServerState(server, { isConnected: false, lastError: error.message, }); } } // Update discovery stats const discoveryTime = Date.now() - startTime; this.updateDiscoveryStats(discoveryTime, allDiscoveredTools.length); this.emit("toolsDiscovered", { serverName, toolCount: allDiscoveredTools.length, tools: allDiscoveredTools, }); return allDiscoveredTools; } /** * Discover tools from a specific server */ async discoverServerTools(serverName) { const connection = this.connectionManager.getConnection(serverName); if (!connection || !connection.isConnected()) { throw new Error(`Server "${serverName}" is not connected`); } // Use proper SDK client to list tools const client = connection.client; if (!client || !client.sdkClient) { throw new Error(`No SDK client available for server "${serverName}"`); } const toolsResult = await client.listTools(); const toolDefinitions = toolsResult.tools || []; logger.debug(`Server ${serverName} returned ${toolDefinitions.length} tools`, { serverName, toolCount: toolDefinitions.length, toolNames: toolDefinitions.map((t) => t.name), hasClient: !!client, hasSDKClient: !!client.sdkClient, }); const discoveredTools = []; for (const toolDef of toolDefinitions) { const namespacedName = this.createNamespacedName(serverName, toolDef.name); // Create tool with proper hashing const discoveredTool = ToolHashUtils.createHashedTool(toolDef, serverName, namespacedName); discoveredTools.push(discoveredTool); // Add to lookup manager this.lookupManager.addTool(discoveredTool); // Add to hash history this.hashManager.addToHistory(discoveredTool); // Cache the tool await this.cache.set(namespacedName, discoveredTool); } return discoveredTools; } /** * Get a tool by its name or namespaced name */ async getToolByName(name) { this.ensureInitialized(); // Try direct lookup first let tool = await this.cache.get(name); if (tool) { return tool; } // Try with namespace prefix for each connected server for (const serverName of this.connectionManager.getConnectedServers()) { const namespacedName = this.createNamespacedName(serverName, name); tool = await this.cache.get(namespacedName); if (tool) { return tool; } } return null; } /** * Output tool server status */ async outputToolServerStatus() { this.ensureInitialized(); // Show tools by server in debug mode const toolsByServerStr = []; const toolsByServer = {}; this.getAvailableTools().forEach((tool) => { toolsByServer[tool.serverName] = (toolsByServer[tool.serverName] || 0) + 1; }); Object.entries(toolsByServer).forEach(([serverName, count]) => { toolsByServerStr.push(`\t- ${serverName}: ${count} tool${count !== 1 ? "s" : ""}`); }); output.info(`Tools discovered by server:\n${toolsByServerStr.join("\n")}`, true); } /** * Search for tools matching criteria */ async searchTools(options) { this.ensureInitialized(); // Use lookup manager for efficient searching const searchQuery = { name: options.namePattern, server: options.serverName, fuzzy: true, }; const results = this.lookupManager.search(searchQuery); // Filter by connection status if needed if (options.connectedOnly) { return results .filter((result) => this.connectionManager.isServerConnected(result.tool.serverName)) .map((result) => result.tool); } return results.map((result) => result.tool); } /** * Advanced search with relevance scoring */ searchToolsWithScoring(options) { this.ensureInitialized(); const searchQuery = { name: options.namePattern, server: options.serverName, keywords: options.keywords, fuzzy: true, }; const results = this.lookupManager.search(searchQuery); // Filter by connection status if needed if (options.connectedOnly) { return results.filter((result) => this.connectionManager.isServerConnected(result.tool.serverName)); } return results; } /** * Get all available tools */ getAvailableTools(connectedOnly = true) { this.ensureInitialized(); const allTools = []; for (const [, state] of this.serverStates) { if (connectedOnly && !state.isConnected) { continue; } allTools.push(...state.tools); } return allTools; } /** * Refresh tool cache for a specific server or all servers */ async refreshCache(serverName) { this.ensureInitialized(); if (serverName) { await this.cache.clearServer(serverName); } else { await this.cache.clear(); } await this.discoverTools(serverName); } /** * Get discovery statistics */ getStats() { return { ...this.discoveryStats }; } /** * Get server tool states */ getServerStates() { return Array.from(this.serverStates.values()); } /** * Clear cache for a specific server or all servers */ async clearCache(serverName) { this.ensureInitialized(); if (serverName) { await this.cache.clearServer(serverName); this.serverStates.delete(serverName); } else { await this.cache.clear(); this.serverStates.clear(); } } /** * Start automatic discovery and refresh */ async start() { this.ensureInitialized(); if (this.isStarted) { return; } this.isStarted = true; // Start periodic refresh if (this.config.refreshInterval > 0) { this.refreshTimer = setInterval(() => { this.discoverTools().catch((error) => { logger.error("Periodic tool discovery failed:", error); }); }, this.config.refreshInterval); this.refreshTimer?.unref(); // Allow CLI exit (see docs/bugs/process-exit-unref.md) } this.emit("started"); } /** * Stop automatic discovery and refresh */ async stop() { if (!this.isStarted) { return; } this.isStarted = false; if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = undefined; } this.emit("stopped"); } /** * Create namespaced tool name */ createNamespacedName(serverName, toolName) { return `${serverName}${this.config.namespaceSeparator}${toolName}`; } /** * Update server state */ updateServerState(serverName, updates) { const currentState = this.serverStates.get(serverName) || { serverName, isConnected: false, toolCount: 0, tools: [], }; this.serverStates.set(serverName, { ...currentState, ...updates, }); } /** * Handle MCP tools list changed notification */ async handleToolsListChanged(serverName) { logger.info(`Received tools list changed notification from server: ${serverName}`); try { // Get current tools for comparison const previousTools = this.getToolsByServer(serverName); // Discover new tools const newTools = await this.discoverServerTools(serverName); // Detect changes const changes = ToolHashUtils.detectToolChanges(previousTools, newTools); const summary = ToolHashUtils.summarizeChanges(changes); logger.info(`Tool changes detected for ${serverName}:`, summary); // Update lookup manager this.lookupManager.clearServer(serverName); for (const tool of newTools) { this.lookupManager.addTool(tool); } // Update cache await this.cache.clearServer(serverName); for (const tool of newTools) { await this.cache.set(tool.namespacedName, tool); } // Update server state this.updateServerState(serverName, { tools: newTools, toolCount: newTools.length, lastDiscovery: new Date(), serverToolsHash: ToolHashUtils.calculateServerToolsHash(newTools), }); // Emit change event const event = { serverName, changes, summary, newTools, timestamp: new Date(), }; this.emit("toolsChanged", event); } catch (error) { logger.error(`Failed to handle tools list changed for server "${serverName}":`, error); this.emit("error", error); } } /** * Get tools for a specific server */ getToolsByServer(serverName) { const serverState = this.serverStates.get(serverName); return serverState?.tools || []; } /** * Setup connection manager event handlers */ setupConnectionEvents() { this.connectionManager.on("connected", (event) => { // Set up MCP notification handlers for this connection const connection = this.connectionManager.getConnection(event.serverName); if (connection?.client) { // Set up tool list change notification handler using SDK client const sdkClient = connection.client.sdkClient; if (sdkClient) { // Register notification handler for tool list changes sdkClient.setNotificationHandler(ToolListChangedNotificationSchema, async ( // eslint-disable-next-line @typescript-eslint/no-unused-vars _notification) => { try { await this.handleToolsListChanged(event.serverName); } catch (error) { logger.error(`Failed to handle tools list changed for ${event.serverName}:`, error); } }); } } if (this.config.autoDiscovery && this.isInitialized) { this.discoverTools(event.serverName).catch((error) => { logger.error(`Auto-discovery failed for server "${event.serverName}":`, error); }); } }); this.connectionManager.on("disconnected", (event) => { this.updateServerState(event.serverName, { isConnected: false, }); // Clear tools for disconnected server from lookup this.lookupManager.clearServer(event.serverName); }); // Listen for health-based tool availability changes this.connectionManager.on("serverToolsUnavailable", (event) => { logger.info(`Server "${event.serverName}" tools unavailable due to health: ${event.reason}`); this.updateServerState(event.serverName, { isConnected: false, lastError: event.error?.message, }); // Clear tools for unhealthy server from lookup this.lookupManager.clearServer(event.serverName); // Emit tools unavailable event this.emit("toolsUnavailable", { serverName: event.serverName, reason: event.reason, error: event.error, }); }); this.connectionManager.on("serverToolsAvailable", (event) => { logger.info(`Server "${event.serverName}" tools available again, recovered from: ${event.recoveredFrom}`); // Trigger tool rediscovery if auto-discovery is enabled if (this.config.autoDiscovery && this.isInitialized) { this.discoverTools(event.serverName).catch((error) => { logger.error(`Auto-discovery failed for recovered server "${event.serverName}":`, error); }); } // Emit tools available event this.emit("toolsAvailable", { serverName: event.serverName, recoveredFrom: event.recoveredFrom, }); }); // Listen for MCP tools list changed notifications this.setupMCPNotificationHandlers(); } /** * Setup MCP notification handlers for tool changes */ setupMCPNotificationHandlers() { // Note: This would be called when connections are established // For now, we'll set up handlers when connections are made } /** * Initialize discovery statistics */ initializeStats() { return { totalServers: 0, connectedServers: 0, totalTools: 0, cacheHitRate: 0, averageDiscoveryTime: 0, toolsByServer: {}, }; } /** * Update discovery statistics */ updateDiscoveryStats(discoveryTime, toolCount) { const connectedServers = this.connectionManager.getConnectedServers(); this.discoveryStats = { totalServers: this.serverStates.size, connectedServers: connectedServers.length, totalTools: toolCount, cacheHitRate: 0, // TODO: Calculate from cache stats lastDiscoveryTime: new Date(), averageDiscoveryTime: discoveryTime, toolsByServer: Object.fromEntries(Array.from(this.serverStates.entries()).map(([name, state]) => [ name, state.toolCount, ])), }; } /** * Resolve a tool reference and return tool + server metadata * Uses optimized lookup maps and strict validation by default * * @param ref Tool reference with namespacedName and/or refId * @param options.allowStaleRefs If true, allows mismatched tools to continue (INSECURE) */ resolveToolReference(ref, options) { this.ensureInitialized(); const allowStaleRefs = options?.allowStaleRefs ?? false; // Default to secure mode const warnings = []; const errors = []; if (!ref.namespacedName && !ref.refId) { return { exists: false, namespacedNameMatch: false, refIdMatch: false, warnings: [], errors: ["Tool reference must have either namespacedName or refId"], serverStatus: undefined, }; } const allTools = this.getAvailableTools(true); // Helper function to get server status from connection manager const getServerStatus = (serverName) => { return this.connectionManager?.status?.[serverName]; }; // Build optimized lookup maps for O(1) access const toolByNamespacedName = new Map(); const toolByRefId = new Map(); for (const tool of allTools) { toolByNamespacedName.set(tool.namespacedName, tool); toolByRefId.set(tool.toolHash, tool); } // Attempt resolution by both methods (refId is more reliable) const toolByRef = ref.refId ? toolByRefId.get(ref.refId) : undefined; const toolByName = ref.namespacedName ? toolByNamespacedName.get(ref.namespacedName) : undefined; // Handle different resolution scenarios if (!toolByName && !toolByRef) { // Neither identifier found the tool return { exists: false, namespacedNameMatch: false, refIdMatch: false, warnings: [], errors: [ `Tool not found by any identifier: namespacedName='${ref.namespacedName}', refId='${ref.refId}'`, ], serverStatus: undefined, }; } if (toolByName && toolByRef) { // Both identifiers found tools - check if they're the same if (toolByName.namespacedName === toolByRef.namespacedName && toolByName.toolHash === toolByRef.toolHash) { // Perfect match - both identifiers point to same tool return { exists: true, tool: toolByName, serverName: toolByName.serverName, serverStatus: getServerStatus(toolByName.serverName), namespacedNameMatch: true, refIdMatch: true, warnings: [], errors: [], }; } else { // Conflict - identifiers point to different tools const conflictMsg = `Tool reference conflict: refId '${ref.refId}' points to tool '${toolByRef.namespacedName}', but namespacedName '${ref.namespacedName}' points to different tool.`; if (allowStaleRefs) { // INSECURE mode: Allow mismatch, prefer refId (more reliable) warnings.push(`${conflictMsg} Using refId match (more reliable). WARNING: This is insecure mode.`); return { exists: true, tool: toolByRef, serverName: toolByRef.serverName, serverStatus: getServerStatus(toolByRef.serverName), namespacedNameMatch: false, refIdMatch: true, warnings, errors: [], }; } else { // SECURE mode: Reject mismatch errors.push(`${conflictMsg} Tool reference rejected for security. Use allowStaleRefs=true to force.`); return { exists: false, namespacedNameMatch: false, refIdMatch: false, warnings: [], errors, serverStatus: undefined, }; } } } if (toolByRef) { // Found by refId but not by namespacedName (prefer refId - more reliable) const namespacedNameMatch = ref.namespacedName ? toolByRef.namespacedName === ref.namespacedName : true; if (!namespacedNameMatch) { const mismatchMsg = `Tool name changed: refId '${ref.refId}' found but namespacedName changed from '${ref.namespacedName}' to '${toolByRef.namespacedName}' (tool may have been renamed or moved)`; if (allowStaleRefs) { // INSECURE mode: Allow name mismatch warnings.push(`${mismatchMsg}. WARNING: This is insecure mode.`); return { exists: true, tool: toolByRef, serverName: toolByRef.serverName, serverStatus: getServerStatus(toolByRef.serverName), namespacedNameMatch: false, refIdMatch: true, warnings, errors: [], }; } else { // SECURE mode: Reject name mismatch errors.push(`${mismatchMsg}. Tool reference rejected for security. Use allowStaleRefs=true to force.`); return { exists: false, namespacedNameMatch: false, refIdMatch: false, warnings: [], errors, serverStatus: undefined, }; } } // Name matches or no name provided - safe to return return { exists: true, tool: toolByRef, serverName: toolByRef.serverName, serverStatus: getServerStatus(toolByRef.serverName), namespacedNameMatch, refIdMatch: true, warnings: [], errors: [], }; } if (toolByName) { // Found by namespacedName but not by refId (fallback - less reliable) const refIdMatch = ref.refId ? toolByName.toolHash === ref.refId : true; if (!refIdMatch) { const mismatchMsg = `Tool refId mismatch: '${ref.namespacedName}' found but refId changed from '${ref.refId}' to '${toolByName.toolHash}' (tool schema may have been updated)`; if (allowStaleRefs) { // INSECURE mode: Allow refId mismatch warnings.push(`${mismatchMsg}. WARNING: This is insecure mode.`); return { exists: true, tool: toolByName, serverName: toolByName.serverName, serverStatus: getServerStatus(toolByName.serverName), namespacedNameMatch: true, refIdMatch: false, warnings, errors: [], }; } else { // SECURE mode: Reject refId mismatch errors.push(`${mismatchMsg}. Tool reference rejected for security. Use allowStaleRefs=true to force.`); return { exists: false, namespacedNameMatch: false, refIdMatch: false, warnings: [], errors, serverStatus: undefined, }; } } // RefId matches or no refId provided - safe to return return { exists: true, tool: toolByName, serverName: toolByName.serverName, serverStatus: getServerStatus(toolByName.serverName), namespacedNameMatch: true, refIdMatch, warnings: [], errors: [], }; } // Should never reach here, but safety fallback return { exists: false, namespacedNameMatch: false, refIdMatch: false, warnings: [], errors: ["Unexpected error in tool resolution"], serverStatus: undefined, }; } /** * Ensure the engine is initialized */ ensureInitialized() { if (!this.isInitialized) { throw new Error("Tool discovery engine not initialized. Call initialize() first."); } } } //# sourceMappingURL=service.js.map