mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-20 01:13:07 +00:00
feat: implement comprehensive workflow validation (v2.5.0)
Major Features: - Add ExpressionValidator for n8n expression syntax validation - Add WorkflowValidator for complete workflow structure validation - Add three new MCP tools: validate_workflow, validate_workflow_connections, validate_workflow_expressions Validation Capabilities: - ✅ Detects workflow cycles (infinite loops) - ✅ Validates n8n expressions with syntax checking - ✅ Checks node references in expressions - ✅ Identifies orphaned nodes and missing connections - ✅ Supports multiple node type formats (n8n-nodes-base, @n8n/n8n-nodes-langchain) - ✅ Provides actionable error messages and suggestions Testing & Analysis: - Add test scripts for workflow validation - Add template validation testing - Add validation summary analysis tool - Fixed expression validation false positives - Handle node type normalization correctly Results from testing 50 real n8n templates: - 70.9% of errors are from informal sticky notes - Expression validation catches real syntax issues - Cycle detection prevents runtime infinite loops - Successfully validates both core and LangChain nodes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
299
src/services/expression-validator.ts
Normal file
299
src/services/expression-validator.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Expression Validator for n8n expressions
|
||||
* Validates expression syntax, variable references, and context availability
|
||||
*/
|
||||
|
||||
interface ExpressionValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
usedVariables: Set<string>;
|
||||
usedNodes: Set<string>;
|
||||
}
|
||||
|
||||
interface ExpressionContext {
|
||||
availableNodes: string[];
|
||||
currentNodeName?: string;
|
||||
isInLoop?: boolean;
|
||||
hasInputData?: boolean;
|
||||
}
|
||||
|
||||
export class ExpressionValidator {
|
||||
// Common n8n expression patterns
|
||||
private static readonly EXPRESSION_PATTERN = /\{\{(.+?)\}\}/g;
|
||||
private static readonly VARIABLE_PATTERNS = {
|
||||
json: /\$json(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g,
|
||||
node: /\$node\["([^"]+)"\]\.json/g,
|
||||
input: /\$input\.item(\.[a-zA-Z_][\w]*|\["[^"]+"\]|\['[^']+'\]|\[\d+\])*/g,
|
||||
items: /\$items\("([^"]+)"(?:,\s*(\d+))?\)/g,
|
||||
parameter: /\$parameter\["([^"]+)"\]/g,
|
||||
env: /\$env\.([a-zA-Z_][\w]*)/g,
|
||||
workflow: /\$workflow\.(id|name|active)/g,
|
||||
execution: /\$execution\.(id|mode|resumeUrl)/g,
|
||||
prevNode: /\$prevNode\.(name|outputIndex|runIndex)/g,
|
||||
itemIndex: /\$itemIndex/g,
|
||||
runIndex: /\$runIndex/g,
|
||||
now: /\$now/g,
|
||||
today: /\$today/g,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a single expression
|
||||
*/
|
||||
static validateExpression(
|
||||
expression: string,
|
||||
context: ExpressionContext
|
||||
): ExpressionValidationResult {
|
||||
const result: ExpressionValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
usedVariables: new Set(),
|
||||
usedNodes: new Set(),
|
||||
};
|
||||
|
||||
// Check for basic syntax errors
|
||||
const syntaxErrors = this.checkSyntaxErrors(expression);
|
||||
result.errors.push(...syntaxErrors);
|
||||
|
||||
// Extract all expressions
|
||||
const expressions = this.extractExpressions(expression);
|
||||
|
||||
for (const expr of expressions) {
|
||||
// Validate each expression
|
||||
this.validateSingleExpression(expr, context, result);
|
||||
}
|
||||
|
||||
// Check for undefined node references
|
||||
this.checkNodeReferences(result, context);
|
||||
|
||||
result.valid = result.errors.length === 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for basic syntax errors
|
||||
*/
|
||||
private static checkSyntaxErrors(expression: string): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for unmatched brackets
|
||||
const openBrackets = (expression.match(/\{\{/g) || []).length;
|
||||
const closeBrackets = (expression.match(/\}\}/g) || []).length;
|
||||
|
||||
if (openBrackets !== closeBrackets) {
|
||||
errors.push('Unmatched expression brackets {{ }}');
|
||||
}
|
||||
|
||||
// Check for nested expressions (not supported in n8n)
|
||||
if (expression.includes('{{') && expression.includes('{{', expression.indexOf('{{') + 2)) {
|
||||
const match = expression.match(/\{\{.*\{\{/);
|
||||
if (match) {
|
||||
errors.push('Nested expressions are not supported');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for empty expressions
|
||||
if (expression.includes('{{}}')) {
|
||||
errors.push('Empty expression found');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all expressions from a string
|
||||
*/
|
||||
private static extractExpressions(text: string): string[] {
|
||||
const expressions: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = this.EXPRESSION_PATTERN.exec(text)) !== null) {
|
||||
expressions.push(match[1].trim());
|
||||
}
|
||||
|
||||
return expressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single expression content
|
||||
*/
|
||||
private static validateSingleExpression(
|
||||
expr: string,
|
||||
context: ExpressionContext,
|
||||
result: ExpressionValidationResult
|
||||
): void {
|
||||
// Check for $json usage
|
||||
let match;
|
||||
while ((match = this.VARIABLE_PATTERNS.json.exec(expr)) !== null) {
|
||||
result.usedVariables.add('$json');
|
||||
|
||||
if (!context.hasInputData && !context.isInLoop) {
|
||||
result.warnings.push(
|
||||
'Using $json but node might not have input data'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for $node references
|
||||
while ((match = this.VARIABLE_PATTERNS.node.exec(expr)) !== null) {
|
||||
const nodeName = match[1];
|
||||
result.usedNodes.add(nodeName);
|
||||
result.usedVariables.add('$node');
|
||||
}
|
||||
|
||||
// Check for $input usage
|
||||
while ((match = this.VARIABLE_PATTERNS.input.exec(expr)) !== null) {
|
||||
result.usedVariables.add('$input');
|
||||
|
||||
if (!context.hasInputData) {
|
||||
result.errors.push(
|
||||
'$input is only available when the node has input data'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for $items usage
|
||||
while ((match = this.VARIABLE_PATTERNS.items.exec(expr)) !== null) {
|
||||
const nodeName = match[1];
|
||||
result.usedNodes.add(nodeName);
|
||||
result.usedVariables.add('$items');
|
||||
}
|
||||
|
||||
// Check for other variables
|
||||
for (const [varName, pattern] of Object.entries(this.VARIABLE_PATTERNS)) {
|
||||
if (['json', 'node', 'input', 'items'].includes(varName)) continue;
|
||||
|
||||
if (pattern.test(expr)) {
|
||||
result.usedVariables.add(`$${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common mistakes
|
||||
this.checkCommonMistakes(expr, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for common expression mistakes
|
||||
*/
|
||||
private static checkCommonMistakes(
|
||||
expr: string,
|
||||
result: ExpressionValidationResult
|
||||
): void {
|
||||
// Check for missing $ prefix - but exclude cases where $ is already present
|
||||
const missingPrefixPattern = /(?<!\$)\b(json|node|input|items|workflow|execution)\b(?!\s*:)/;
|
||||
if (expr.match(missingPrefixPattern)) {
|
||||
result.warnings.push(
|
||||
'Possible missing $ prefix for variable (e.g., use $json instead of json)'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for incorrect array access
|
||||
if (expr.includes('$json[') && !expr.match(/\$json\[\d+\]/)) {
|
||||
result.warnings.push(
|
||||
'Array access should use numeric index: $json[0] or property access: $json.property'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Python-style property access
|
||||
if (expr.match(/\$json\['[^']+'\]/)) {
|
||||
result.warnings.push(
|
||||
"Consider using dot notation: $json.property instead of $json['property']"
|
||||
);
|
||||
}
|
||||
|
||||
// Check for undefined/null access attempts
|
||||
if (expr.match(/\?\./)) {
|
||||
result.warnings.push(
|
||||
'Optional chaining (?.) is not supported in n8n expressions'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for template literals
|
||||
if (expr.includes('${')) {
|
||||
result.errors.push(
|
||||
'Template literals ${} are not supported. Use string concatenation instead'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that all referenced nodes exist
|
||||
*/
|
||||
private static checkNodeReferences(
|
||||
result: ExpressionValidationResult,
|
||||
context: ExpressionContext
|
||||
): void {
|
||||
for (const nodeName of result.usedNodes) {
|
||||
if (!context.availableNodes.includes(nodeName)) {
|
||||
result.errors.push(
|
||||
`Referenced node "${nodeName}" not found in workflow`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all expressions in a node's parameters
|
||||
*/
|
||||
static validateNodeExpressions(
|
||||
parameters: any,
|
||||
context: ExpressionContext
|
||||
): ExpressionValidationResult {
|
||||
const combinedResult: ExpressionValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
usedVariables: new Set(),
|
||||
usedNodes: new Set(),
|
||||
};
|
||||
|
||||
this.validateParametersRecursive(parameters, context, combinedResult);
|
||||
|
||||
combinedResult.valid = combinedResult.errors.length === 0;
|
||||
return combinedResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively validate expressions in parameters
|
||||
*/
|
||||
private static validateParametersRecursive(
|
||||
obj: any,
|
||||
context: ExpressionContext,
|
||||
result: ExpressionValidationResult,
|
||||
path: string = ''
|
||||
): void {
|
||||
if (typeof obj === 'string') {
|
||||
if (obj.includes('{{')) {
|
||||
const validation = this.validateExpression(obj, context);
|
||||
|
||||
// Add path context to errors
|
||||
validation.errors.forEach(error => {
|
||||
result.errors.push(`${path}: ${error}`);
|
||||
});
|
||||
|
||||
validation.warnings.forEach(warning => {
|
||||
result.warnings.push(`${path}: ${warning}`);
|
||||
});
|
||||
|
||||
// Merge used variables and nodes
|
||||
validation.usedVariables.forEach(v => result.usedVariables.add(v));
|
||||
validation.usedNodes.forEach(n => result.usedNodes.add(n));
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
obj.forEach((item, index) => {
|
||||
this.validateParametersRecursive(
|
||||
item,
|
||||
context,
|
||||
result,
|
||||
`${path}[${index}]`
|
||||
);
|
||||
});
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
Object.entries(obj).forEach(([key, value]) => {
|
||||
const newPath = path ? `${path}.${key}` : key;
|
||||
this.validateParametersRecursive(value, context, result, newPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user