Skip to main content
Glama
aegntic

Obsidian Elite RAG MCP Server

workflow-validator.ts32.6 kB
/** * Workflow Validator for n8n workflows * Validates complete workflow structure, connections, and node configurations */ import { NodeRepository } from '../database/node-repository'; import { EnhancedConfigValidator } from './enhanced-config-validator'; import { ExpressionValidator } from './expression-validator'; import { Logger } from '../utils/logger'; const logger = new Logger({ prefix: '[WorkflowValidator]' }); interface WorkflowNode { id: string; name: string; type: string; position: [number, number]; parameters: any; credentials?: any; disabled?: boolean; notes?: string; typeVersion?: number; } interface WorkflowConnection { [sourceNode: string]: { main?: Array<Array<{ node: string; type: string; index: number }>>; error?: Array<Array<{ node: string; type: string; index: number }>>; ai_tool?: Array<Array<{ node: string; type: string; index: number }>>; }; } interface WorkflowJson { name?: string; nodes: WorkflowNode[]; connections: WorkflowConnection; settings?: any; staticData?: any; pinData?: any; meta?: any; } interface ValidationIssue { type: 'error' | 'warning'; nodeId?: string; nodeName?: string; message: string; details?: any; } interface WorkflowValidationResult { valid: boolean; errors: ValidationIssue[]; warnings: ValidationIssue[]; statistics: { totalNodes: number; enabledNodes: number; triggerNodes: number; validConnections: number; invalidConnections: number; expressionsValidated: number; }; suggestions: string[]; } export class WorkflowValidator { constructor( private nodeRepository: NodeRepository, private nodeValidator: typeof EnhancedConfigValidator ) {} /** * Validate a complete workflow */ async validateWorkflow( workflow: WorkflowJson, options: { validateNodes?: boolean; validateConnections?: boolean; validateExpressions?: boolean; profile?: 'minimal' | 'runtime' | 'ai-friendly' | 'strict'; } = {} ): Promise<WorkflowValidationResult> { const { validateNodes = true, validateConnections = true, validateExpressions = true, profile = 'runtime' } = options; const result: WorkflowValidationResult = { valid: true, errors: [], warnings: [], statistics: { totalNodes: workflow.nodes.length, enabledNodes: workflow.nodes.filter(n => !n.disabled).length, triggerNodes: 0, validConnections: 0, invalidConnections: 0, expressionsValidated: 0, }, suggestions: [] }; try { // Basic workflow structure validation this.validateWorkflowStructure(workflow, result); // Validate each node if requested if (validateNodes) { await this.validateAllNodes(workflow, result, profile); } // Validate connections if requested if (validateConnections) { this.validateConnections(workflow, result); } // Validate expressions if requested if (validateExpressions) { this.validateExpressions(workflow, result); } // Check workflow patterns and best practices this.checkWorkflowPatterns(workflow, result); // Add suggestions based on findings this.generateSuggestions(workflow, result); } catch (error) { logger.error('Error validating workflow:', error); result.errors.push({ type: 'error', message: `Workflow validation failed: ${error instanceof Error ? error.message : 'Unknown error'}` }); } result.valid = result.errors.length === 0; return result; } /** * Validate basic workflow structure */ private validateWorkflowStructure( workflow: WorkflowJson, result: WorkflowValidationResult ): void { // Check for required fields if (!workflow.nodes || !Array.isArray(workflow.nodes)) { result.errors.push({ type: 'error', message: 'Workflow must have a nodes array' }); return; } if (!workflow.connections || typeof workflow.connections !== 'object') { result.errors.push({ type: 'error', message: 'Workflow must have a connections object' }); return; } // Check for empty workflow if (workflow.nodes.length === 0) { result.errors.push({ type: 'error', message: 'Workflow has no nodes' }); return; } // Check for minimum viable workflow if (workflow.nodes.length === 1) { const singleNode = workflow.nodes[0]; const normalizedType = singleNode.type.replace('n8n-nodes-base.', 'nodes-base.'); const isWebhook = normalizedType === 'nodes-base.webhook' || normalizedType === 'nodes-base.webhookTrigger'; if (!isWebhook) { result.errors.push({ type: 'error', message: 'Single-node workflows are only valid for webhook endpoints. Add at least one more connected node to create a functional workflow.' }); } else if (Object.keys(workflow.connections).length === 0) { result.warnings.push({ type: 'warning', message: 'Webhook node has no connections. Consider adding nodes to process the webhook data.' }); } } // Check for empty connections in multi-node workflows if (workflow.nodes.length > 1) { const hasEnabledNodes = workflow.nodes.some(n => !n.disabled); const hasConnections = Object.keys(workflow.connections).length > 0; if (hasEnabledNodes && !hasConnections) { result.errors.push({ type: 'error', message: 'Multi-node workflow has no connections. Nodes must be connected to create a workflow. Use connections: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }' }); } } // Check for duplicate node names const nodeNames = new Set<string>(); const nodeIds = new Set<string>(); for (const node of workflow.nodes) { if (nodeNames.has(node.name)) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Duplicate node name: "${node.name}"` }); } nodeNames.add(node.name); if (nodeIds.has(node.id)) { result.errors.push({ type: 'error', nodeId: node.id, message: `Duplicate node ID: "${node.id}"` }); } nodeIds.add(node.id); } // Count trigger nodes - normalize type names first const triggerNodes = workflow.nodes.filter(n => { const normalizedType = n.type.replace('n8n-nodes-base.', 'nodes-base.'); return normalizedType.toLowerCase().includes('trigger') || normalizedType.toLowerCase().includes('webhook') || normalizedType === 'nodes-base.start' || normalizedType === 'nodes-base.manualTrigger' || normalizedType === 'nodes-base.formTrigger'; }); result.statistics.triggerNodes = triggerNodes.length; // Check for at least one trigger node if (triggerNodes.length === 0 && workflow.nodes.filter(n => !n.disabled).length > 0) { result.warnings.push({ type: 'warning', message: 'Workflow has no trigger nodes. It can only be executed manually.' }); } } /** * Validate all nodes in the workflow */ private async validateAllNodes( workflow: WorkflowJson, result: WorkflowValidationResult, profile: string ): Promise<void> { for (const node of workflow.nodes) { if (node.disabled) continue; try { // FIRST: Check for common invalid patterns before database lookup if (node.type.startsWith('nodes-base.')) { // This is ALWAYS invalid in workflows - must use n8n-nodes-base prefix const correctType = node.type.replace('nodes-base.', 'n8n-nodes-base.'); result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Invalid node type: "${node.type}". Use "${correctType}" instead. Node types in workflows must use the full package name.` }); continue; } // Get node definition - try multiple formats let nodeInfo = this.nodeRepository.getNode(node.type); // If not found, try with normalized type if (!nodeInfo) { let normalizedType = node.type; // Handle n8n-nodes-base -> nodes-base if (node.type.startsWith('n8n-nodes-base.')) { normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.'); nodeInfo = this.nodeRepository.getNode(normalizedType); } // Handle @n8n/n8n-nodes-langchain -> nodes-langchain else if (node.type.startsWith('@n8n/n8n-nodes-langchain.')) { normalizedType = node.type.replace('@n8n/n8n-nodes-langchain.', 'nodes-langchain.'); nodeInfo = this.nodeRepository.getNode(normalizedType); } } if (!nodeInfo) { // Check for common mistakes let suggestion = ''; // Missing package prefix if (node.type.startsWith('nodes-base.')) { const withPrefix = node.type.replace('nodes-base.', 'n8n-nodes-base.'); const exists = this.nodeRepository.getNode(withPrefix) || this.nodeRepository.getNode(withPrefix.replace('n8n-nodes-base.', 'nodes-base.')); if (exists) { suggestion = ` Did you mean "n8n-nodes-base.${node.type.substring(11)}"?`; } } // Check if it's just the node name without package else if (!node.type.includes('.')) { // Try common node names const commonNodes = [ 'webhook', 'httpRequest', 'set', 'code', 'manualTrigger', 'scheduleTrigger', 'emailSend', 'slack', 'discord' ]; if (commonNodes.includes(node.type)) { suggestion = ` Did you mean "n8n-nodes-base.${node.type}"?`; } } // If no specific suggestion, try to find similar nodes if (!suggestion) { const similarNodes = this.findSimilarNodeTypes(node.type); if (similarNodes.length > 0) { suggestion = ` Did you mean: ${similarNodes.map(n => `"${n}"`).join(', ')}?`; } } result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Unknown node type: "${node.type}".${suggestion} Node types must include the package prefix (e.g., "n8n-nodes-base.webhook", not "webhook" or "nodes-base.webhook").` }); continue; } // Validate typeVersion for versioned nodes if (nodeInfo.isVersioned) { // Check if typeVersion is missing if (!node.typeVersion) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Missing required property 'typeVersion'. Add typeVersion: ${nodeInfo.version || 1}` }); } // Check if typeVersion is invalid else if (typeof node.typeVersion !== 'number' || node.typeVersion < 1) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Invalid typeVersion: ${node.typeVersion}. Must be a positive number` }); } // Check if typeVersion is outdated (less than latest) else if (nodeInfo.version && node.typeVersion < nodeInfo.version) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Outdated typeVersion: ${node.typeVersion}. Latest is ${nodeInfo.version}` }); } // Check if typeVersion exceeds maximum supported else if (nodeInfo.version && node.typeVersion > nodeInfo.version) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `typeVersion ${node.typeVersion} exceeds maximum supported version ${nodeInfo.version}` }); } } // Validate node configuration const nodeValidation = this.nodeValidator.validateWithMode( node.type, node.parameters, nodeInfo.properties || [], 'operation', profile as any ); // Add node-specific errors and warnings nodeValidation.errors.forEach((error: any) => { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: error }); }); nodeValidation.warnings.forEach((warning: any) => { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: warning }); }); } catch (error) { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Failed to validate node: ${error instanceof Error ? error.message : 'Unknown error'}` }); } } } /** * Validate workflow connections */ private validateConnections( workflow: WorkflowJson, result: WorkflowValidationResult ): void { const nodeMap = new Map(workflow.nodes.map(n => [n.name, n])); const nodeIdMap = new Map(workflow.nodes.map(n => [n.id, n])); // Check all connections for (const [sourceName, outputs] of Object.entries(workflow.connections)) { const sourceNode = nodeMap.get(sourceName); if (!sourceNode) { // Check if this is an ID being used instead of a name const nodeById = nodeIdMap.get(sourceName); if (nodeById) { result.errors.push({ type: 'error', nodeId: nodeById.id, nodeName: nodeById.name, message: `Connection uses node ID '${sourceName}' instead of node name '${nodeById.name}'. In n8n, connections must use node names, not IDs.` }); } else { result.errors.push({ type: 'error', message: `Connection from non-existent node: "${sourceName}"` }); } result.statistics.invalidConnections++; continue; } // Check main outputs if (outputs.main) { this.validateConnectionOutputs( sourceName, outputs.main, nodeMap, nodeIdMap, result, 'main' ); } // Check error outputs if (outputs.error) { this.validateConnectionOutputs( sourceName, outputs.error, nodeMap, nodeIdMap, result, 'error' ); } // Check AI tool outputs if (outputs.ai_tool) { this.validateConnectionOutputs( sourceName, outputs.ai_tool, nodeMap, nodeIdMap, result, 'ai_tool' ); } } // Check for orphaned nodes (not connected and not triggers) const connectedNodes = new Set<string>(); // Add all source nodes Object.keys(workflow.connections).forEach(name => connectedNodes.add(name)); // Add all target nodes Object.values(workflow.connections).forEach(outputs => { if (outputs.main) { outputs.main.flat().forEach(conn => { if (conn) connectedNodes.add(conn.node); }); } if (outputs.error) { outputs.error.flat().forEach(conn => { if (conn) connectedNodes.add(conn.node); }); } if (outputs.ai_tool) { outputs.ai_tool.flat().forEach(conn => { if (conn) connectedNodes.add(conn.node); }); } }); // Check for orphaned nodes for (const node of workflow.nodes) { if (node.disabled) continue; const normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.'); const isTrigger = normalizedType.toLowerCase().includes('trigger') || normalizedType.toLowerCase().includes('webhook') || normalizedType === 'nodes-base.start' || normalizedType === 'nodes-base.manualTrigger' || normalizedType === 'nodes-base.formTrigger'; if (!connectedNodes.has(node.name) && !isTrigger) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: 'Node is not connected to any other nodes' }); } } // Check for cycles if (this.hasCycle(workflow)) { result.errors.push({ type: 'error', message: 'Workflow contains a cycle (infinite loop)' }); } } /** * Validate connection outputs */ private validateConnectionOutputs( sourceName: string, outputs: Array<Array<{ node: string; type: string; index: number }>>, nodeMap: Map<string, WorkflowNode>, nodeIdMap: Map<string, WorkflowNode>, result: WorkflowValidationResult, outputType: 'main' | 'error' | 'ai_tool' ): void { outputs.forEach((outputConnections, outputIndex) => { if (!outputConnections) return; outputConnections.forEach(connection => { const targetNode = nodeMap.get(connection.node); if (!targetNode) { // Check if this is an ID being used instead of a name const nodeById = nodeIdMap.get(connection.node); if (nodeById) { result.errors.push({ type: 'error', nodeId: nodeById.id, nodeName: nodeById.name, message: `Connection target uses node ID '${connection.node}' instead of node name '${nodeById.name}' (from ${sourceName}). In n8n, connections must use node names, not IDs.` }); } else { result.errors.push({ type: 'error', message: `Connection to non-existent node: "${connection.node}" from "${sourceName}"` }); } result.statistics.invalidConnections++; } else if (targetNode.disabled) { result.warnings.push({ type: 'warning', message: `Connection to disabled node: "${connection.node}" from "${sourceName}"` }); } else { result.statistics.validConnections++; // Additional validation for AI tool connections if (outputType === 'ai_tool') { this.validateAIToolConnection(sourceName, targetNode, result); } } }); }); } /** * Validate AI tool connections */ private validateAIToolConnection( sourceName: string, targetNode: WorkflowNode, result: WorkflowValidationResult ): void { // For AI tool connections, we just need to check if this is being used as a tool // The source should be an AI Agent connecting to this target node as a tool // Get target node info to check if it can be used as a tool let targetNodeInfo = this.nodeRepository.getNode(targetNode.type); // Try normalized type if not found if (!targetNodeInfo) { let normalizedType = targetNode.type; // Handle n8n-nodes-base -> nodes-base if (targetNode.type.startsWith('n8n-nodes-base.')) { normalizedType = targetNode.type.replace('n8n-nodes-base.', 'nodes-base.'); targetNodeInfo = this.nodeRepository.getNode(normalizedType); } // Handle @n8n/n8n-nodes-langchain -> nodes-langchain else if (targetNode.type.startsWith('@n8n/n8n-nodes-langchain.')) { normalizedType = targetNode.type.replace('@n8n/n8n-nodes-langchain.', 'nodes-langchain.'); targetNodeInfo = this.nodeRepository.getNode(normalizedType); } } if (targetNodeInfo && !targetNodeInfo.isAITool && targetNodeInfo.package !== 'n8n-nodes-base') { // It's a community node being used as a tool result.warnings.push({ type: 'warning', nodeId: targetNode.id, nodeName: targetNode.name, message: `Community node "${targetNode.name}" is being used as an AI tool. Ensure N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true is set.` }); } } /** * Check if workflow has cycles */ private hasCycle(workflow: WorkflowJson): boolean { const visited = new Set<string>(); const recursionStack = new Set<string>(); const hasCycleDFS = (nodeName: string): boolean => { visited.add(nodeName); recursionStack.add(nodeName); const connections = workflow.connections[nodeName]; if (connections) { const allTargets: string[] = []; if (connections.main) { connections.main.flat().forEach(conn => { if (conn) allTargets.push(conn.node); }); } if (connections.error) { connections.error.flat().forEach(conn => { if (conn) allTargets.push(conn.node); }); } if (connections.ai_tool) { connections.ai_tool.flat().forEach(conn => { if (conn) allTargets.push(conn.node); }); } for (const target of allTargets) { if (!visited.has(target)) { if (hasCycleDFS(target)) return true; } else if (recursionStack.has(target)) { return true; } } } recursionStack.delete(nodeName); return false; }; // Check from all nodes for (const node of workflow.nodes) { if (!visited.has(node.name)) { if (hasCycleDFS(node.name)) return true; } } return false; } /** * Validate expressions in the workflow */ private validateExpressions( workflow: WorkflowJson, result: WorkflowValidationResult ): void { const nodeNames = workflow.nodes.map(n => n.name); for (const node of workflow.nodes) { if (node.disabled) continue; // Create expression context const context = { availableNodes: nodeNames.filter(n => n !== node.name), currentNodeName: node.name, hasInputData: this.nodeHasInput(node.name, workflow), isInLoop: false // Could be enhanced to detect loop nodes }; // Validate expressions in parameters const exprValidation = ExpressionValidator.validateNodeExpressions( node.parameters, context ); result.statistics.expressionsValidated += exprValidation.usedVariables.size; // Add expression errors and warnings exprValidation.errors.forEach(error => { result.errors.push({ type: 'error', nodeId: node.id, nodeName: node.name, message: `Expression error: ${error}` }); }); exprValidation.warnings.forEach(warning => { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Expression warning: ${warning}` }); }); } } /** * Check if a node has input connections */ private nodeHasInput(nodeName: string, workflow: WorkflowJson): boolean { for (const [sourceName, outputs] of Object.entries(workflow.connections)) { if (outputs.main) { for (const outputConnections of outputs.main) { if (outputConnections?.some(conn => conn.node === nodeName)) { return true; } } } } return false; } /** * Check workflow patterns and best practices */ private checkWorkflowPatterns( workflow: WorkflowJson, result: WorkflowValidationResult ): void { // Check for error handling const hasErrorHandling = Object.values(workflow.connections).some( outputs => outputs.error && outputs.error.length > 0 ); if (!hasErrorHandling && workflow.nodes.length > 3) { result.warnings.push({ type: 'warning', message: 'Consider adding error handling to your workflow' }); } // Check for very long linear workflows const linearChainLength = this.getLongestLinearChain(workflow); if (linearChainLength > 10) { result.warnings.push({ type: 'warning', message: `Long linear chain detected (${linearChainLength} nodes). Consider breaking into sub-workflows.` }); } // Check for missing credentials for (const node of workflow.nodes) { if (node.credentials && Object.keys(node.credentials).length > 0) { for (const [credType, credConfig] of Object.entries(node.credentials)) { if (!credConfig || (typeof credConfig === 'object' && !('id' in credConfig))) { result.warnings.push({ type: 'warning', nodeId: node.id, nodeName: node.name, message: `Missing credentials configuration for ${credType}` }); } } } } // Check for AI Agent workflows const aiAgentNodes = workflow.nodes.filter(n => n.type.toLowerCase().includes('agent') || n.type.includes('langchain.agent') ); if (aiAgentNodes.length > 0) { // Check if AI agents have tools connected for (const agentNode of aiAgentNodes) { const connections = workflow.connections[agentNode.name]; if (!connections?.ai_tool || connections.ai_tool.flat().filter(c => c).length === 0) { result.warnings.push({ type: 'warning', nodeId: agentNode.id, nodeName: agentNode.name, message: 'AI Agent has no tools connected. Consider adding tools to enhance agent capabilities.' }); } } // Check for community nodes used as tools const hasAIToolConnections = Object.values(workflow.connections).some( outputs => outputs.ai_tool && outputs.ai_tool.length > 0 ); if (hasAIToolConnections) { result.suggestions.push( 'For community nodes used as AI tools, ensure N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true is set' ); } } } /** * Get the longest linear chain in the workflow */ private getLongestLinearChain(workflow: WorkflowJson): number { const memo = new Map<string, number>(); const visiting = new Set<string>(); const getChainLength = (nodeName: string): number => { // If we're already visiting this node, we have a cycle if (visiting.has(nodeName)) return 0; if (memo.has(nodeName)) return memo.get(nodeName)!; visiting.add(nodeName); let maxLength = 0; const connections = workflow.connections[nodeName]; if (connections?.main) { for (const outputConnections of connections.main) { if (outputConnections) { for (const conn of outputConnections) { const length = getChainLength(conn.node); maxLength = Math.max(maxLength, length); } } } } visiting.delete(nodeName); const result = maxLength + 1; memo.set(nodeName, result); return result; }; let maxChain = 0; for (const node of workflow.nodes) { if (!this.nodeHasInput(node.name, workflow)) { maxChain = Math.max(maxChain, getChainLength(node.name)); } } return maxChain; } /** * Find similar node types for suggestions */ private findSimilarNodeTypes(invalidType: string): string[] { // Since we don't have a method to list all nodes, we'll use a predefined list // of common node types that users might be looking for const suggestions: string[] = []; const nodeName = invalidType.includes('.') ? invalidType.split('.').pop()! : invalidType; const commonNodeMappings: Record<string, string[]> = { 'webhook': ['nodes-base.webhook'], 'httpRequest': ['nodes-base.httpRequest'], 'http': ['nodes-base.httpRequest'], 'set': ['nodes-base.set'], 'code': ['nodes-base.code'], 'manualTrigger': ['nodes-base.manualTrigger'], 'manual': ['nodes-base.manualTrigger'], 'scheduleTrigger': ['nodes-base.scheduleTrigger'], 'schedule': ['nodes-base.scheduleTrigger'], 'cron': ['nodes-base.scheduleTrigger'], 'emailSend': ['nodes-base.emailSend'], 'email': ['nodes-base.emailSend'], 'slack': ['nodes-base.slack'], 'discord': ['nodes-base.discord'], 'postgres': ['nodes-base.postgres'], 'mysql': ['nodes-base.mySql'], 'mongodb': ['nodes-base.mongoDb'], 'redis': ['nodes-base.redis'], 'if': ['nodes-base.if'], 'switch': ['nodes-base.switch'], 'merge': ['nodes-base.merge'], 'splitInBatches': ['nodes-base.splitInBatches'], 'loop': ['nodes-base.splitInBatches'], 'googleSheets': ['nodes-base.googleSheets'], 'sheets': ['nodes-base.googleSheets'], 'airtable': ['nodes-base.airtable'], 'github': ['nodes-base.github'], 'git': ['nodes-base.github'], }; // Check for exact match const lowerNodeName = nodeName.toLowerCase(); if (commonNodeMappings[lowerNodeName]) { suggestions.push(...commonNodeMappings[lowerNodeName]); } // Check for partial matches Object.entries(commonNodeMappings).forEach(([key, values]) => { if (key.includes(lowerNodeName) || lowerNodeName.includes(key)) { values.forEach(v => { if (!suggestions.includes(v)) { suggestions.push(v); } }); } }); return suggestions.slice(0, 3); // Return top 3 suggestions } /** * Generate suggestions based on validation results */ private generateSuggestions( workflow: WorkflowJson, result: WorkflowValidationResult ): void { // Suggest adding trigger if missing if (result.statistics.triggerNodes === 0) { result.suggestions.push( 'Add a trigger node (e.g., Webhook, Schedule Trigger) to automate workflow execution' ); } // Suggest proper connection structure for workflows with connection errors const hasConnectionErrors = result.errors.some(e => e.message && ( e.message.includes('connection') || e.message.includes('Connection') || e.message.includes('Multi-node workflow has no connections') ) ); if (hasConnectionErrors) { result.suggestions.push( 'Example connection structure: connections: { "Manual Trigger": { "main": [[{ "node": "Set", "type": "main", "index": 0 }]] } }' ); result.suggestions.push( 'Remember: Use node NAMES (not IDs) in connections. The name is what you see in the UI, not the node type.' ); } // Suggest error handling if (!Object.values(workflow.connections).some(o => o.error)) { result.suggestions.push( 'Add error handling using the error output of nodes or an Error Trigger node' ); } // Suggest optimization for large workflows if (workflow.nodes.length > 20) { result.suggestions.push( 'Consider breaking this workflow into smaller sub-workflows for better maintainability' ); } // Suggest using Code node for complex logic const complexExpressionNodes = workflow.nodes.filter(node => { const jsonString = JSON.stringify(node.parameters); const expressionCount = (jsonString.match(/\{\{/g) || []).length; return expressionCount > 5; }); if (complexExpressionNodes.length > 0) { result.suggestions.push( 'Consider using a Code node for complex data transformations instead of multiple expressions' ); } // Suggest minimum workflow structure if (workflow.nodes.length === 1 && Object.keys(workflow.connections).length === 0) { result.suggestions.push( 'A minimal workflow needs: 1) A trigger node (e.g., Manual Trigger), 2) An action node (e.g., Set, HTTP Request), 3) A connection between them' ); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aegntic/aegntic-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server