mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 22:42:04 +00:00
This fix addresses issue #351 where Execute Workflow Trigger and other trigger nodes were incorrectly treated as regular nodes, causing "disconnected node" errors during partial workflow updates. ## Changes **1. Created Shared Trigger Detection Utilities** - src/utils/node-type-utils.ts: - isTriggerNode(): Recognizes ALL trigger types using flexible pattern matching - isActivatableTrigger(): Returns false for executeWorkflowTrigger (not activatable) - getTriggerTypeDescription(): Human-readable trigger descriptions **2. Updated Workflow Validation** - src/services/n8n-validation.ts: - Replaced hardcoded webhookTypes Set with isTriggerNode() function - Added validation preventing activation of workflows with only executeWorkflowTrigger - Now recognizes 200+ trigger types across n8n packages **3. Updated Workflow Validator** - src/services/workflow-validator.ts: - Replaced inline trigger detection with shared isTriggerNode() function - Ensures consistency across all validation code paths **4. Comprehensive Tests** - tests/unit/utils/node-type-utils.test.ts: - Added 30+ tests for trigger detection functions - Validates all trigger types are recognized correctly - Confirms executeWorkflowTrigger is trigger but not activatable ## Impact Before: - Execute Workflow Trigger flagged as disconnected node - Schedule/email/polling triggers also rejected - Users forced to keep unnecessary webhook triggers After: - ALL trigger types recognized (executeWorkflowTrigger, scheduleTrigger, etc.) - No disconnected node errors for triggers - Clear error when activating workflow with only executeWorkflowTrigger - Future-proof (new triggers automatically supported) ## Testing - Build: ✅ Passes - Typecheck: ✅ Passes - Unit tests: ✅ All pass - Validation test: ✅ Trigger detection working correctly Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
255 lines
7.8 KiB
TypeScript
255 lines
7.8 KiB
TypeScript
/**
|
|
* Utility functions for working with n8n node types
|
|
* Provides consistent normalization and transformation of node type strings
|
|
*/
|
|
|
|
/**
|
|
* Normalize a node type to the standard short form
|
|
* Handles both old-style (n8n-nodes-base.) and new-style (nodes-base.) prefixes
|
|
*
|
|
* @example
|
|
* normalizeNodeType('n8n-nodes-base.httpRequest') // 'nodes-base.httpRequest'
|
|
* normalizeNodeType('@n8n/n8n-nodes-langchain.openAi') // 'nodes-langchain.openAi'
|
|
* normalizeNodeType('nodes-base.webhook') // 'nodes-base.webhook' (unchanged)
|
|
*/
|
|
export function normalizeNodeType(type: string): string {
|
|
if (!type) return type;
|
|
|
|
return type
|
|
.replace(/^n8n-nodes-base\./, 'nodes-base.')
|
|
.replace(/^@n8n\/n8n-nodes-langchain\./, 'nodes-langchain.');
|
|
}
|
|
|
|
/**
|
|
* Convert a short-form node type to the full package name
|
|
*
|
|
* @example
|
|
* denormalizeNodeType('nodes-base.httpRequest', 'base') // 'n8n-nodes-base.httpRequest'
|
|
* denormalizeNodeType('nodes-langchain.openAi', 'langchain') // '@n8n/n8n-nodes-langchain.openAi'
|
|
*/
|
|
export function denormalizeNodeType(type: string, packageType: 'base' | 'langchain'): string {
|
|
if (!type) return type;
|
|
|
|
if (packageType === 'base') {
|
|
return type.replace(/^nodes-base\./, 'n8n-nodes-base.');
|
|
}
|
|
|
|
return type.replace(/^nodes-langchain\./, '@n8n/n8n-nodes-langchain.');
|
|
}
|
|
|
|
/**
|
|
* Extract the node name from a full node type
|
|
*
|
|
* @example
|
|
* extractNodeName('nodes-base.httpRequest') // 'httpRequest'
|
|
* extractNodeName('n8n-nodes-base.webhook') // 'webhook'
|
|
*/
|
|
export function extractNodeName(type: string): string {
|
|
if (!type) return '';
|
|
|
|
// First normalize the type
|
|
const normalized = normalizeNodeType(type);
|
|
|
|
// Extract everything after the last dot
|
|
const parts = normalized.split('.');
|
|
return parts[parts.length - 1] || '';
|
|
}
|
|
|
|
/**
|
|
* Get the package prefix from a node type
|
|
*
|
|
* @example
|
|
* getNodePackage('nodes-base.httpRequest') // 'nodes-base'
|
|
* getNodePackage('nodes-langchain.openAi') // 'nodes-langchain'
|
|
*/
|
|
export function getNodePackage(type: string): string | null {
|
|
if (!type || !type.includes('.')) return null;
|
|
|
|
// First normalize the type
|
|
const normalized = normalizeNodeType(type);
|
|
|
|
// Extract everything before the first dot
|
|
const parts = normalized.split('.');
|
|
return parts[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Check if a node type is from the base package
|
|
*/
|
|
export function isBaseNode(type: string): boolean {
|
|
const normalized = normalizeNodeType(type);
|
|
return normalized.startsWith('nodes-base.');
|
|
}
|
|
|
|
/**
|
|
* Check if a node type is from the langchain package
|
|
*/
|
|
export function isLangChainNode(type: string): boolean {
|
|
const normalized = normalizeNodeType(type);
|
|
return normalized.startsWith('nodes-langchain.');
|
|
}
|
|
|
|
/**
|
|
* Validate if a string looks like a valid node type
|
|
* (has package prefix and node name)
|
|
*/
|
|
export function isValidNodeTypeFormat(type: string): boolean {
|
|
if (!type || typeof type !== 'string') return false;
|
|
|
|
// Must contain at least one dot
|
|
if (!type.includes('.')) return false;
|
|
|
|
const parts = type.split('.');
|
|
|
|
// Must have exactly 2 parts (package and node name)
|
|
if (parts.length !== 2) return false;
|
|
|
|
// Both parts must be non-empty
|
|
return parts[0].length > 0 && parts[1].length > 0;
|
|
}
|
|
|
|
/**
|
|
* Try multiple variations of a node type to find a match
|
|
* Returns an array of variations to try in order
|
|
*
|
|
* @example
|
|
* getNodeTypeVariations('httpRequest')
|
|
* // ['nodes-base.httpRequest', 'n8n-nodes-base.httpRequest', 'nodes-langchain.httpRequest', ...]
|
|
*/
|
|
export function getNodeTypeVariations(type: string): string[] {
|
|
const variations: string[] = [];
|
|
|
|
// If it already has a package prefix, try normalized version first
|
|
if (type.includes('.')) {
|
|
variations.push(normalizeNodeType(type));
|
|
|
|
// Also try the denormalized versions
|
|
const normalized = normalizeNodeType(type);
|
|
if (normalized.startsWith('nodes-base.')) {
|
|
variations.push(denormalizeNodeType(normalized, 'base'));
|
|
} else if (normalized.startsWith('nodes-langchain.')) {
|
|
variations.push(denormalizeNodeType(normalized, 'langchain'));
|
|
}
|
|
} else {
|
|
// No package prefix, try common packages
|
|
variations.push(`nodes-base.${type}`);
|
|
variations.push(`n8n-nodes-base.${type}`);
|
|
variations.push(`nodes-langchain.${type}`);
|
|
variations.push(`@n8n/n8n-nodes-langchain.${type}`);
|
|
}
|
|
|
|
// Remove duplicates while preserving order
|
|
return [...new Set(variations)];
|
|
}
|
|
|
|
/**
|
|
* Check if a node is ANY type of trigger (including executeWorkflowTrigger)
|
|
*
|
|
* This function determines if a node can start a workflow execution.
|
|
* Returns true for:
|
|
* - Webhook triggers (webhook, webhookTrigger)
|
|
* - Time-based triggers (schedule, cron)
|
|
* - Poll-based triggers (emailTrigger, slackTrigger, etc.)
|
|
* - Manual triggers (manualTrigger, start, formTrigger)
|
|
* - Sub-workflow triggers (executeWorkflowTrigger)
|
|
*
|
|
* Used for: Disconnection validation (triggers don't need incoming connections)
|
|
*
|
|
* @param nodeType - The node type to check (e.g., "n8n-nodes-base.executeWorkflowTrigger")
|
|
* @returns true if node is any type of trigger
|
|
*/
|
|
export function isTriggerNode(nodeType: string): boolean {
|
|
const normalized = normalizeNodeType(nodeType);
|
|
const lowerType = normalized.toLowerCase();
|
|
|
|
// Check for trigger pattern in node type name
|
|
if (lowerType.includes('trigger')) {
|
|
return true;
|
|
}
|
|
|
|
// Check for webhook nodes (excluding respondToWebhook which is NOT a trigger)
|
|
if (lowerType.includes('webhook') && !lowerType.includes('respond')) {
|
|
return true;
|
|
}
|
|
|
|
// Check for specific trigger types that don't have 'trigger' in their name
|
|
const specificTriggers = [
|
|
'nodes-base.start',
|
|
'nodes-base.manualTrigger',
|
|
'nodes-base.formTrigger'
|
|
];
|
|
|
|
return specificTriggers.includes(normalized);
|
|
}
|
|
|
|
/**
|
|
* Check if a node is an ACTIVATABLE trigger (excludes executeWorkflowTrigger)
|
|
*
|
|
* This function determines if a node can be used to activate a workflow.
|
|
* Returns true for:
|
|
* - Webhook triggers (webhook, webhookTrigger)
|
|
* - Time-based triggers (schedule, cron)
|
|
* - Poll-based triggers (emailTrigger, slackTrigger, etc.)
|
|
* - Manual triggers (manualTrigger, start, formTrigger)
|
|
*
|
|
* Returns FALSE for:
|
|
* - executeWorkflowTrigger (can only be invoked by other workflows)
|
|
*
|
|
* Used for: Activation validation (active workflows need activatable triggers)
|
|
*
|
|
* @param nodeType - The node type to check
|
|
* @returns true if node can activate a workflow
|
|
*/
|
|
export function isActivatableTrigger(nodeType: string): boolean {
|
|
const normalized = normalizeNodeType(nodeType);
|
|
const lowerType = normalized.toLowerCase();
|
|
|
|
// executeWorkflowTrigger cannot activate a workflow (invoked by other workflows)
|
|
if (lowerType.includes('executeworkflow')) {
|
|
return false;
|
|
}
|
|
|
|
// All other triggers can activate workflows
|
|
return isTriggerNode(nodeType);
|
|
}
|
|
|
|
/**
|
|
* Get human-readable description of trigger type
|
|
*
|
|
* @param nodeType - The node type
|
|
* @returns Description of what triggers this node
|
|
*/
|
|
export function getTriggerTypeDescription(nodeType: string): string {
|
|
const normalized = normalizeNodeType(nodeType);
|
|
const lowerType = normalized.toLowerCase();
|
|
|
|
if (lowerType.includes('executeworkflow')) {
|
|
return 'Execute Workflow Trigger (invoked by other workflows)';
|
|
}
|
|
|
|
if (lowerType.includes('webhook')) {
|
|
return 'Webhook Trigger (HTTP requests)';
|
|
}
|
|
|
|
if (lowerType.includes('schedule') || lowerType.includes('cron')) {
|
|
return 'Schedule Trigger (time-based)';
|
|
}
|
|
|
|
if (lowerType.includes('manual') || normalized === 'nodes-base.start') {
|
|
return 'Manual Trigger (manual execution)';
|
|
}
|
|
|
|
if (lowerType.includes('email') || lowerType.includes('imap') || lowerType.includes('gmail')) {
|
|
return 'Email Trigger (polling)';
|
|
}
|
|
|
|
if (lowerType.includes('form')) {
|
|
return 'Form Trigger (form submissions)';
|
|
}
|
|
|
|
if (lowerType.includes('trigger')) {
|
|
return 'Trigger (event-based)';
|
|
}
|
|
|
|
return 'Unknown trigger type';
|
|
} |