n8n-mcp
Version:
Integration between n8n workflow automation and Model Context Protocol (MCP)
1,003 lines (1,002 loc) • 83.5 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WorkflowValidator = exports.VALID_CONNECTION_TYPES = void 0;
const crypto_1 = __importDefault(require("crypto"));
const expression_validator_1 = require("./expression-validator");
const expression_utils_1 = require("../utils/expression-utils");
const expression_format_validator_1 = require("./expression-format-validator");
const node_similarity_service_1 = require("./node-similarity-service");
const node_type_normalizer_1 = require("../utils/node-type-normalizer");
const typeversion_1 = require("../utils/typeversion");
const logger_1 = require("../utils/logger");
const ai_node_validator_1 = require("./ai-node-validator");
const ai_tool_validators_1 = require("./ai-tool-validators");
const node_type_utils_1 = require("../utils/node-type-utils");
const node_classification_1 = require("../utils/node-classification");
const n8n_validation_1 = require("./n8n-validation");
const tool_variant_generator_1 = require("./tool-variant-generator");
const logger = new logger_1.Logger({ prefix: '[WorkflowValidator]' });
const ADD_ERROR_HANDLING_ADVISORY = 'Consider adding error handling to your workflow';
exports.VALID_CONNECTION_TYPES = new Set([
'main',
'error',
...ai_node_validator_1.AI_CONNECTION_TYPES,
'ai_agent',
'ai_chain',
'ai_retriever',
'ai_reranker',
]);
class WorkflowValidator {
constructor(nodeRepository, nodeValidator) {
this.nodeRepository = nodeRepository;
this.nodeValidator = nodeValidator;
this.currentWorkflow = null;
this.similarityService = new node_similarity_service_1.NodeSimilarityService(nodeRepository);
}
async validateWorkflow(workflow, options = {}) {
this.currentWorkflow = workflow;
const { validateNodes = true, validateConnections = true, validateExpressions = true, profile = 'runtime' } = options;
const result = {
valid: true,
errors: [],
warnings: [],
statistics: {
totalNodes: 0,
enabledNodes: 0,
triggerNodes: 0,
validConnections: 0,
invalidConnections: 0,
expressionsValidated: 0,
},
suggestions: []
};
try {
if (!workflow) {
result.errors.push({
type: 'error',
message: 'Invalid workflow structure: workflow is null or undefined'
});
result.valid = false;
return result;
}
const executableNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !(0, node_classification_1.isNonExecutableNode)(n.type)) : [];
result.statistics.totalNodes = executableNodes.length;
result.statistics.enabledNodes = executableNodes.filter(n => !n.disabled).length;
this.validateWorkflowStructure(workflow, result);
if (workflow.nodes && Array.isArray(workflow.nodes) && workflow.connections && typeof workflow.connections === 'object') {
if (validateNodes && workflow.nodes.length > 0) {
await this.validateAllNodes(workflow, result, profile);
}
if (validateConnections) {
this.validateConnections(workflow, result, profile);
}
if (validateExpressions && workflow.nodes.length > 0) {
this.validateExpressions(workflow, result, profile);
}
if (workflow.nodes.length > 0) {
this.checkWorkflowPatterns(workflow, result, profile);
}
if (workflow.nodes.length > 0 && (0, ai_node_validator_1.hasAINodes)(workflow)) {
const aiIssues = (0, ai_node_validator_1.validateAISpecificNodes)(workflow);
for (const issue of aiIssues) {
if (issue.severity === 'info') {
result.suggestions.push(issue.message);
continue;
}
const validationIssue = {
type: issue.severity === 'error' ? 'error' : 'warning',
nodeId: issue.nodeId,
nodeName: issue.nodeName,
message: issue.message,
details: issue.code ? { code: issue.code } : undefined
};
if (issue.severity === 'error') {
result.errors.push(validationIssue);
}
else {
result.warnings.push(validationIssue);
}
}
}
this.generateSuggestions(workflow, result, profile);
if (result.errors.length > 0) {
this.addErrorRecoverySuggestions(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;
}
validateWorkflowStructure(workflow, result) {
if (!workflow.nodes) {
result.errors.push({
type: 'error',
message: workflow.nodes === null ? 'nodes must be an array' : 'Workflow must have a nodes array'
});
return;
}
if (!Array.isArray(workflow.nodes)) {
result.errors.push({
type: 'error',
message: 'nodes must be an array'
});
return;
}
if (!workflow.connections) {
result.errors.push({
type: 'error',
message: workflow.connections === null ? 'connections must be an object' : 'Workflow must have a connections object'
});
return;
}
if (typeof workflow.connections !== 'object' || Array.isArray(workflow.connections)) {
result.errors.push({
type: 'error',
message: 'connections must be an object'
});
return;
}
if (workflow.nodes.length === 0) {
result.warnings.push({
type: 'warning',
message: 'Workflow is empty - no nodes defined'
});
return;
}
if (workflow.nodes.length === 1) {
const singleNode = workflow.nodes[0];
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(singleNode.type);
const isWebhook = normalizedType === 'nodes-base.webhook' ||
normalizedType === 'nodes-base.webhookTrigger';
const isLangchainNode = normalizedType.startsWith('nodes-langchain.');
if (!isWebhook && !isLangchainNode) {
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 (isWebhook && 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.'
});
}
}
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 }]] } }'
});
}
}
const nodeNames = new Set();
const nodeIds = new Set();
const nodeIdToIndex = new Map();
for (let i = 0; i < workflow.nodes.length; i++) {
const node = workflow.nodes[i];
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 (node.id && nodeIds.has(node.id)) {
const firstNodeIndex = nodeIdToIndex.get(node.id);
const firstNode = firstNodeIndex !== undefined ? workflow.nodes[firstNodeIndex] : undefined;
result.errors.push({
type: 'error',
nodeId: node.id,
message: `Duplicate node ID: "${node.id}". Node at index ${i} (name: "${node.name}", type: "${node.type}") conflicts with node at index ${firstNodeIndex} (name: "${firstNode?.name || 'unknown'}", type: "${firstNode?.type || 'unknown'}"). Each node must have a unique ID. Generate a new UUID using crypto.randomUUID() - Example: {id: "${crypto_1.default.randomUUID()}", name: "${node.name}", type: "${node.type}", ...}`
});
}
else if (node.id) {
nodeIds.add(node.id);
nodeIdToIndex.set(node.id, i);
}
}
const triggerNodes = workflow.nodes.filter(n => (0, node_type_utils_1.isTriggerNode)(n.type));
result.statistics.triggerNodes = triggerNodes.length;
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.'
});
}
}
async validateAllNodes(workflow, result, profile) {
for (const node of workflow.nodes) {
if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type))
continue;
try {
if (node.name && node.name.length > 255) {
result.warnings.push({
type: 'warning',
nodeId: node.id,
nodeName: node.name,
message: `Node name is very long (${node.name.length} characters). Consider using a shorter name for better readability.`
});
}
if (!Array.isArray(node.position) || node.position.length !== 2) {
result.errors.push({
type: 'error',
nodeId: node.id,
nodeName: node.name,
message: 'Node position must be an array with exactly 2 numbers [x, y]'
});
}
else {
const [x, y] = node.position;
if (typeof x !== 'number' || typeof y !== 'number' ||
!isFinite(x) || !isFinite(y)) {
result.errors.push({
type: 'error',
nodeId: node.id,
nodeName: node.name,
message: 'Node position values must be finite numbers'
});
}
}
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(node.type);
let nodeInfo = this.nodeRepository.getNode(normalizedType);
if (!nodeInfo && tool_variant_generator_1.ToolVariantGenerator.isToolVariantNodeType(normalizedType)) {
const baseNodeType = tool_variant_generator_1.ToolVariantGenerator.getBaseNodeType(normalizedType);
if (baseNodeType) {
const baseNodeInfo = this.nodeRepository.getNode(baseNodeType);
if (baseNodeInfo) {
result.suggestions.push(`Node type "${node.type}" is inferred as a dynamic AI Tool variant of "${baseNodeType}". ` +
`This Tool variant is created by n8n at runtime when connecting "${baseNodeInfo.displayName}" to an AI Agent.`);
nodeInfo = {
...baseNodeInfo,
nodeType: normalizedType,
displayName: `${baseNodeInfo.displayName} Tool`,
isToolVariant: true,
toolVariantOf: baseNodeType,
isInferred: true
};
}
}
}
if (!nodeInfo) {
const suggestions = await this.similarityService.findSimilarNodes(node.type, 3);
const isCommunityType = !this.isCorePackageType(normalizedType) && normalizedType.includes('.');
let message = `Unknown node type: "${node.type}".`;
if (isCommunityType) {
message += ' This looks like a community node that is not in this server\'s node database — the workflow can still run on an n8n instance where the package is installed.';
}
if (suggestions.length > 0) {
message += '\n\nDid you mean one of these?';
for (const suggestion of suggestions) {
const confidence = Math.round(suggestion.confidence * 100);
message += `\n• ${suggestion.nodeType} (${confidence}% match)`;
if (suggestion.displayName) {
message += ` - ${suggestion.displayName}`;
}
message += `\n → ${suggestion.reason}`;
if (suggestion.confidence >= 0.9) {
message += ' (can be auto-fixed)';
}
}
}
else if (!isCommunityType) {
message += ' No similar nodes found. Node types must include the package prefix (e.g., "n8n-nodes-base.webhook").';
}
const issue = {
type: isCommunityType ? 'warning' : 'error',
nodeId: node.id,
nodeName: node.name,
message
};
if (suggestions.length > 0) {
issue.suggestions = suggestions.map(s => ({
nodeType: s.nodeType,
confidence: s.confidence,
reason: s.reason
}));
}
if (isCommunityType) {
result.warnings.push(issue);
}
else {
result.errors.push(issue);
}
continue;
}
if (nodeInfo.isVersioned) {
const maxVersion = (0, typeversion_1.parseTypeVersion)(nodeInfo.version);
if (maxVersion === null && nodeInfo.version != null) {
result.warnings.push({
type: 'warning',
nodeId: node.id,
nodeName: node.name,
message: `Cannot validate typeVersion for ${node.type}: stored version "${nodeInfo.version}" is not a valid typeVersion. Min/max checks were skipped — re-sync this node or verify typeVersion against the node descriptor manually.`
});
}
if (node.typeVersion === undefined || node.typeVersion === null) {
result.errors.push({
type: 'error',
nodeId: node.id,
nodeName: node.name,
message: `Missing required property 'typeVersion'. Add typeVersion: ${maxVersion ?? 1}`
});
}
else if (typeof node.typeVersion !== 'number' || !Number.isFinite(node.typeVersion) || node.typeVersion < 0) {
result.errors.push({
type: 'error',
nodeId: node.id,
nodeName: node.name,
message: `Invalid typeVersion: ${node.typeVersion}. Must be a finite non-negative number`
});
}
else if (maxVersion !== null && node.typeVersion < maxVersion) {
if (this.isAdvisoryProfile(profile)) {
result.suggestions.push(`Outdated typeVersion for node "${node.name}": ${node.typeVersion}. Latest is ${maxVersion}.`);
}
}
else if (maxVersion !== null && node.typeVersion > maxVersion) {
if (this.isCorePackageType(normalizedType)) {
result.errors.push({
type: 'error',
nodeId: node.id,
nodeName: node.name,
message: `typeVersion ${node.typeVersion} exceeds maximum supported version ${maxVersion}`
});
}
else {
result.warnings.push({
type: 'warning',
nodeId: node.id,
nodeName: node.name,
message: `typeVersion ${node.typeVersion} exceeds maximum supported version ${maxVersion} known to this server. The community package may be newer than this server's data — verify the version exists in your installed package.`
});
}
}
}
if (normalizedType.startsWith('nodes-langchain.')) {
continue;
}
if (nodeInfo.isInferred) {
continue;
}
const paramsWithVersion = {
'@version': node.typeVersion || 1,
...node.parameters
};
const nodeValidation = this.nodeValidator.validateWithMode(node.type, paramsWithVersion, nodeInfo.properties || [], 'operation', profile);
nodeValidation.errors.forEach((error) => {
result.errors.push({
type: 'error',
nodeId: node.id,
nodeName: node.name,
message: typeof error === 'string' ? error : error.message || String(error)
});
});
nodeValidation.warnings.forEach((warning) => {
result.warnings.push({
type: 'warning',
nodeId: node.id,
nodeName: node.name,
message: typeof warning === 'string' ? warning : warning.message || String(warning)
});
});
if (node.type === 'n8n-nodes-base.if' || node.type === 'n8n-nodes-base.switch') {
const conditionErrors = (0, n8n_validation_1.validateConditionNodeStructure)(node);
for (const err of conditionErrors) {
result.errors.push({
type: 'error',
nodeId: node.id,
nodeName: node.name,
message: err
});
}
}
}
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'}`
});
}
}
}
validateConnections(workflow, result, profile = 'runtime') {
const nodeMap = new Map(workflow.nodes.map(n => [n.name, n]));
const nodeIdMap = new Map(workflow.nodes.map(n => [n.id, n]));
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
const sourceNode = nodeMap.get(sourceName);
if (!sourceNode) {
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;
}
for (const [outputKey, outputConnections] of Object.entries(outputs)) {
if (!exports.VALID_CONNECTION_TYPES.has(outputKey)) {
let suggestion = '';
if (/^\d+$/.test(outputKey)) {
suggestion = ` If you meant to use output index ${outputKey}, use main[${outputKey}] instead.`;
}
result.errors.push({
type: 'error',
nodeName: sourceName,
message: `Unknown connection output key "${outputKey}" on node "${sourceName}". Valid keys are: ${[...exports.VALID_CONNECTION_TYPES].join(', ')}.${suggestion}`,
code: 'UNKNOWN_CONNECTION_KEY'
});
result.statistics.invalidConnections++;
continue;
}
if (!outputConnections || !Array.isArray(outputConnections))
continue;
if (outputKey === 'ai_tool') {
this.validateAIToolSource(sourceNode, result);
}
if (outputKey === 'main') {
this.validateNotAISubNode(sourceNode, result);
}
this.validateConnectionOutputs(sourceName, outputConnections, nodeMap, nodeIdMap, result, outputKey, profile);
}
}
if (profile !== 'minimal') {
this.validateTriggerReachability(workflow, result);
}
else {
this.flagOrphanedNodes(workflow, result);
}
if (profile !== 'minimal' && this.hasCycle(workflow)) {
result.warnings.push({
type: 'warning',
message: 'Workflow contains a cycle with no recognized exit. Verify the loop can terminate (e.g. via a conditional branch, an error output, or a node that can return zero items).'
});
}
}
validateConnectionOutputs(sourceName, outputs, nodeMap, nodeIdMap, result, outputType, profile = 'runtime') {
const sourceNode = nodeMap.get(sourceName);
if (outputType === 'main' && sourceNode) {
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result, profile);
this.validateOutputIndexBounds(sourceNode, outputs, result);
this.validateConditionalBranchUsage(sourceNode, outputs, result);
}
outputs.forEach((outputConnections, outputIndex) => {
if (!outputConnections)
return;
outputConnections.forEach(connection => {
if (connection.index < 0) {
result.errors.push({
type: 'error',
message: `Invalid connection index ${connection.index} from "${sourceName}". Connection indices must be non-negative.`
});
result.statistics.invalidConnections++;
return;
}
if (connection.type && !exports.VALID_CONNECTION_TYPES.has(connection.type)) {
let suggestion = '';
if (/^\d+$/.test(connection.type)) {
suggestion = ` Numeric types are not valid - use "main", "error", or an AI connection type.`;
}
result.errors.push({
type: 'error',
nodeName: sourceName,
message: `Invalid connection type "${connection.type}" in connection from "${sourceName}" to "${connection.node}". Expected "main", "error", or an AI connection type (ai_tool, ai_languageModel, etc.).${suggestion}`,
code: 'INVALID_CONNECTION_TYPE'
});
result.statistics.invalidConnections++;
return;
}
const isSplitInBatches = sourceNode && (sourceNode.type === 'n8n-nodes-base.splitInBatches' ||
sourceNode.type === 'nodes-base.splitInBatches');
if (isSplitInBatches) {
this.validateSplitInBatchesConnection(sourceNode, outputIndex, connection, nodeMap, result);
}
if (connection.node === sourceName) {
if (sourceNode && !isSplitInBatches) {
result.warnings.push({
type: 'warning',
message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.`
});
}
}
const targetNode = nodeMap.get(connection.node);
if (!targetNode) {
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++;
if (outputType === 'ai_tool') {
this.validateAIToolConnection(sourceName, targetNode, result);
}
if (outputType === 'main') {
this.validateInputIndexBounds(sourceName, targetNode, connection, result);
}
}
});
});
}
validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result, profile = 'runtime') {
const hasErrorOutputSetting = sourceNode.onError === 'continueErrorOutput';
const errorOutputIndex = this.getMainOutputCount(sourceNode);
if (errorOutputIndex !== null) {
const hasErrorConnections = outputs.length > errorOutputIndex &&
outputs[errorOutputIndex] &&
outputs[errorOutputIndex].length > 0;
if (hasErrorOutputSetting && !hasErrorConnections && profile !== 'minimal') {
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node has onError: 'continueErrorOutput' but the error output (main[${errorOutputIndex}]) is not connected — failed items are silently dropped. Connect an error handler to main[${errorOutputIndex}] or change onError to 'continueRegularOutput' or 'stopWorkflow'.`
});
}
if (!hasErrorOutputSetting && hasErrorConnections) {
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node has error output connections in main[${errorOutputIndex}] but missing onError: 'continueErrorOutput'. Add this property to properly handle errors.`
});
}
}
if (outputs.length >= 1 && outputs[0] && outputs[0].length > 1) {
const potentialErrorHandlers = outputs[0].filter(conn => {
const targetNode = nodeMap.get(conn.node);
if (!targetNode)
return false;
const nodeName = targetNode.name.toLowerCase();
const nodeType = targetNode.type.toLowerCase();
return nodeName.includes('error') ||
nodeName.includes('fail') ||
nodeName.includes('catch') ||
nodeName.includes('exception') ||
nodeType.includes('respondtowebhook') ||
nodeType.includes('emailsend');
});
if (potentialErrorHandlers.length > 0) {
const errorHandlerNames = potentialErrorHandlers.map(conn => `"${conn.node}"`).join(', ');
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Incorrect error output configuration. Nodes ${errorHandlerNames} appear to be error handlers but are in main[0] (success output) along with other nodes.\n\n` +
`INCORRECT (current):\n` +
`"${sourceName}": {\n` +
` "main": [\n` +
` [ // main[0] has multiple nodes mixed together\n` +
outputs[0].map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' +
` ]\n` +
` ]\n` +
`}\n\n` +
`CORRECT (should be):\n` +
`"${sourceName}": {\n` +
` "main": [\n` +
` [ // main[0] = success output\n` +
outputs[0].filter(conn => !potentialErrorHandlers.includes(conn)).map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' +
` ],\n` +
` [ // main[1] = error output\n` +
potentialErrorHandlers.map(conn => ` {"node": "${conn.node}", "type": "${conn.type}", "index": ${conn.index}}`).join(',\n') + '\n' +
` ]\n` +
` ]\n` +
`}\n\n` +
`Also add: "onError": "continueErrorOutput" to the "${sourceName}" node.`
});
}
}
}
validateAIToolConnection(sourceName, targetNode, result) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
let targetNodeInfo = this.nodeRepository.getNode(normalizedType);
if (!targetNodeInfo && normalizedType !== targetNode.type) {
targetNodeInfo = this.nodeRepository.getNode(targetNode.type);
}
if (targetNodeInfo && !targetNodeInfo.isAITool && targetNodeInfo.package !== 'n8n-nodes-base') {
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.`
});
}
}
validateAIToolSource(sourceNode, result) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
if ((0, ai_tool_validators_1.isAIToolSubNode)(normalizedType)) {
return;
}
const nodeInfo = this.nodeRepository.getNode(normalizedType);
if (tool_variant_generator_1.ToolVariantGenerator.isToolVariantNodeType(normalizedType)) {
if (nodeInfo?.isToolVariant) {
return;
}
}
if (!nodeInfo) {
return;
}
if (nodeInfo.hasToolVariant) {
const toolVariantType = tool_variant_generator_1.ToolVariantGenerator.getToolVariantNodeType(normalizedType);
const workflowToolVariantType = node_type_normalizer_1.NodeTypeNormalizer.toWorkflowFormat(toolVariantType);
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node "${sourceNode.name}" uses "${sourceNode.type}" which cannot output ai_tool connections. ` +
`Use the Tool variant "${workflowToolVariantType}" instead for AI Agent integration.`,
code: 'WRONG_NODE_TYPE_FOR_AI_TOOL',
fix: {
type: 'tool-variant-correction',
currentType: sourceNode.type,
suggestedType: workflowToolVariantType,
description: `Change node type from "${sourceNode.type}" to "${workflowToolVariantType}"`
}
});
return;
}
if (nodeInfo.isAITool) {
return;
}
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node "${sourceNode.name}" of type "${sourceNode.type}" cannot output ai_tool connections. ` +
`Only AI tool nodes (e.g., Calculator, HTTP Request Tool) or Tool variants (e.g., *Tool suffix nodes) can be connected to AI Agents as tools.`,
code: 'INVALID_AI_TOOL_SOURCE'
});
}
getNodeOutputTypes(nodeType) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
const nodeInfo = this.nodeRepository.getNode(normalizedType);
if (!nodeInfo || !nodeInfo.outputs)
return null;
const outputs = nodeInfo.outputs;
if (!Array.isArray(outputs))
return null;
for (const output of outputs) {
if (typeof output === 'string' && output.startsWith('={{')) {
return null;
}
}
return outputs;
}
validateNotAISubNode(sourceNode, result) {
const outputTypes = this.getNodeOutputTypes(sourceNode.type);
if (!outputTypes)
return;
const hasMainOutput = outputTypes.some(t => t === 'main');
if (hasMainOutput)
return;
const aiTypes = outputTypes.filter(t => t !== 'main');
const expectedType = aiTypes[0] || 'ai_languageModel';
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Node "${sourceNode.name}" (${sourceNode.type}) is an AI sub-node that outputs "${expectedType}" connections. ` +
`It cannot be used with "main" connections. Connect it to an AI Agent or Chain via "${expectedType}" instead.`,
code: 'AI_SUBNODE_MAIN_CONNECTION'
});
}
getShortNodeType(sourceNode) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
return normalizedType.replace(/^(n8n-)?nodes-base\./, '');
}
isCorePackageType(normalizedType) {
return normalizedType.startsWith('nodes-base.') || normalizedType.startsWith('nodes-langchain.');
}
isAdvisoryProfile(profile) {
return profile === 'ai-friendly' || profile === 'strict';
}
getMainOutputCount(sourceNode) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
const nodeInfo = this.nodeRepository.getNode(normalizedType);
if (!nodeInfo || !nodeInfo.outputs)
return null;
if (!Array.isArray(nodeInfo.outputs))
return null;
const mainOutputCount = nodeInfo.outputs.filter((o) => typeof o === 'string' ? o === 'main' : (o.type === 'main' || !o.type)).length;
if (mainOutputCount === 0)
return null;
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
if (conditionalInfo) {
return conditionalInfo.expectedOutputs;
}
if (this.getShortNodeType(sourceNode) === 'switch') {
return null;
}
return mainOutputCount;
}
getConditionalOutputInfo(sourceNode) {
const shortType = this.getShortNodeType(sourceNode);
if (shortType === 'if' || shortType === 'filter') {
return { shortType, expectedOutputs: 2 };
}
if (shortType === 'switch') {
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
if (Array.isArray(rules)) {
return { shortType, expectedOutputs: rules.length + 1 };
}
return null;
}
return null;
}
validateOutputIndexBounds(sourceNode, outputs, result) {
const naturalOutputCount = this.getMainOutputCount(sourceNode);
if (naturalOutputCount === null)
return;
let mainOutputCount = naturalOutputCount;
if (sourceNode.onError === 'continueErrorOutput') {
mainOutputCount += 1;
}
const maxOutputIndex = outputs.length - 1;
if (maxOutputIndex >= mainOutputCount) {
for (let i = mainOutputCount; i < outputs.length; i++) {
if (outputs[i] && outputs[i].length > 0) {
result.errors.push({
type: 'error',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message: `Output index ${i} on node "${sourceNode.name}" exceeds its output count (${mainOutputCount}). ` +
`This node has ${mainOutputCount} main output(s) (indices 0-${mainOutputCount - 1}).`,
code: 'OUTPUT_INDEX_OUT_OF_BOUNDS'
});
result.statistics.invalidConnections++;
}
}
}
}
validateConditionalBranchUsage(sourceNode, outputs, result) {
const conditionalInfo = this.getConditionalOutputInfo(sourceNode);
if (!conditionalInfo || conditionalInfo.expectedOutputs < 2)
return;
const { shortType, expectedOutputs } = conditionalInfo;
const main0Count = outputs[0]?.length || 0;
if (main0Count < 2)
return;
const hasHigherIndexConnections = outputs.slice(1).some(conns => conns && conns.length > 0);
if (hasHigherIndexConnections)
return;
let message;
if (shortType === 'if' || shortType === 'filter') {
const isFilter = shortType === 'filter';
const displayName = isFilter ? 'Filter' : 'IF';
const trueLabel = isFilter ? 'matched' : 'true';
const falseLabel = isFilter ? 'unmatched' : 'false';
message = `${displayName} node "${sourceNode.name}" has ${main0Count} connections on the "${trueLabel}" branch (main[0]) ` +
`but no connections on the "${falseLabel}" branch (main[1]). ` +
`All ${main0Count} target nodes execute together on the "${trueLabel}" branch, ` +
`while the "${falseLabel}" branch has no effect. ` +
`Split connections: main[0] for ${trueLabel}, main[1] for ${falseLabel}.`;
}
else {
message = `Switch node "${sourceNode.name}" has ${main0Count} connections on output 0 ` +
`but no connections on any other outputs (1-${expectedOutputs - 1}). ` +
`All ${main0Count} target nodes execute together on output 0, ` +
`while other switch branches have no effect. ` +
`Distribute connections across outputs to match switch rules.`;
}
result.warnings.push({
type: 'warning',
nodeId: sourceNode.id,
nodeName: sourceNode.name,
message,
code: 'CONDITIONAL_BRANCH_FANOUT'
});
}
validateInputIndexBounds(sourceName, targetNode, connection, result) {
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
const nodeInfo = this.nodeRepository.getNode(normalizedType);
if (!nodeInfo)
return;
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, '');
if (nodeInfo.isTrigger || (0, node_type_utils_1.isTriggerNode)(targetNode.type)) {
if (connection.index >= 0) {
result.errors.push({
type: 'error',
nodeName: targetNode.name,
message: `Input index ${connection.index} on node "${targetNode.name}" exceeds its input count (0). ` +
`Connection from "${sourceName}" targets input ${connection.index}, but trigger nodes have no main inputs.`,
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
});
result.statistics.invalidConnections++;
}
return;
}
if (shortType === 'merge' || shortType === 'compareDatasets') {
const rawInputs = targetNode.parameters?.numberInputs;
if (rawInputs != null && rawInputs !== '') {
if (typeof rawInputs === 'string' && rawInputs.trim().startsWith('=')) {
return;
}
const parsed = Number(rawInputs);
if (!Number.isFinite(parsed) || parsed <= 0) {
return;
}
if (connection.index >= parsed) {
result.errors.push({
type: 'error',
nodeName: targetNode.name,
message: `Input index ${connection.index} on node "${targetNode.name}" exceeds its input count (${parsed}). ` +
`Connection from "${sourceName}" targets input ${connection.index}, but this node has ${parsed} main input(s) (indices 0-${parsed - 1}).`,
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
});
result.statistics.invalidConnections++;
}
return;
}
const defaultInputs = 2;
if (connection.index >= defaultInputs) {
result.warnings.push({
type: 'warning',
nodeName: targetNode.name,
message: `Input index ${connection.index} on node "${targetNode.name}" exceeds the default input count (${defaultInputs}) — ` +
`numberInputs is not set, so n8n will ignore this connection and drop items from "${sourceName}". ` +
`Set numberInputs to at least ${connection.index + 1} to use this input.`,
code: 'MERGE_EXTRA_INPUTS_IGNORED'
});
}
return;
}
}
flagOrphanedNodes(workflow, result) {
const connectedNodes = new Set();
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
connectedNodes.add(sourceName);
for (const outputConns of Object.values(outputs)) {
if (!Array.isArray(outputConns))
continue;
for (const conns of outputConns) {
if (!conns)
continue;
for (const conn of conns) {
if (conn)
connectedNodes.add(conn.node);
}
}
}
}
for (const node of workflow.nodes) {
if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type))
continue;
if ((0, node_type_utils_1.isTriggerNode)(node.type))
continue;
if (!connectedNodes.has(node.name)) {
result.warnings.push({
type: 'warning',
nodeId: node.id,
nodeName: node.name,
message: 'Node is not connected to any other nodes'
});
}
}
}
validateTriggerReachability(workflow, result) {
const adjacency = new Map();
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
if (!adjacency.has(sourceName))
adjacency.set(sourceName, new Set());
for (const [connectionType, outputConns] of Object.entries(outputs)) {
if (Array.isArray(outputConns)) {
for (const conns of outputConns) {
if (!conns)
continue;
for (const conn of conns) {
if (conn) {
adjacency.get(sourceName).add(conn.node);
if (!adjacency.has(conn.node))
adjacency.set(conn.node, new Set());
if (connectionType.startsWith('ai_')) {
adjacency.get(conn.node).add(sourceName);
}
}
}
}
}
}
}
const triggerNodes = [];
for (const node of workflow.nodes) {
if ((0, node_type_utils_1.isTriggerNode)(node.type) && !node.disabled) {
triggerNodes.push(node.name);
}
}
if (triggerNodes.length === 0) {
this.flagOrphanedNodes(workflow, result);
return;
}
const reachable = new Set();
const queue = [...triggerNodes];
for (const t of triggerNodes)
reachable.add(t);
while (queue.length > 0) {
const current = queue.shift();
const neighbors = adjacency.get(current);
if (neighbors) {
for (const neighbor of neighbors) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
}
for (const node of workflow.nodes) {
if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type))
continue;
if ((0, node_type_utils_1.isTriggerNode)(node.type))
continue;
if (!reachable.has(node.name)) {
result.warnings.push({
type: 'warning',
nodeId: node.id,
nodeName: node.name,
message: 'Node is not reachable from any trigger node'
});
}
}
}
hasCycle(workflow) {
const visited = new Set();
const recursionStack = new Set();
const path = [];
const nodeTypeMap = new Map();
const nodeMap = new Map();
workflow.nodes.forEach(node => {
if (!(0, node_classification_1.isNonExecutableNode)(node.type)) {
nodeTypeMap.set(node.name, node.type);
nodeMap.set(node.name, node);
}
});
const loopNodeTypes = [
'n8n-nodes-base.splitInBatches',
'nodes-base.splitInBatches',
'n8n-nodes-base.itemLists',
'nodes-base.itemLists',
'n8n-nodes-base.loop',