Skip to main content
Glama

n8n-MCP

by 88-888
workflow-diff-engine.ts43.7 kB
/** * Workflow Diff Engine * Applies diff operations to n8n workflows */ import { v4 as uuidv4 } from 'uuid'; import { WorkflowDiffOperation, WorkflowDiffRequest, WorkflowDiffResult, WorkflowDiffValidationError, isNodeOperation, isConnectionOperation, isMetadataOperation, AddNodeOperation, RemoveNodeOperation, UpdateNodeOperation, MoveNodeOperation, EnableNodeOperation, DisableNodeOperation, AddConnectionOperation, RemoveConnectionOperation, RewireConnectionOperation, UpdateSettingsOperation, UpdateNameOperation, AddTagOperation, RemoveTagOperation, CleanStaleConnectionsOperation, ReplaceConnectionsOperation } from '../types/workflow-diff'; import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api'; import { Logger } from '../utils/logger'; import { validateWorkflowNode, validateWorkflowConnections } from './n8n-validation'; import { sanitizeNode, sanitizeWorkflowNodes } from './node-sanitizer'; const logger = new Logger({ prefix: '[WorkflowDiffEngine]' }); export class WorkflowDiffEngine { // Track node name changes during operations for connection reference updates private renameMap: Map<string, string> = new Map(); // Track warnings during operation processing private warnings: WorkflowDiffValidationError[] = []; /** * Apply diff operations to a workflow */ async applyDiff( workflow: Workflow, request: WorkflowDiffRequest ): Promise<WorkflowDiffResult> { try { // Reset tracking for this diff operation this.renameMap.clear(); this.warnings = []; // Clone workflow to avoid modifying original const workflowCopy = JSON.parse(JSON.stringify(workflow)); // Group operations by type for two-pass processing const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode']; const nodeOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = []; const otherOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = []; request.operations.forEach((operation, index) => { if (nodeOperationTypes.includes(operation.type)) { nodeOperations.push({ operation, index }); } else { otherOperations.push({ operation, index }); } }); const allOperations = [...nodeOperations, ...otherOperations]; const errors: WorkflowDiffValidationError[] = []; const appliedIndices: number[] = []; const failedIndices: number[] = []; // Process based on mode if (request.continueOnError) { // Best-effort mode: continue even if some operations fail for (const { operation, index } of allOperations) { const error = this.validateOperation(workflowCopy, operation); if (error) { errors.push({ operation: index, message: error, details: operation }); failedIndices.push(index); continue; } try { this.applyOperation(workflowCopy, operation); appliedIndices.push(index); } catch (error) { const errorMsg = `Failed to apply operation: ${error instanceof Error ? error.message : 'Unknown error'}`; errors.push({ operation: index, message: errorMsg, details: operation }); failedIndices.push(index); } } // Update connection references after all node renames (even in continueOnError mode) if (this.renameMap.size > 0 && appliedIndices.length > 0) { this.updateConnectionReferences(workflowCopy); logger.debug(`Auto-updated ${this.renameMap.size} node name references in connections (continueOnError mode)`); } // If validateOnly flag is set, return success without applying if (request.validateOnly) { return { success: errors.length === 0, message: errors.length === 0 ? 'Validation successful. All operations are valid.' : `Validation completed with ${errors.length} errors.`, errors: errors.length > 0 ? errors : undefined, warnings: this.warnings.length > 0 ? this.warnings : undefined, applied: appliedIndices, failed: failedIndices }; } const success = appliedIndices.length > 0; return { success, workflow: workflowCopy, operationsApplied: appliedIndices.length, message: `Applied ${appliedIndices.length} operations, ${failedIndices.length} failed (continueOnError mode)`, errors: errors.length > 0 ? errors : undefined, warnings: this.warnings.length > 0 ? this.warnings : undefined, applied: appliedIndices, failed: failedIndices }; } else { // Atomic mode: all operations must succeed // Pass 1: Validate and apply node operations first for (const { operation, index } of nodeOperations) { const error = this.validateOperation(workflowCopy, operation); if (error) { return { success: false, errors: [{ operation: index, message: error, details: operation }] }; } try { this.applyOperation(workflowCopy, operation); } catch (error) { return { success: false, errors: [{ operation: index, message: `Failed to apply operation: ${error instanceof Error ? error.message : 'Unknown error'}`, details: operation }] }; } } // Update connection references after all node renames if (this.renameMap.size > 0) { this.updateConnectionReferences(workflowCopy); logger.debug(`Auto-updated ${this.renameMap.size} node name references in connections`); } // Pass 2: Validate and apply other operations (connections, metadata) for (const { operation, index } of otherOperations) { const error = this.validateOperation(workflowCopy, operation); if (error) { return { success: false, errors: [{ operation: index, message: error, details: operation }] }; } try { this.applyOperation(workflowCopy, operation); } catch (error) { return { success: false, errors: [{ operation: index, message: `Failed to apply operation: ${error instanceof Error ? error.message : 'Unknown error'}`, details: operation }] }; } } // Sanitize ALL nodes in the workflow after operations are applied // This ensures existing invalid nodes (e.g., binary operators with singleValue: true) // are fixed automatically when any update is made to the workflow workflowCopy.nodes = workflowCopy.nodes.map((node: WorkflowNode) => sanitizeNode(node)); logger.debug('Applied full-workflow sanitization to all nodes'); // If validateOnly flag is set, return success without applying if (request.validateOnly) { return { success: true, message: 'Validation successful. Operations are valid but not applied.' }; } const operationsApplied = request.operations.length; return { success: true, workflow: workflowCopy, operationsApplied, message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`, warnings: this.warnings.length > 0 ? this.warnings : undefined }; } } catch (error) { logger.error('Failed to apply diff', error); return { success: false, errors: [{ operation: -1, message: `Diff engine error: ${error instanceof Error ? error.message : 'Unknown error'}` }] }; } } /** * Validate a single operation */ private validateOperation(workflow: Workflow, operation: WorkflowDiffOperation): string | null { switch (operation.type) { case 'addNode': return this.validateAddNode(workflow, operation); case 'removeNode': return this.validateRemoveNode(workflow, operation); case 'updateNode': return this.validateUpdateNode(workflow, operation); case 'moveNode': return this.validateMoveNode(workflow, operation); case 'enableNode': case 'disableNode': return this.validateToggleNode(workflow, operation); case 'addConnection': return this.validateAddConnection(workflow, operation); case 'removeConnection': return this.validateRemoveConnection(workflow, operation); case 'rewireConnection': return this.validateRewireConnection(workflow, operation as RewireConnectionOperation); case 'updateSettings': case 'updateName': case 'addTag': case 'removeTag': return null; // These are always valid case 'cleanStaleConnections': return this.validateCleanStaleConnections(workflow, operation); case 'replaceConnections': return this.validateReplaceConnections(workflow, operation); default: return `Unknown operation type: ${(operation as any).type}`; } } /** * Apply a single operation to the workflow */ private applyOperation(workflow: Workflow, operation: WorkflowDiffOperation): void { switch (operation.type) { case 'addNode': this.applyAddNode(workflow, operation); break; case 'removeNode': this.applyRemoveNode(workflow, operation); break; case 'updateNode': this.applyUpdateNode(workflow, operation); break; case 'moveNode': this.applyMoveNode(workflow, operation); break; case 'enableNode': this.applyEnableNode(workflow, operation); break; case 'disableNode': this.applyDisableNode(workflow, operation); break; case 'addConnection': this.applyAddConnection(workflow, operation); break; case 'removeConnection': this.applyRemoveConnection(workflow, operation); break; case 'rewireConnection': this.applyRewireConnection(workflow, operation as RewireConnectionOperation); break; case 'updateSettings': this.applyUpdateSettings(workflow, operation); break; case 'updateName': this.applyUpdateName(workflow, operation); break; case 'addTag': this.applyAddTag(workflow, operation); break; case 'removeTag': this.applyRemoveTag(workflow, operation); break; case 'cleanStaleConnections': this.applyCleanStaleConnections(workflow, operation); break; case 'replaceConnections': this.applyReplaceConnections(workflow, operation); break; } } // Node operation validators private validateAddNode(workflow: Workflow, operation: AddNodeOperation): string | null { const { node } = operation; // Check if node with same name already exists (use normalization to prevent collisions) const normalizedNewName = this.normalizeNodeName(node.name); const duplicate = workflow.nodes.find(n => this.normalizeNodeName(n.name) === normalizedNewName ); if (duplicate) { return `Node with name "${node.name}" already exists (normalized name matches existing node "${duplicate.name}")`; } // Validate node type format if (!node.type.includes('.')) { return `Invalid node type "${node.type}". Must include package prefix (e.g., "n8n-nodes-base.webhook")`; } if (node.type.startsWith('nodes-base.')) { return `Invalid node type "${node.type}". Use "n8n-nodes-base.${node.type.substring(11)}" instead`; } return null; } private validateRemoveNode(workflow: Workflow, operation: RemoveNodeOperation): string | null { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) { return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'removeNode'); } // Check if node has connections that would be broken const hasConnections = Object.values(workflow.connections).some(conn => { return Object.values(conn).some(outputs => outputs.some(connections => connections.some(c => c.node === node.name) ) ); }); if (hasConnections || workflow.connections[node.name]) { // This is a warning, not an error - connections will be cleaned up logger.warn(`Removing node "${node.name}" will break existing connections`); } return null; } private validateUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): string | null { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) { return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'updateNode'); } // Check for name collision if renaming if (operation.updates.name && operation.updates.name !== node.name) { const normalizedNewName = this.normalizeNodeName(operation.updates.name); const normalizedCurrentName = this.normalizeNodeName(node.name); // Only check collision if the names are actually different after normalization if (normalizedNewName !== normalizedCurrentName) { const collision = workflow.nodes.find(n => n.id !== node.id && this.normalizeNodeName(n.name) === normalizedNewName ); if (collision) { return `Cannot rename node "${node.name}" to "${operation.updates.name}": A node with that name already exists (id: ${collision.id.substring(0, 8)}...). Please choose a different name.`; } } } return null; } private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) { return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'moveNode'); } return null; } private validateToggleNode(workflow: Workflow, operation: EnableNodeOperation | DisableNodeOperation): string | null { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) { const operationType = operation.type === 'enableNode' ? 'enableNode' : 'disableNode'; return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', operationType); } return null; } // Connection operation validators private validateAddConnection(workflow: Workflow, operation: AddConnectionOperation): string | null { // Check for common parameter mistakes (Issue #249) const operationAny = operation as any; if (operationAny.sourceNodeId || operationAny.targetNodeId) { const wrongParams: string[] = []; if (operationAny.sourceNodeId) wrongParams.push('sourceNodeId'); if (operationAny.targetNodeId) wrongParams.push('targetNodeId'); return `Invalid parameter(s): ${wrongParams.join(', ')}. Use 'source' and 'target' instead. Example: {type: "addConnection", source: "Node Name", target: "Target Name"}`; } // Check for missing required parameters if (!operation.source) { return `Missing required parameter 'source'. The addConnection operation requires both 'source' and 'target' parameters. Check that you're using 'source' (not 'sourceNodeId').`; } if (!operation.target) { return `Missing required parameter 'target'. The addConnection operation requires both 'source' and 'target' parameters. Check that you're using 'target' (not 'targetNodeId').`; } const sourceNode = this.findNode(workflow, operation.source, operation.source); const targetNode = this.findNode(workflow, operation.target, operation.target); if (!sourceNode) { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`; } if (!targetNode) { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`; } // Check if connection already exists const sourceOutput = operation.sourceOutput || 'main'; const existing = workflow.connections[sourceNode.name]?.[sourceOutput]; if (existing) { const hasConnection = existing.some(connections => connections.some(c => c.node === targetNode.name) ); if (hasConnection) { return `Connection already exists from "${sourceNode.name}" to "${targetNode.name}"`; } } return null; } private validateRemoveConnection(workflow: Workflow, operation: RemoveConnectionOperation): string | null { // If ignoreErrors is true, don't validate - operation will silently succeed even if connection doesn't exist if (operation.ignoreErrors) { return null; } const sourceNode = this.findNode(workflow, operation.source, operation.source); const targetNode = this.findNode(workflow, operation.target, operation.target); if (!sourceNode) { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`; } if (!targetNode) { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`; } const sourceOutput = operation.sourceOutput || 'main'; const connections = workflow.connections[sourceNode.name]?.[sourceOutput]; if (!connections) { return `No connections found from "${sourceNode.name}"`; } const hasConnection = connections.some(conns => conns.some(c => c.node === targetNode.name) ); if (!hasConnection) { return `No connection exists from "${sourceNode.name}" to "${targetNode.name}"`; } return null; } private validateRewireConnection(workflow: Workflow, operation: RewireConnectionOperation): string | null { // Validate source node exists const sourceNode = this.findNode(workflow, operation.source, operation.source); if (!sourceNode) { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`; } // Validate "from" node exists (current target) const fromNode = this.findNode(workflow, operation.from, operation.from); if (!fromNode) { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `"From" node not found: "${operation.from}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`; } // Validate "to" node exists (new target) const toNode = this.findNode(workflow, operation.to, operation.to); if (!toNode) { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `"To" node not found: "${operation.to}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`; } // Resolve smart parameters (branch, case) before validating connections const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation); // Validate that connection from source to "from" exists at the specific index const connections = workflow.connections[sourceNode.name]?.[sourceOutput]; if (!connections) { return `No connections found from "${sourceNode.name}" on output "${sourceOutput}"`; } if (!connections[sourceIndex]) { return `No connections found from "${sourceNode.name}" on output "${sourceOutput}" at index ${sourceIndex}`; } const hasConnection = connections[sourceIndex].some(c => c.node === fromNode.name); if (!hasConnection) { return `No connection exists from "${sourceNode.name}" to "${fromNode.name}" on output "${sourceOutput}" at index ${sourceIndex}"`; } return null; } // Node operation appliers private applyAddNode(workflow: Workflow, operation: AddNodeOperation): void { const newNode: WorkflowNode = { id: operation.node.id || uuidv4(), name: operation.node.name, type: operation.node.type, typeVersion: operation.node.typeVersion || 1, position: operation.node.position, parameters: operation.node.parameters || {}, credentials: operation.node.credentials, disabled: operation.node.disabled, notes: operation.node.notes, notesInFlow: operation.node.notesInFlow, continueOnFail: operation.node.continueOnFail, onError: operation.node.onError, retryOnFail: operation.node.retryOnFail, maxTries: operation.node.maxTries, waitBetweenTries: operation.node.waitBetweenTries, alwaysOutputData: operation.node.alwaysOutputData, executeOnce: operation.node.executeOnce }; // Sanitize node to ensure complete metadata (filter options, operator structure, etc.) const sanitizedNode = sanitizeNode(newNode); workflow.nodes.push(sanitizedNode); } private applyRemoveNode(workflow: Workflow, operation: RemoveNodeOperation): void { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) return; // Remove node from array const index = workflow.nodes.findIndex(n => n.id === node.id); if (index !== -1) { workflow.nodes.splice(index, 1); } // Remove all connections from this node delete workflow.connections[node.name]; // Remove all connections to this node Object.keys(workflow.connections).forEach(sourceName => { const sourceConnections = workflow.connections[sourceName]; Object.keys(sourceConnections).forEach(outputName => { sourceConnections[outputName] = sourceConnections[outputName].map(connections => connections.filter(conn => conn.node !== node.name) ).filter(connections => connections.length > 0); // Clean up empty arrays if (sourceConnections[outputName].length === 0) { delete sourceConnections[outputName]; } }); // Clean up empty connection objects if (Object.keys(sourceConnections).length === 0) { delete workflow.connections[sourceName]; } }); } private applyUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): void { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) return; // Track node renames for connection reference updates if (operation.updates.name && operation.updates.name !== node.name) { const oldName = node.name; const newName = operation.updates.name; this.renameMap.set(oldName, newName); logger.debug(`Tracking rename: "${oldName}" → "${newName}"`); } // Apply updates using dot notation Object.entries(operation.updates).forEach(([path, value]) => { this.setNestedProperty(node, path, value); }); // Sanitize node after updates to ensure metadata is complete const sanitized = sanitizeNode(node); // Update the node in-place Object.assign(node, sanitized); } private applyMoveNode(workflow: Workflow, operation: MoveNodeOperation): void { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) return; node.position = operation.position; } private applyEnableNode(workflow: Workflow, operation: EnableNodeOperation): void { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) return; node.disabled = false; } private applyDisableNode(workflow: Workflow, operation: DisableNodeOperation): void { const node = this.findNode(workflow, operation.nodeId, operation.nodeName); if (!node) return; node.disabled = true; } /** * Resolve smart parameters (branch, case) to technical parameters * Phase 1 UX improvement: Semantic parameters for multi-output nodes */ private resolveSmartParameters( workflow: Workflow, operation: AddConnectionOperation | RewireConnectionOperation ): { sourceOutput: string; sourceIndex: number } { const sourceNode = this.findNode(workflow, operation.source, operation.source); // Start with explicit values or defaults let sourceOutput = operation.sourceOutput ?? 'main'; let sourceIndex = operation.sourceIndex ?? 0; // Smart parameter: branch (for IF nodes) // IF nodes use 'main' output with index 0 (true) or 1 (false) if (operation.branch !== undefined && operation.sourceIndex === undefined) { // Only apply if sourceIndex not explicitly set if (sourceNode?.type === 'n8n-nodes-base.if') { sourceIndex = operation.branch === 'true' ? 0 : 1; // sourceOutput remains 'main' (do not change it) } } // Smart parameter: case (for Switch nodes) if (operation.case !== undefined && operation.sourceIndex === undefined) { // Only apply if sourceIndex not explicitly set sourceIndex = operation.case; } // Validation: Warn if using sourceIndex with If/Switch nodes without smart parameters if (sourceNode && operation.sourceIndex !== undefined && operation.branch === undefined && operation.case === undefined) { if (sourceNode.type === 'n8n-nodes-base.if') { this.warnings.push({ operation: -1, // Not tied to specific operation index in request message: `Connection to If node "${operation.source}" uses sourceIndex=${operation.sourceIndex}. ` + `Consider using branch="true" or branch="false" for better clarity. ` + `If node outputs: main[0]=TRUE branch, main[1]=FALSE branch.` }); } else if (sourceNode.type === 'n8n-nodes-base.switch') { this.warnings.push({ operation: -1, // Not tied to specific operation index in request message: `Connection to Switch node "${operation.source}" uses sourceIndex=${operation.sourceIndex}. ` + `Consider using case=N for better clarity (case=0 for first output, case=1 for second, etc.).` }); } } return { sourceOutput, sourceIndex }; } // Connection operation appliers private applyAddConnection(workflow: Workflow, operation: AddConnectionOperation): void { const sourceNode = this.findNode(workflow, operation.source, operation.source); const targetNode = this.findNode(workflow, operation.target, operation.target); if (!sourceNode || !targetNode) return; // Resolve smart parameters (branch, case) to technical parameters const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation); // Use nullish coalescing to properly handle explicit 0 values const targetInput = operation.targetInput ?? 'main'; const targetIndex = operation.targetIndex ?? 0; // Initialize source node connections object if (!workflow.connections[sourceNode.name]) { workflow.connections[sourceNode.name] = {}; } // Initialize output type array if (!workflow.connections[sourceNode.name][sourceOutput]) { workflow.connections[sourceNode.name][sourceOutput] = []; } // Get reference to output array for clarity const outputArray = workflow.connections[sourceNode.name][sourceOutput]; // Ensure we have connection arrays up to and including the target sourceIndex while (outputArray.length <= sourceIndex) { outputArray.push([]); } // Defensive: Verify the slot is an array (should always be true after while loop) if (!Array.isArray(outputArray[sourceIndex])) { outputArray[sourceIndex] = []; } // Add connection to the correct sourceIndex outputArray[sourceIndex].push({ node: targetNode.name, type: targetInput, index: targetIndex }); } private applyRemoveConnection(workflow: Workflow, operation: RemoveConnectionOperation): void { const sourceNode = this.findNode(workflow, operation.source, operation.source); const targetNode = this.findNode(workflow, operation.target, operation.target); // If ignoreErrors is true, silently succeed even if nodes don't exist if (!sourceNode || !targetNode) { if (operation.ignoreErrors) { return; // Gracefully handle missing nodes } return; // Should never reach here if validation passed, but safety check } const sourceOutput = operation.sourceOutput || 'main'; const connections = workflow.connections[sourceNode.name]?.[sourceOutput]; if (!connections) return; // Remove connection from all indices workflow.connections[sourceNode.name][sourceOutput] = connections.map(conns => conns.filter(conn => conn.node !== targetNode.name) ); // Remove trailing empty arrays only (preserve intermediate empty arrays to maintain indices) const outputConnections = workflow.connections[sourceNode.name][sourceOutput]; while (outputConnections.length > 0 && outputConnections[outputConnections.length - 1].length === 0) { outputConnections.pop(); } if (outputConnections.length === 0) { delete workflow.connections[sourceNode.name][sourceOutput]; } if (Object.keys(workflow.connections[sourceNode.name]).length === 0) { delete workflow.connections[sourceNode.name]; } } /** * Rewire a connection from one target to another * This is a semantic wrapper around removeConnection + addConnection * that provides clear intent: "rewire connection from X to Y" * * @param workflow - Workflow to modify * @param operation - Rewire operation specifying source, from, and to */ private applyRewireConnection(workflow: Workflow, operation: RewireConnectionOperation): void { // Resolve smart parameters (branch, case) to technical parameters const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation); // First, remove the old connection (source → from) this.applyRemoveConnection(workflow, { type: 'removeConnection', source: operation.source, target: operation.from, sourceOutput: sourceOutput, targetInput: operation.targetInput }); // Then, add the new connection (source → to) this.applyAddConnection(workflow, { type: 'addConnection', source: operation.source, target: operation.to, sourceOutput: sourceOutput, targetInput: operation.targetInput, sourceIndex: sourceIndex, targetIndex: 0 // Default target index for new connection }); } // Metadata operation appliers private applyUpdateSettings(workflow: Workflow, operation: UpdateSettingsOperation): void { if (!workflow.settings) { workflow.settings = {}; } Object.assign(workflow.settings, operation.settings); } private applyUpdateName(workflow: Workflow, operation: UpdateNameOperation): void { workflow.name = operation.name; } private applyAddTag(workflow: Workflow, operation: AddTagOperation): void { if (!workflow.tags) { workflow.tags = []; } if (!workflow.tags.includes(operation.tag)) { workflow.tags.push(operation.tag); } } private applyRemoveTag(workflow: Workflow, operation: RemoveTagOperation): void { if (!workflow.tags) return; const index = workflow.tags.indexOf(operation.tag); if (index !== -1) { workflow.tags.splice(index, 1); } } // Connection cleanup operation validators private validateCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): string | null { // This operation is always valid - it just cleans up what it finds return null; } private validateReplaceConnections(workflow: Workflow, operation: ReplaceConnectionsOperation): string | null { // Validate that all referenced nodes exist const nodeNames = new Set(workflow.nodes.map(n => n.name)); for (const [sourceName, outputs] of Object.entries(operation.connections)) { if (!nodeNames.has(sourceName)) { return `Source node not found in connections: ${sourceName}`; } // outputs is the value from Object.entries, need to iterate its keys for (const outputName of Object.keys(outputs)) { const connections = outputs[outputName]; for (const conns of connections) { for (const conn of conns) { if (!nodeNames.has(conn.node)) { return `Target node not found in connections: ${conn.node}`; } } } } } return null; } // Connection cleanup operation appliers private applyCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): void { const nodeNames = new Set(workflow.nodes.map(n => n.name)); const staleConnections: Array<{ from: string; to: string }> = []; // If dryRun, only identify stale connections without removing them if (operation.dryRun) { for (const [sourceName, outputs] of Object.entries(workflow.connections)) { if (!nodeNames.has(sourceName)) { for (const [outputName, connections] of Object.entries(outputs)) { for (const conns of connections) { for (const conn of conns) { staleConnections.push({ from: sourceName, to: conn.node }); } } } } else { for (const [outputName, connections] of Object.entries(outputs)) { for (const conns of connections) { for (const conn of conns) { if (!nodeNames.has(conn.node)) { staleConnections.push({ from: sourceName, to: conn.node }); } } } } } } logger.info(`[DryRun] Would remove ${staleConnections.length} stale connections:`, staleConnections); return; } // Actually remove stale connections for (const [sourceName, outputs] of Object.entries(workflow.connections)) { // If source node doesn't exist, mark all connections as stale if (!nodeNames.has(sourceName)) { for (const [outputName, connections] of Object.entries(outputs)) { for (const conns of connections) { for (const conn of conns) { staleConnections.push({ from: sourceName, to: conn.node }); } } } delete workflow.connections[sourceName]; continue; } // Check each connection for (const [outputName, connections] of Object.entries(outputs)) { const filteredConnections = connections.map(conns => conns.filter(conn => { if (!nodeNames.has(conn.node)) { staleConnections.push({ from: sourceName, to: conn.node }); return false; } return true; }) ).filter(conns => conns.length > 0); if (filteredConnections.length === 0) { delete outputs[outputName]; } else { outputs[outputName] = filteredConnections; } } // Clean up empty output objects if (Object.keys(outputs).length === 0) { delete workflow.connections[sourceName]; } } logger.info(`Removed ${staleConnections.length} stale connections`); } private applyReplaceConnections(workflow: Workflow, operation: ReplaceConnectionsOperation): void { workflow.connections = operation.connections; } /** * Update all connection references when nodes are renamed. * This method is called after node operations to ensure connection integrity. * * Updates: * - Connection object keys (source node names) * - Connection target.node values (target node names) * - All output types (main, error, ai_tool, ai_languageModel, etc.) * * @param workflow - The workflow to update */ private updateConnectionReferences(workflow: Workflow): void { if (this.renameMap.size === 0) return; logger.debug(`Updating connection references for ${this.renameMap.size} renamed nodes`); // Create a mapping of all renames (old → new) const renames = new Map(this.renameMap); // Step 1: Update connection object keys (source node names) const updatedConnections: WorkflowConnection = {}; for (const [sourceName, outputs] of Object.entries(workflow.connections)) { // Check if this source node was renamed const newSourceName = renames.get(sourceName) || sourceName; updatedConnections[newSourceName] = outputs; } // Step 2: Update target node references within connections for (const [sourceName, outputs] of Object.entries(updatedConnections)) { // Iterate through all output types (main, error, ai_tool, ai_languageModel, etc.) for (const [outputType, connections] of Object.entries(outputs)) { // connections is Array<Array<{node, type, index}>> for (let outputIndex = 0; outputIndex < connections.length; outputIndex++) { const connectionsAtIndex = connections[outputIndex]; for (let connIndex = 0; connIndex < connectionsAtIndex.length; connIndex++) { const connection = connectionsAtIndex[connIndex]; // Check if target node was renamed if (renames.has(connection.node)) { const newTargetName = renames.get(connection.node)!; connection.node = newTargetName; logger.debug(`Updated connection: ${sourceName}[${outputType}][${outputIndex}][${connIndex}].node: "${connection.node}" → "${newTargetName}"`); } } } } } // Replace workflow connections with updated connections workflow.connections = updatedConnections; logger.info(`Auto-updated ${this.renameMap.size} node name references in connections`); } // Helper methods /** * Normalize node names to handle special characters and escaping differences. * Fixes issue #270: apostrophes and other special characters in node names. * * ⚠️ WARNING: Normalization can cause collisions between names that differ only in: * - Leading/trailing whitespace * - Multiple consecutive spaces vs single spaces * - Escaped vs unescaped quotes/backslashes * - Different types of whitespace (tabs, newlines, spaces) * * Examples of names that normalize to the SAME value: * - "Node 'test'" === "Node 'test'" (multiple spaces) * - "Node 'test'" === "Node\t'test'" (tab vs space) * - "Node 'test'" === "Node \\'test\\'" (escaped quotes) * - "Path\\to\\file" === "Path\\\\to\\\\file" (escaped backslashes) * * Best Practice: For node names with special characters, prefer using node IDs * to avoid ambiguity. Use n8n_get_workflow_structure() to get node IDs. * * @param name - The node name to normalize * @returns Normalized node name for safe comparison */ private normalizeNodeName(name: string): string { return name .trim() // Remove leading/trailing whitespace .replace(/\\\\/g, '\\') // FIRST: Unescape backslashes: \\ -> \ (must be first to handle multiply-escaped chars) .replace(/\\'/g, "'") // THEN: Unescape single quotes: \' -> ' .replace(/\\"/g, '"') // THEN: Unescape double quotes: \" -> " .replace(/\s+/g, ' '); // FINALLY: Normalize all whitespace (spaces, tabs, newlines) to single space } /** * Find a node by ID or name in the workflow. * Uses string normalization to handle special characters (Issue #270). * * @param workflow - The workflow to search in * @param nodeId - Optional node ID to search for * @param nodeName - Optional node name to search for * @returns The found node or null */ private findNode(workflow: Workflow, nodeId?: string, nodeName?: string): WorkflowNode | null { // Try to find by ID first (exact match, no normalization needed for UUIDs) if (nodeId) { const nodeById = workflow.nodes.find(n => n.id === nodeId); if (nodeById) return nodeById; } // Try to find by name with normalization (handles special characters) if (nodeName) { const normalizedSearch = this.normalizeNodeName(nodeName); const nodeByName = workflow.nodes.find(n => this.normalizeNodeName(n.name) === normalizedSearch ); if (nodeByName) return nodeByName; } // Fallback: If nodeId provided but not found, try treating it as a name // This allows operations to work with either IDs or names flexibly if (nodeId && !nodeName) { const normalizedSearch = this.normalizeNodeName(nodeId); const nodeByName = workflow.nodes.find(n => this.normalizeNodeName(n.name) === normalizedSearch ); if (nodeByName) return nodeByName; } return null; } /** * Format a consistent "node not found" error message with helpful context. * Shows available nodes with IDs and tips about using node IDs for special characters. * * @param workflow - The workflow being validated * @param nodeIdentifier - The node ID or name that wasn't found * @param operationType - The operation being performed (e.g., "removeNode", "updateNode") * @returns Formatted error message with available nodes and helpful tips */ private formatNodeNotFoundError( workflow: Workflow, nodeIdentifier: string, operationType: string ): string { const availableNodes = workflow.nodes .map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`) .join(', '); return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`; } private setNestedProperty(obj: any, path: string, value: any): void { const keys = path.split('.'); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; if (!(key in current) || typeof current[key] !== 'object') { current[key] = {}; } current = current[key]; } current[keys[keys.length - 1]] = value; } }

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/88-888/n8n-mcp'

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