UNPKG

n8n-mcp

Version:

Integration between n8n workflow automation and Model Context Protocol (MCP)

1,054 lines 95.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.N8NDocumentationMCPServer = void 0; const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const fs_1 = require("fs"); const path_1 = __importDefault(require("path")); const tools_1 = require("./tools"); const tools_n8n_manager_1 = require("./tools-n8n-manager"); const tools_n8n_friendly_1 = require("./tools-n8n-friendly"); const workflow_examples_1 = require("./workflow-examples"); const logger_1 = require("../utils/logger"); const node_repository_1 = require("../database/node-repository"); const database_adapter_1 = require("../database/database-adapter"); const property_filter_1 = require("../services/property-filter"); const task_templates_1 = require("../services/task-templates"); const enhanced_config_validator_1 = require("../services/enhanced-config-validator"); const property_dependencies_1 = require("../services/property-dependencies"); const simple_cache_1 = require("../utils/simple-cache"); const template_service_1 = require("../templates/template-service"); const workflow_validator_1 = require("../services/workflow-validator"); const n8n_api_1 = require("../config/n8n-api"); const n8nHandlers = __importStar(require("./handlers-n8n-manager")); const handlers_workflow_diff_1 = require("./handlers-workflow-diff"); const tools_documentation_1 = require("./tools-documentation"); const version_1 = require("../utils/version"); const node_utils_1 = require("../utils/node-utils"); const validation_schemas_1 = require("../utils/validation-schemas"); const protocol_version_1 = require("../utils/protocol-version"); class N8NDocumentationMCPServer { constructor() { this.db = null; this.repository = null; this.templateService = null; this.cache = new simple_cache_1.SimpleCache(); this.clientInfo = null; const envDbPath = process.env.NODE_DB_PATH; let dbPath = null; let possiblePaths = []; if (envDbPath && (envDbPath === ':memory:' || (0, fs_1.existsSync)(envDbPath))) { dbPath = envDbPath; } else { possiblePaths = [ path_1.default.join(process.cwd(), 'data', 'nodes.db'), path_1.default.join(__dirname, '../../data', 'nodes.db'), './data/nodes.db' ]; for (const p of possiblePaths) { if ((0, fs_1.existsSync)(p)) { dbPath = p; break; } } } if (!dbPath) { logger_1.logger.error('Database not found in any of the expected locations:', possiblePaths); throw new Error('Database nodes.db not found. Please run npm run rebuild first.'); } this.initialized = this.initializeDatabase(dbPath); logger_1.logger.info('Initializing n8n Documentation MCP server'); const apiConfigured = (0, n8n_api_1.isN8nApiConfigured)(); const totalTools = apiConfigured ? tools_1.n8nDocumentationToolsFinal.length + tools_n8n_manager_1.n8nManagementTools.length : tools_1.n8nDocumentationToolsFinal.length; logger_1.logger.info(`MCP server initialized with ${totalTools} tools (n8n API: ${apiConfigured ? 'configured' : 'not configured'})`); this.server = new index_js_1.Server({ name: 'n8n-documentation-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, }); this.setupHandlers(); } async initializeDatabase(dbPath) { try { this.db = await (0, database_adapter_1.createDatabaseAdapter)(dbPath); if (dbPath === ':memory:') { await this.initializeInMemorySchema(); } this.repository = new node_repository_1.NodeRepository(this.db); this.templateService = new template_service_1.TemplateService(this.db); logger_1.logger.info(`Initialized database from: ${dbPath}`); } catch (error) { logger_1.logger.error('Failed to initialize database:', error); throw new Error(`Failed to open database: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async initializeInMemorySchema() { if (!this.db) return; const schemaPath = path_1.default.join(__dirname, '../../src/database/schema.sql'); const schema = await fs_1.promises.readFile(schemaPath, 'utf-8'); const statements = schema.split(';').filter(stmt => stmt.trim()); for (const statement of statements) { if (statement.trim()) { this.db.exec(statement); } } } async ensureInitialized() { await this.initialized; if (!this.db || !this.repository) { throw new Error('Database not initialized'); } } setupHandlers() { this.server.setRequestHandler(types_js_1.InitializeRequestSchema, async (request) => { const clientVersion = request.params.protocolVersion; const clientCapabilities = request.params.capabilities; const clientInfo = request.params.clientInfo; logger_1.logger.info('MCP Initialize request received', { clientVersion, clientCapabilities, clientInfo }); this.clientInfo = clientInfo; const negotiationResult = (0, protocol_version_1.negotiateProtocolVersion)(clientVersion, clientInfo, undefined, undefined); (0, protocol_version_1.logProtocolNegotiation)(negotiationResult, logger_1.logger, 'MCP_INITIALIZE'); if (clientVersion && clientVersion !== negotiationResult.version) { logger_1.logger.warn(`Protocol version negotiated: client requested ${clientVersion}, server will use ${negotiationResult.version}`, { reasoning: negotiationResult.reasoning }); } const response = { protocolVersion: negotiationResult.version, capabilities: { tools: {}, }, serverInfo: { name: 'n8n-documentation-mcp', version: version_1.PROJECT_VERSION, }, }; logger_1.logger.info('MCP Initialize response', { response }); return response; }); this.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async (request) => { let tools = [...tools_1.n8nDocumentationToolsFinal]; const isConfigured = (0, n8n_api_1.isN8nApiConfigured)(); if (isConfigured) { tools.push(...tools_n8n_manager_1.n8nManagementTools); logger_1.logger.debug(`Tool listing: ${tools.length} tools available (${tools_1.n8nDocumentationToolsFinal.length} documentation + ${tools_n8n_manager_1.n8nManagementTools.length} management)`); } else { logger_1.logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`); } const clientInfo = this.clientInfo; const isN8nClient = clientInfo?.name?.includes('n8n') || clientInfo?.name?.includes('langchain'); if (isN8nClient) { logger_1.logger.info('Detected n8n client, using n8n-friendly tool descriptions'); tools = (0, tools_n8n_friendly_1.makeToolsN8nFriendly)(tools); } const validationTools = tools.filter(t => t.name.startsWith('validate_')); validationTools.forEach(tool => { logger_1.logger.info('Validation tool schema', { toolName: tool.name, inputSchema: JSON.stringify(tool.inputSchema, null, 2), hasOutputSchema: !!tool.outputSchema, description: tool.description }); }); return { tools }; }); this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; logger_1.logger.info('Tool call received - DETAILED DEBUG', { toolName: name, arguments: JSON.stringify(args, null, 2), argumentsType: typeof args, argumentsKeys: args ? Object.keys(args) : [], hasNodeType: args && 'nodeType' in args, hasConfig: args && 'config' in args, configType: args && args.config ? typeof args.config : 'N/A', rawRequest: JSON.stringify(request.params) }); let processedArgs = args; if (args && typeof args === 'object' && 'output' in args) { try { const possibleNestedData = args.output; if (typeof possibleNestedData === 'string' && possibleNestedData.trim().startsWith('{')) { const parsed = JSON.parse(possibleNestedData); if (parsed && typeof parsed === 'object') { logger_1.logger.warn('Detected n8n nested output bug, attempting to extract actual arguments', { originalArgs: args, extractedArgs: parsed }); if (this.validateExtractedArgs(name, parsed)) { processedArgs = parsed; } else { logger_1.logger.warn('Extracted arguments failed validation, using original args', { toolName: name, extractedArgs: parsed }); } } } } catch (parseError) { logger_1.logger.debug('Failed to parse nested output, continuing with original args', { error: parseError instanceof Error ? parseError.message : String(parseError) }); } } try { logger_1.logger.debug(`Executing tool: ${name}`, { args: processedArgs }); const result = await this.executeTool(name, processedArgs); logger_1.logger.debug(`Tool ${name} executed successfully`); let responseText; let structuredContent = null; try { if (name.startsWith('validate_') && typeof result === 'object' && result !== null) { const cleanResult = this.sanitizeValidationResult(result, name); structuredContent = cleanResult; responseText = JSON.stringify(cleanResult, null, 2); } else { responseText = typeof result === 'string' ? result : JSON.stringify(result, null, 2); } } catch (jsonError) { logger_1.logger.warn(`Failed to stringify tool result for ${name}:`, jsonError); responseText = String(result); } if (responseText.length > 1000000) { logger_1.logger.warn(`Tool ${name} response is very large (${responseText.length} chars), truncating`); responseText = responseText.substring(0, 999000) + '\n\n[Response truncated due to size limits]'; structuredContent = null; } const mcpResponse = { content: [ { type: 'text', text: responseText, }, ], }; if (name.startsWith('validate_') && structuredContent !== null) { mcpResponse.structuredContent = structuredContent; } return mcpResponse; } catch (error) { logger_1.logger.error(`Error executing tool ${name}`, error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`; if (errorMessage.includes('required') || errorMessage.includes('missing')) { helpfulMessage += '\n\nNote: This error often occurs when the AI agent sends incomplete or incorrectly formatted parameters. Please ensure all required fields are provided with the correct types.'; } else if (errorMessage.includes('type') || errorMessage.includes('expected')) { helpfulMessage += '\n\nNote: This error indicates a type mismatch. The AI agent may be sending data in the wrong format (e.g., string instead of object).'; } else if (errorMessage.includes('Unknown category') || errorMessage.includes('not found')) { helpfulMessage += '\n\nNote: The requested resource or category was not found. Please check the available options.'; } if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) { helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})'; } return { content: [ { type: 'text', text: helpfulMessage, }, ], isError: true, }; } }); } sanitizeValidationResult(result, toolName) { if (!result || typeof result !== 'object') { return result; } const sanitized = { ...result }; if (toolName === 'validate_node_minimal') { const filtered = { nodeType: String(sanitized.nodeType || ''), displayName: String(sanitized.displayName || ''), valid: Boolean(sanitized.valid), missingRequiredFields: Array.isArray(sanitized.missingRequiredFields) ? sanitized.missingRequiredFields.map(String) : [] }; return filtered; } else if (toolName === 'validate_node_operation') { let summary = sanitized.summary; if (!summary || typeof summary !== 'object') { summary = { hasErrors: Array.isArray(sanitized.errors) ? sanitized.errors.length > 0 : false, errorCount: Array.isArray(sanitized.errors) ? sanitized.errors.length : 0, warningCount: Array.isArray(sanitized.warnings) ? sanitized.warnings.length : 0, suggestionCount: Array.isArray(sanitized.suggestions) ? sanitized.suggestions.length : 0 }; } const filtered = { nodeType: String(sanitized.nodeType || ''), workflowNodeType: String(sanitized.workflowNodeType || sanitized.nodeType || ''), displayName: String(sanitized.displayName || ''), valid: Boolean(sanitized.valid), errors: Array.isArray(sanitized.errors) ? sanitized.errors : [], warnings: Array.isArray(sanitized.warnings) ? sanitized.warnings : [], suggestions: Array.isArray(sanitized.suggestions) ? sanitized.suggestions : [], summary: summary }; return filtered; } else if (toolName.startsWith('validate_workflow')) { sanitized.valid = Boolean(sanitized.valid); sanitized.errors = Array.isArray(sanitized.errors) ? sanitized.errors : []; sanitized.warnings = Array.isArray(sanitized.warnings) ? sanitized.warnings : []; if (toolName === 'validate_workflow') { if (!sanitized.summary || typeof sanitized.summary !== 'object') { sanitized.summary = { totalNodes: 0, enabledNodes: 0, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0, errorCount: sanitized.errors.length, warningCount: sanitized.warnings.length }; } } else { if (!sanitized.statistics || typeof sanitized.statistics !== 'object') { sanitized.statistics = { totalNodes: 0, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0 }; } } } return JSON.parse(JSON.stringify(sanitized)); } validateToolParams(toolName, args, legacyRequiredParams) { try { let validationResult; switch (toolName) { case 'validate_node_operation': validationResult = validation_schemas_1.ToolValidation.validateNodeOperation(args); break; case 'validate_node_minimal': validationResult = validation_schemas_1.ToolValidation.validateNodeMinimal(args); break; case 'validate_workflow': case 'validate_workflow_connections': case 'validate_workflow_expressions': validationResult = validation_schemas_1.ToolValidation.validateWorkflow(args); break; case 'search_nodes': validationResult = validation_schemas_1.ToolValidation.validateSearchNodes(args); break; case 'list_node_templates': validationResult = validation_schemas_1.ToolValidation.validateListNodeTemplates(args); break; case 'n8n_create_workflow': validationResult = validation_schemas_1.ToolValidation.validateCreateWorkflow(args); break; case 'n8n_get_workflow': case 'n8n_get_workflow_details': case 'n8n_get_workflow_structure': case 'n8n_get_workflow_minimal': case 'n8n_update_full_workflow': case 'n8n_delete_workflow': case 'n8n_validate_workflow': case 'n8n_get_execution': case 'n8n_delete_execution': validationResult = validation_schemas_1.ToolValidation.validateWorkflowId(args); break; default: return this.validateToolParamsBasic(toolName, args, legacyRequiredParams || []); } if (!validationResult.valid) { const errorMessage = validation_schemas_1.Validator.formatErrors(validationResult, toolName); logger_1.logger.error(`Parameter validation failed for ${toolName}:`, errorMessage); throw new validation_schemas_1.ValidationError(errorMessage); } } catch (error) { if (error instanceof validation_schemas_1.ValidationError) { throw error; } logger_1.logger.error(`Validation system error for ${toolName}:`, error); const errorMessage = error instanceof Error ? `Internal validation error: ${error.message}` : `Internal validation error while processing ${toolName}`; throw new Error(errorMessage); } } validateToolParamsBasic(toolName, args, requiredParams) { const missing = []; for (const param of requiredParams) { if (!(param in args) || args[param] === undefined || args[param] === null) { missing.push(param); } } if (missing.length > 0) { throw new Error(`Missing required parameters for ${toolName}: ${missing.join(', ')}. Please provide the required parameters to use this tool.`); } } validateExtractedArgs(toolName, args) { if (!args || typeof args !== 'object') { return false; } const allTools = [...tools_1.n8nDocumentationToolsFinal, ...tools_n8n_manager_1.n8nManagementTools]; const tool = allTools.find(t => t.name === toolName); if (!tool || !tool.inputSchema) { return true; } const schema = tool.inputSchema; const required = schema.required || []; const properties = schema.properties || {}; for (const requiredField of required) { if (!(requiredField in args)) { logger_1.logger.debug(`Extracted args missing required field: ${requiredField}`, { toolName, extractedArgs: args, required }); return false; } } for (const [fieldName, fieldValue] of Object.entries(args)) { if (properties[fieldName]) { const expectedType = properties[fieldName].type; const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue; if (expectedType && expectedType !== actualType) { if (expectedType === 'number' && actualType === 'string' && !isNaN(Number(fieldValue))) { continue; } logger_1.logger.debug(`Extracted args field type mismatch: ${fieldName}`, { toolName, expectedType, actualType, fieldValue }); return false; } } } if (schema.additionalProperties === false) { const allowedFields = Object.keys(properties); const extraFields = Object.keys(args).filter(field => !allowedFields.includes(field)); if (extraFields.length > 0) { logger_1.logger.debug(`Extracted args have extra fields`, { toolName, extraFields, allowedFields }); } } return true; } async executeTool(name, args) { args = args || {}; logger_1.logger.info(`Tool execution: ${name}`, { args: typeof args === 'object' ? JSON.stringify(args) : args, argsType: typeof args, argsKeys: typeof args === 'object' ? Object.keys(args) : 'not-object' }); if (typeof args !== 'object' || args === null) { throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`); } switch (name) { case 'tools_documentation': return this.getToolsDocumentation(args.topic, args.depth); case 'list_nodes': return this.listNodes(args); case 'get_node_info': this.validateToolParams(name, args, ['nodeType']); return this.getNodeInfo(args.nodeType); case 'search_nodes': this.validateToolParams(name, args, ['query']); const limit = args.limit !== undefined ? Number(args.limit) || 20 : 20; return this.searchNodes(args.query, limit, { mode: args.mode }); case 'list_ai_tools': return this.listAITools(); case 'get_node_documentation': this.validateToolParams(name, args, ['nodeType']); return this.getNodeDocumentation(args.nodeType); case 'get_database_statistics': return this.getDatabaseStatistics(); case 'get_node_essentials': this.validateToolParams(name, args, ['nodeType']); return this.getNodeEssentials(args.nodeType); case 'search_node_properties': this.validateToolParams(name, args, ['nodeType', 'query']); const maxResults = args.maxResults !== undefined ? Number(args.maxResults) || 20 : 20; return this.searchNodeProperties(args.nodeType, args.query, maxResults); case 'get_node_for_task': this.validateToolParams(name, args, ['task']); return this.getNodeForTask(args.task); case 'list_tasks': return this.listTasks(args.category); case 'validate_node_operation': this.validateToolParams(name, args, ['nodeType', 'config']); if (typeof args.config !== 'object' || args.config === null) { logger_1.logger.warn(`validate_node_operation called with invalid config type: ${typeof args.config}`); return { nodeType: args.nodeType || 'unknown', workflowNodeType: args.nodeType || 'unknown', displayName: 'Unknown Node', valid: false, errors: [{ type: 'config', property: 'config', message: 'Invalid config format - expected object', fix: 'Provide config as an object with node properties' }], warnings: [], suggestions: [ '🔧 RECOVERY: Invalid config detected. Fix with:', ' • Ensure config is an object: { "resource": "...", "operation": "..." }', ' • Use get_node_essentials to see required fields for this node type', ' • Check if the node type is correct before configuring it' ], summary: { hasErrors: true, errorCount: 1, warningCount: 0, suggestionCount: 3 } }; } return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile); case 'validate_node_minimal': this.validateToolParams(name, args, ['nodeType', 'config']); if (typeof args.config !== 'object' || args.config === null) { logger_1.logger.warn(`validate_node_minimal called with invalid config type: ${typeof args.config}`); return { nodeType: args.nodeType || 'unknown', displayName: 'Unknown Node', valid: false, missingRequiredFields: [ 'Invalid config format - expected object', '🔧 RECOVERY: Use format { "resource": "...", "operation": "..." } or {} for empty config' ] }; } return this.validateNodeMinimal(args.nodeType, args.config); case 'get_property_dependencies': this.validateToolParams(name, args, ['nodeType']); return this.getPropertyDependencies(args.nodeType, args.config); case 'get_node_as_tool_info': this.validateToolParams(name, args, ['nodeType']); return this.getNodeAsToolInfo(args.nodeType); case 'list_node_templates': this.validateToolParams(name, args, ['nodeTypes']); const templateLimit = args.limit !== undefined ? Number(args.limit) || 10 : 10; return this.listNodeTemplates(args.nodeTypes, templateLimit); case 'get_template': this.validateToolParams(name, args, ['templateId']); const templateId = Number(args.templateId); return this.getTemplate(templateId); case 'search_templates': this.validateToolParams(name, args, ['query']); const searchLimit = args.limit !== undefined ? Number(args.limit) || 20 : 20; return this.searchTemplates(args.query, searchLimit); case 'get_templates_for_task': this.validateToolParams(name, args, ['task']); return this.getTemplatesForTask(args.task); case 'validate_workflow': this.validateToolParams(name, args, ['workflow']); return this.validateWorkflow(args.workflow, args.options); case 'validate_workflow_connections': this.validateToolParams(name, args, ['workflow']); return this.validateWorkflowConnections(args.workflow); case 'validate_workflow_expressions': this.validateToolParams(name, args, ['workflow']); return this.validateWorkflowExpressions(args.workflow); case 'n8n_create_workflow': this.validateToolParams(name, args, ['name', 'nodes', 'connections']); return n8nHandlers.handleCreateWorkflow(args); case 'n8n_get_workflow': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleGetWorkflow(args); case 'n8n_get_workflow_details': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleGetWorkflowDetails(args); case 'n8n_get_workflow_structure': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleGetWorkflowStructure(args); case 'n8n_get_workflow_minimal': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleGetWorkflowMinimal(args); case 'n8n_update_full_workflow': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleUpdateWorkflow(args); case 'n8n_update_partial_workflow': this.validateToolParams(name, args, ['id', 'operations']); return (0, handlers_workflow_diff_1.handleUpdatePartialWorkflow)(args); case 'n8n_delete_workflow': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleDeleteWorkflow(args); case 'n8n_list_workflows': return n8nHandlers.handleListWorkflows(args); case 'n8n_validate_workflow': this.validateToolParams(name, args, ['id']); await this.ensureInitialized(); if (!this.repository) throw new Error('Repository not initialized'); return n8nHandlers.handleValidateWorkflow(args, this.repository); case 'n8n_trigger_webhook_workflow': this.validateToolParams(name, args, ['webhookUrl']); return n8nHandlers.handleTriggerWebhookWorkflow(args); case 'n8n_get_execution': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleGetExecution(args); case 'n8n_list_executions': return n8nHandlers.handleListExecutions(args); case 'n8n_delete_execution': this.validateToolParams(name, args, ['id']); return n8nHandlers.handleDeleteExecution(args); case 'n8n_health_check': return n8nHandlers.handleHealthCheck(); case 'n8n_list_available_tools': return n8nHandlers.handleListAvailableTools(); case 'n8n_diagnostic': return n8nHandlers.handleDiagnostic({ params: { arguments: args } }); default: throw new Error(`Unknown tool: ${name}`); } } async listNodes(filters = {}) { await this.ensureInitialized(); let query = 'SELECT * FROM nodes WHERE 1=1'; const params = []; if (filters.package) { const packageVariants = [ filters.package, `@n8n/${filters.package}`, filters.package.replace('@n8n/', '') ]; query += ' AND package_name IN (' + packageVariants.map(() => '?').join(',') + ')'; params.push(...packageVariants); } if (filters.category) { query += ' AND category = ?'; params.push(filters.category); } if (filters.developmentStyle) { query += ' AND development_style = ?'; params.push(filters.developmentStyle); } if (filters.isAITool !== undefined) { query += ' AND is_ai_tool = ?'; params.push(filters.isAITool ? 1 : 0); } query += ' ORDER BY display_name'; if (filters.limit) { query += ' LIMIT ?'; params.push(filters.limit); } const nodes = this.db.prepare(query).all(...params); return { nodes: nodes.map(node => ({ nodeType: node.node_type, displayName: node.display_name, description: node.description, category: node.category, package: node.package_name, developmentStyle: node.development_style, isAITool: Number(node.is_ai_tool) === 1, isTrigger: Number(node.is_trigger) === 1, isVersioned: Number(node.is_versioned) === 1, })), totalCount: nodes.length, }; } async getNodeInfo(nodeType) { await this.ensureInitialized(); if (!this.repository) throw new Error('Repository not initialized'); const normalizedType = (0, node_utils_1.normalizeNodeType)(nodeType); let node = this.repository.getNode(normalizedType); if (!node && normalizedType !== nodeType) { node = this.repository.getNode(nodeType); } if (!node) { const alternatives = (0, node_utils_1.getNodeTypeAlternatives)(normalizedType); for (const alt of alternatives) { const found = this.repository.getNode(alt); if (found) { node = found; break; } } } if (!node) { throw new Error(`Node ${nodeType} not found`); } const aiToolCapabilities = { canBeUsedAsTool: true, hasUsableAsToolProperty: node.isAITool, requiresEnvironmentVariable: !node.isAITool && node.package !== 'n8n-nodes-base', toolConnectionType: 'ai_tool', commonToolUseCases: this.getCommonAIToolUseCases(node.nodeType), environmentRequirement: node.package !== 'n8n-nodes-base' ? 'N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true' : null }; let outputs = undefined; if (node.outputNames && node.outputNames.length > 0) { outputs = node.outputNames.map((name, index) => { const descriptions = this.getOutputDescriptions(node.nodeType, name, index); return { index, name, description: descriptions.description, connectionGuidance: descriptions.connectionGuidance }; }); } return { ...node, workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package, node.nodeType), aiToolCapabilities, outputs }; } async searchNodes(query, limit = 20, options) { await this.ensureInitialized(); if (!this.db) throw new Error('Database not initialized'); let normalizedQuery = query; if (query.includes('n8n-nodes-base.') || query.includes('@n8n/n8n-nodes-langchain.')) { normalizedQuery = query .replace(/n8n-nodes-base\./g, 'nodes-base.') .replace(/@n8n\/n8n-nodes-langchain\./g, 'nodes-langchain.'); } const searchMode = options?.mode || 'OR'; const ftsExists = this.db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name='nodes_fts' `).get(); if (ftsExists) { return this.searchNodesFTS(normalizedQuery, limit, searchMode); } else { return this.searchNodesLIKE(normalizedQuery, limit); } } async searchNodesFTS(query, limit, mode) { if (!this.db) throw new Error('Database not initialized'); const cleanedQuery = query.trim(); if (!cleanedQuery) { return { query, results: [], totalCount: 0 }; } if (mode === 'FUZZY') { return this.searchNodesFuzzy(cleanedQuery, limit); } let ftsQuery; if (cleanedQuery.startsWith('"') && cleanedQuery.endsWith('"')) { ftsQuery = cleanedQuery; } else { const words = cleanedQuery.split(/\s+/).filter(w => w.length > 0); switch (mode) { case 'AND': ftsQuery = words.join(' AND '); break; case 'OR': default: ftsQuery = words.join(' OR '); break; } } try { const nodes = this.db.prepare(` SELECT n.*, rank FROM nodes n JOIN nodes_fts ON n.rowid = nodes_fts.rowid WHERE nodes_fts MATCH ? ORDER BY rank, CASE WHEN n.display_name = ? THEN 0 WHEN n.display_name LIKE ? THEN 1 WHEN n.node_type LIKE ? THEN 2 ELSE 3 END, n.display_name LIMIT ? `).all(ftsQuery, cleanedQuery, `%${cleanedQuery}%`, `%${cleanedQuery}%`, limit); const scoredNodes = nodes.map(node => { const relevanceScore = this.calculateRelevanceScore(node, cleanedQuery); return { ...node, relevanceScore }; }); scoredNodes.sort((a, b) => { if (a.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return -1; if (b.display_name.toLowerCase() === cleanedQuery.toLowerCase()) return 1; if (a.relevanceScore !== b.relevanceScore) { return b.relevanceScore - a.relevanceScore; } return a.rank - b.rank; }); const hasHttpRequest = scoredNodes.some(n => n.node_type === 'nodes-base.httpRequest'); if (cleanedQuery.toLowerCase().includes('http') && !hasHttpRequest) { logger_1.logger.debug('FTS missed HTTP Request node, augmenting with LIKE search'); return this.searchNodesLIKE(query, limit); } const result = { query, results: scoredNodes.map(node => ({ nodeType: node.node_type, workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type), displayName: node.display_name, description: node.description, category: node.category, package: node.package_name, relevance: this.calculateRelevance(node, cleanedQuery) })), totalCount: scoredNodes.length }; if (mode !== 'OR') { result.mode = mode; } return result; } catch (error) { logger_1.logger.warn('FTS5 search failed, falling back to LIKE search:', error.message); if (error.message.includes('syntax error') || error.message.includes('fts5')) { logger_1.logger.warn(`FTS5 syntax error for query "${query}" in mode ${mode}`); const likeResult = await this.searchNodesLIKE(query, limit); return { ...likeResult, mode }; } return this.searchNodesLIKE(query, limit); } } async searchNodesFuzzy(query, limit) { if (!this.db) throw new Error('Database not initialized'); const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0); if (words.length === 0) { return { query, results: [], totalCount: 0, mode: 'FUZZY' }; } const candidateNodes = this.db.prepare(` SELECT * FROM nodes `).all(); const scoredNodes = candidateNodes.map(node => { const score = this.calculateFuzzyScore(node, query); return { node, score }; }); const matchingNodes = scoredNodes .filter(item => item.score >= 200) .sort((a, b) => b.score - a.score) .slice(0, limit) .map(item => item.node); if (matchingNodes.length === 0) { const topScores = scoredNodes .sort((a, b) => b.score - a.score) .slice(0, 5); logger_1.logger.debug(`FUZZY search for "${query}" - no matches above 400. Top scores:`, topScores.map(s => ({ name: s.node.display_name, score: s.score }))); } return { query, mode: 'FUZZY', results: matchingNodes.map(node => ({ nodeType: node.node_type, workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type), displayName: node.display_name, description: node.description, category: node.category, package: node.package_name })), totalCount: matchingNodes.length }; } calculateFuzzyScore(node, query) { const queryLower = query.toLowerCase(); const displayNameLower = node.display_name.toLowerCase(); const nodeTypeLower = node.node_type.toLowerCase(); const nodeTypeClean = nodeTypeLower.replace(/^nodes-base\./, '').replace(/^nodes-langchain\./, ''); if (displayNameLower === queryLower || nodeTypeClean === queryLower) { return 1000; } const nameDistance = this.getEditDistance(queryLower, displayNameLower); const typeDistance = this.getEditDistance(queryLower, nodeTypeClean); const nameWords = displayNameLower.split(/\s+/); let minWordDistance = Infinity; for (const word of nameWords) { const distance = this.getEditDistance(queryLower, word); if (distance < minWordDistance) { minWordDistance = distance; } } const bestDistance = Math.min(nameDistance, typeDistance, minWordDistance); let matchedLen = queryLower.length; if (minWordDistance === bestDistance) { for (const word of nameWords) { if (this.getEditDistance(queryLower, word) === minWordDistance) { matchedLen = Math.max(queryLower.length, word.length); break; } } } else if (typeDistance === bestDistance) { matchedLen = Math.max(queryLower.length, nodeTypeClean.length); } else { matchedLen = Math.max(queryLower.length, displayNameLower.length); } const similarity = 1 - (bestDistance / matchedLen); if (displayNameLower.includes(queryLower) || nodeTypeClean.includes(queryLower)) { return 800 + (similarity * 100); } if (displayNameLower.startsWith(queryLower) || nodeTypeClean.startsWith(queryLower) || nameWords.some(w => w.startsWith(queryLower))) { return 700 + (similarity * 100); } if (bestDistance <= 2) { return 500 + ((2 - bestDistance) * 100) + (similarity * 50); } if (bestDistance <= 3 && queryLower.length >= 4) { return 400 + ((3 - bestDistance) * 50) + (similarity * 50); } return similarity * 300; } getEditDistance(s1, s2) { const m = s1.length; const n = s2.length; const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); for (let i = 0; i <= m; i++) dp[i][0] = i; for (let j = 0; j <= n; j++) dp[0][j] = j; for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (s1[i - 1] === s2[j - 1]) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } } return dp[m][n]; } async searchNodesLIKE(query, limit) { if (!this.db) throw new Error('Database not initialized'); if (query.startsWith('"') && query.endsWith('"')) { const exactPhrase = query.slice(1, -1); const nodes = this.db.prepare(` SELECT * FROM nodes WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ? LIMIT ? `).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, limit * 3); const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit); return { query, results: rankedNodes.map(node => ({ nodeType: node.node_type, workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type), displayName: node.display_name, description: node.description, category: node.category, package: node.package_name })), totalCount: rankedNodes.length }; } const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0); if (words.length === 0) { return { query, results: [], totalCount: 0 }; } const conditions = words.map(() => '(node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)').join(' OR '); const params = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]); params.push(limit * 3); const nodes = this.db.prepare(` SELECT DISTINCT * FROM nodes WHERE ${conditions} LIMIT ? `).all(...params); const rankedNodes = this.rankSearchResults(nodes, query, limit); return { query, results: rankedNodes.map(node => ({ nodeType: node.node_type, workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type), displayName: node.display_name, description: node.description, category: node.category, package: node.package_name })), totalCount: rankedNodes.length }; } calculateRelevance(node, query) { const lowerQuery = query.toLowerCase(); if (node.node_type.toLowerCase().includes(lowerQuery)) return 'high'; if (node.display_name.toLowerCase().includes(lowerQuery)) return 'high'; if (node.description?.toLowerCase().includes(lowerQuery)) return 'medium'; ret