UNPKG

@the_cfdude/productboard-mcp

Version:

Model Context Protocol server for Productboard REST API with dynamic tool loading

633 lines (632 loc) 27.2 kB
/** * Dynamic tool registry with lazy loading support */ import { readFileSync } from 'fs'; import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { adaptParameters } from '../utils/parameter-adapter.js'; // ToolDefinition is now imported from types/tool-types.js /** * Dynamic tool registry that supports lazy loading */ export class ToolRegistry { manifest = null; toolLoaders = new Map(); loadedHandlers = new Map(); enabledCategories; maxLoadedHandlers = 100; // Maximum number of handlers to keep in memory handlerAccessCount = new Map(); lastAccessTime = new Map(); customTools = new Map(); constructor(enabledCategories = []) { this.enabledCategories = new Set(enabledCategories); } /** * Load tool manifest */ loadManifest(manifestPath) { try { const content = readFileSync(manifestPath, 'utf-8'); this.manifest = JSON.parse(content); } catch (error) { console.error('Failed to load tool manifest:', error); throw new McpError(ErrorCode.InternalError, 'Failed to load tool manifest'); } } /** * Register a tool loader function */ registerLoader(toolName, loader) { this.toolLoaders.set(toolName, loader); } /** * Register tool loaders from manifest */ async registerFromManifest() { if (!this.manifest) { throw new Error('Manifest not loaded'); } // First register loaders for static implementation tools for (const [category, catInfo] of Object.entries(this.manifest.categories)) { for (const toolName of catInfo.tools) { // Skip if category is not enabled if (this.enabledCategories.size > 0 && !this.enabledCategories.has(category)) { continue; } // Check if it's a static implementation tool const staticImplementationTools = [ 'create_feature', 'update_feature', 'delete_feature', 'get_features', 'get_feature', 'create_component', 'update_component', 'get_components', 'get_component', 'create_product', 'update_product', 'get_products', 'get_product', 'create_note', 'update_note', 'delete_note', 'get_notes', 'get_note', 'create_company', 'update_company', 'delete_company', 'get_companies', 'get_company', 'create_user', 'update_user', 'delete_user', 'get_users', 'get_user', 'create_release', 'update_release', 'delete_release', 'get_releases', 'get_release', 'create_release_group', 'update_release_group', 'delete_release_group', 'get_release_groups', 'get_release_group', 'create_webhook', 'list_webhooks', 'get_webhook', 'delete_webhook', 'create_objective', 'update_objective', 'delete_objective', 'get_objectives', 'get_objective', 'create_initiative', 'update_initiative', 'delete_initiative', 'get_initiatives', 'get_initiative', 'create_key_result', 'update_key_result', 'delete_key_result', 'get_key_results', 'get_key_result', 'get_custom_fields', 'get_custom_field', 'get_custom_fields_values', 'get_custom_field_value', 'set_custom_field_value', 'delete_custom_field_value', 'get_feature_statuses', ]; if (staticImplementationTools.includes(toolName)) { // Skip if already registered if (this.toolLoaders.has(toolName)) { continue; } // Register loader for static tool this.registerLoader(toolName, async () => { // Map category to module file const categoryMappings = { notes: 'notes', features: 'features', companies: 'companies', users: 'companies', releases: 'releases', webhooks: 'webhooks', objectives: 'objectives', 'custom-fields': 'custom-fields', 'plugin-integrations': 'plugin-integrations', 'jira-integrations': 'jira-integrations', // Add new dynamic categories performance: 'performance', 'bulk-operations': 'bulk-operations', 'context-aware': 'context-aware', search: 'search', }; // For components and products, they have their own module files let moduleFile = categoryMappings[category.toLowerCase()]; // Special handling for component/product tools if (toolName.includes('component')) { moduleFile = 'components'; } else if (toolName.includes('product')) { moduleFile = 'products'; } if (!moduleFile) { throw new Error(`No module mapping found for tool ${toolName} in category ${category}`); } const module = await import(`./${moduleFile}.js`); // Get the handler function name based on the module file const handlerName = `handle${moduleFile.charAt(0).toUpperCase() + moduleFile.slice(1).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())}Tool`; const handler = module[handlerName]; if (!handler) { throw new Error(`Handler ${handlerName} not found in ${moduleFile}.js`); } // Return a wrapper that calls the handler return async (args) => handler(toolName, args); }); } } } // Then register loaders for dynamic tools from manifest for (const [toolName, toolInfo] of Object.entries(this.manifest.tools)) { // Skip if category is not enabled if (this.enabledCategories.size > 0 && !this.enabledCategories.has(toolInfo.category)) { continue; } // Register lazy loader this.registerLoader(toolName, async () => { try { // Check if it's an existing tool in src/tools const categoryMappings = { notes: 'notes', features: 'features', companies: 'companies', users: 'companies', // User tools are in companies handler releases: 'releases', webhooks: 'webhooks', objectives: 'objectives', 'custom-fields': 'custom-fields', 'plugin-integrations': 'plugin-integrations', 'jira-integrations': 'jira-integrations', // Add new dynamic categories performance: 'performance', 'bulk-operations': 'bulk-operations', 'context-aware': 'context-aware', search: 'search', // Handle category name mismatches from manifest followers: 'notes', components: 'components', products: 'products', statuses: 'features', hierarchyentitiescustomfields: 'custom-fields', hierarchyentitiescustomfieldsvalues: 'custom-fields', pluginintegrations: 'plugin-integrations', pluginintegrationconnections: 'plugin-integrations', jiraintegrations: 'jira-integrations', jiraintegrationconnections: 'jira-integrations', releasegroups: 'releases', featurereleaseassignments: 'releases', keyresults: 'objectives', initiatives: 'objectives', // Handle spaces and special characters 'companies & users': 'companies', 'key results': 'objectives', 'custom fields': 'custom-fields', 'plugin integrations': 'plugin-integrations', 'jira integrations': 'jira-integrations', 'product hierarchy': 'features', 'releases & release groups': 'releases', 'hierarchy entity custom fields': 'custom-fields', 'hierarchy entity custom fields values': 'custom-fields', 'plugin integration connections': 'plugin-integrations', 'jira integration connections': 'jira-integrations', 'release groups': 'releases', 'feature release assignments': 'releases', }; const moduleFile = categoryMappings[toolInfo.category.toLowerCase()]; if (moduleFile) { // Import from existing tools const module = await import(`./${moduleFile}.js`); // Get the handler function name based on the module file const handlerName = `handle${moduleFile.charAt(0).toUpperCase() + moduleFile.slice(1).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())}Tool`; const handler = module[handlerName]; if (!handler) { throw new Error(`Handler ${handlerName} not found in ${moduleFile}.js`); } // Return a wrapper that calls the handler return async (args) => handler(toolName, args); } else { // Import from generated tools const categoryFile = toolInfo.category .toLowerCase() .replace(/&/g, 'and') .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); // Use process.cwd() to get the project root const projectRoot = process.cwd(); const module = await import(`${projectRoot}/generated/tools/${categoryFile}.js`); // Get the handler function name const handlerName = `handle${toolInfo.category .split(/[\s&]+/) .filter(w => w.length > 0) .map(w => w.charAt(0).toUpperCase() + w.slice(1)) .join('')}Tool`; const handler = module[handlerName]; if (!handler) { throw new Error(`Handler ${handlerName} not found in generated/${categoryFile}.js`); } // Return a wrapper that calls the handler return async (args) => handler(toolName, args); } } catch (error) { console.error(`Failed to load handler for ${toolName}:`, error); throw error; } }); } } /** * Register a custom tool (not from manifest/OpenAPI) */ registerCustomTool(name, tool) { this.customTools.set(name, tool); // Register the tool handler this.registerLoader(name, async () => { const { handleSearchTool } = await import('./search.js'); return async (args) => handleSearchTool(name, args); }); } /** * Get tool definitions for enabled categories */ async getToolDefinitions() { if (!this.manifest) return []; const definitions = []; // Tools that have static implementations with their own inputSchemas const staticImplementationTools = [ 'create_feature', 'update_feature', 'delete_feature', 'get_features', 'get_feature', 'create_component', 'update_component', 'get_components', 'get_component', 'create_product', 'update_product', 'get_products', 'get_product', 'create_note', 'update_note', 'delete_note', 'get_notes', 'get_note', 'create_company', 'update_company', 'delete_company', 'get_companies', 'get_company', 'create_user', 'update_user', 'delete_user', 'get_users', 'get_user', 'create_release', 'update_release', 'delete_release', 'get_releases', 'get_release', 'create_release_group', 'update_release_group', 'delete_release_group', 'get_release_groups', 'get_release_group', 'create_webhook', 'list_webhooks', 'get_webhook', 'delete_webhook', 'create_objective', 'update_objective', 'delete_objective', 'get_objectives', 'get_objective', 'create_initiative', 'update_initiative', 'delete_initiative', 'get_initiatives', 'get_initiative', 'create_key_result', 'update_key_result', 'delete_key_result', 'get_key_results', 'get_key_result', 'get_custom_fields', 'get_custom_field', 'get_custom_fields_values', 'get_custom_field_value', 'set_custom_field_value', 'delete_custom_field_value', 'get_feature_statuses', ]; // Load static tool definitions from modules const staticToolsMap = new Map(); // Helper to load tool definitions from a module const loadToolDefinitions = async (moduleName, setupFunctionName) => { try { const module = await import(`./${moduleName}.js`); const setupFunction = module[setupFunctionName]; if (setupFunction) { const tools = setupFunction(); tools.forEach((tool) => { staticToolsMap.set(tool.name, tool); }); } } catch (error) { console.error(`Failed to load static tools from ${moduleName}:`, error); } }; // Load all static tool definitions await Promise.all([ loadToolDefinitions('notes', 'setupNotesTools'), loadToolDefinitions('features', 'setupFeaturesTools'), loadToolDefinitions('components', 'setupComponentsTools'), loadToolDefinitions('products', 'setupProductsTools'), loadToolDefinitions('companies', 'setupCompaniesTools'), loadToolDefinitions('users', 'setupUsersTools'), loadToolDefinitions('releases', 'setupReleasesTools'), loadToolDefinitions('webhooks', 'setupWebhooksTools'), loadToolDefinitions('objectives', 'setupObjectivesTools'), loadToolDefinitions('custom-fields', 'setupCustomFieldsTools'), loadToolDefinitions('plugin-integrations', 'setupPluginIntegrationsTools'), loadToolDefinitions('jira-integrations', 'setupJiraIntegrationsTools'), ]); // Debug log what tools were loaded console.error(`📦 Loaded ${staticToolsMap.size} static tool definitions`); // First, add all static tool definitions for (const [toolName, toolDef] of staticToolsMap) { // Skip if category is not enabled if (this.enabledCategories.size > 0) { // Find which category this tool belongs to let toolCategory = ''; for (const [category, catInfo] of Object.entries(this.manifest.categories)) { if (catInfo.tools.includes(toolName)) { toolCategory = category; break; } } if (toolCategory && !this.enabledCategories.has(toolCategory)) { continue; } } // Add the static tool definition definitions.push(toolDef); } // Then add dynamic tools from manifest for (const [toolName, toolInfo] of Object.entries(this.manifest.tools)) { // Skip if category is not enabled if (this.enabledCategories.size > 0 && !this.enabledCategories.has(toolInfo.category)) { continue; } // Skip if no loader registered if (!this.toolLoaders.has(toolName)) { continue; } // Check if this is a static implementation tool if (staticImplementationTools.includes(toolName)) { if (staticToolsMap.has(toolName)) { const staticDef = staticToolsMap.get(toolName); // Use the actual tool definition from the module definitions.push(staticDef); continue; } } // Build input schema from manifest for dynamic tools const properties = {}; // Filter nulls from parameter arrays const filteredRequiredParams = toolInfo.requiredParams.filter(param => param != null); const filteredOptionalParams = toolInfo.optionalParams.filter(param => param != null); // Add required parameters filteredRequiredParams.forEach(param => { if (param === 'body') { properties[param] = { type: 'object', description: `${param} parameter`, }; } else if (param === 'status' || param === 'owner' || param === 'parent' || param === 'company' || param === 'user' || param === 'timeframe') { // These are object parameters properties[param] = { type: 'object', description: `${param} parameter`, }; } else { properties[param] = { type: 'string', description: `${param} parameter`, }; } }); // Add optional parameters filteredOptionalParams.forEach(param => { if (param === 'body') { properties[param] = { type: 'object', description: `${param} parameter (optional)`, }; } else if (param === 'includeRaw') { properties[param] = { type: 'boolean', description: `${param} parameter (optional)`, }; } else if (param === 'status' || param === 'owner' || param === 'parent' || param === 'company' || param === 'user' || param === 'timeframe') { // These are object parameters properties[param] = { type: 'object', description: `${param} parameter (optional)`, }; } else if (param === 'current' || param === 'target' || param === 'limit' || param === 'startWith' || param === 'pageLimit' || param === 'pageOffset') { // These are number parameters properties[param] = { type: 'number', description: `${param} parameter (optional)`, }; } else { properties[param] = { type: 'string', description: `${param} parameter (optional)`, }; } }); const toolDef = { name: toolName, description: toolInfo.description, inputSchema: { type: 'object', properties, additionalProperties: true, }, }; // Add required params to schema if any exist if (filteredRequiredParams.length > 0) { toolDef.inputSchema.required = filteredRequiredParams; } // Debug logging removed - handled by debugLog utility definitions.push(toolDef); } // Add custom tools (not from manifest/OpenAPI) for (const [, tool] of this.customTools) { definitions.push(tool); } // Debug logging removed - handled by debugLog utility return definitions; } /** * Execute a tool by name */ async executeTool(toolName, args) { // Check if handler is already loaded let handler = this.loadedHandlers.get(toolName); if (!handler) { // Get loader const loader = this.toolLoaders.get(toolName); if (!loader) { throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`); } // Load handler try { handler = await loader(); this.loadedHandlers.set(toolName, handler); // Check memory usage and cleanup if needed if (this.loadedHandlers.size > this.maxLoadedHandlers) { this.cleanupLeastUsedHandlers(); } } catch (error) { console.error(`Failed to load tool ${toolName}:`, error); throw new McpError(ErrorCode.InternalError, `Failed to load tool: ${error instanceof Error ? error.message : String(error)}`); } } // Track access for memory management this.handlerAccessCount.set(toolName, (this.handlerAccessCount.get(toolName) || 0) + 1); this.lastAccessTime.set(toolName, Date.now()); // Execute handler with error handling // Apply parameter adaptation before calling handler const adaptedArgs = adaptParameters(toolName, args); // Debug logging removed for production return await handler(adaptedArgs); } /** * Update enabled categories at runtime */ updateEnabledCategories(categories) { this.enabledCategories = new Set(categories); // Clear loaded handlers for disabled categories if (this.manifest) { for (const [toolName] of this.loadedHandlers.entries()) { const toolInfo = this.manifest.tools[toolName]; if (toolInfo && !this.enabledCategories.has(toolInfo.category)) { this.loadedHandlers.delete(toolName); } } } } /** * Get available categories */ getCategories() { if (!this.manifest) return []; return Object.values(this.manifest.categories); } /** * Get tools for a specific category */ getToolsForCategory(category) { if (!this.manifest) return []; return this.manifest.categories[category]?.tools || []; } /** * Cleanup least used handlers to prevent memory leaks */ cleanupLeastUsedHandlers() { const handlersToRemove = Math.floor(this.maxLoadedHandlers * 0.2); // Remove 20% of handlers // Sort handlers by last access time and access count const handlerStats = Array.from(this.loadedHandlers.keys()).map(name => ({ name, accessCount: this.handlerAccessCount.get(name) || 0, lastAccess: this.lastAccessTime.get(name) || 0, score: (this.handlerAccessCount.get(name) || 0) * 1000 + (this.lastAccessTime.get(name) || 0) / 1000000, })); handlerStats.sort((a, b) => a.score - b.score); // Remove least used handlers for (let i = 0; i < handlersToRemove && i < handlerStats.length; i++) { const { name } = handlerStats[i]; this.loadedHandlers.delete(name); this.handlerAccessCount.delete(name); this.lastAccessTime.delete(name); } } /** * Clear all loaded handlers (for testing or manual cleanup) */ clearLoadedHandlers() { this.loadedHandlers.clear(); this.handlerAccessCount.clear(); this.lastAccessTime.clear(); } }