Compare commits

..

2 Commits

Author SHA1 Message Date
czlonkowski
22635b708b refactor: deduplicate tryParseJson — export from handlers-n8n-manager
tryParseJson was duplicated in handlers-workflow-diff.ts. Now imported
from handlers-n8n-manager.ts where it was already defined. Updated
test mock to use importOriginal so the real function is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:45:03 +01:00
czlonkowski
9133f5e483 fix: raise session timeout default and fix VS Code MCP compatibility (#626, #600, #611)
- #626: Raise SESSION_TIMEOUT_MINUTES default from 5 to 30 minutes.
  Complex editing sessions easily exceed 5 min between LLM calls.

- #600: Add z.preprocess(tryParseJson, ...) to operations parameter
  in n8n_update_partial_workflow. VS Code extension sends arrays as
  JSON strings.

- #611: Strip undefined values from tool args via JSON round-trip
  before Zod validation. VS Code sends explicit undefined which
  Zod's .optional() rejects.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:53:42 +01:00
9 changed files with 101 additions and 439 deletions

View File

@@ -7,18 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.41.4] - 2026-03-30
### Fixed
- **`validate_workflow` misses `conditions.options` check for If/Switch nodes** (Issue #675): Added version-conditional validation — If v2.2+ and Switch v3.2+ now require `conditions.options` metadata, If v2.0-2.1 validates operator structures, and v1.x is left unchecked. Previously only caught by `n8n_create_workflow` pre-flight but not by offline `validate_workflow`.
- **False positive "Set node has no fields configured" for Set v3+** (Issue #676): The `validateSet` checker now recognizes `config.assignments.assignments` (v3+ schema) in addition to `config.values` (v1/v2 schema). Updated suggestion text to match current UI terminology.
- **Expression validator does not detect unwrapped n8n expressions** (Issue #677): Added heuristic pre-pass that detects bare `$json`, `$node`, `$input`, `$execution`, `$workflow`, `$prevNode`, `$env`, `$now`, `$today`, `$itemIndex`, and `$runIndex` references missing `={{ }}` wrappers. Uses anchored patterns to avoid false positives. Emits warnings, not errors.
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
## [2.41.3] - 2026-03-27
### Fixed

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.41.4",
"version": "2.41.3",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -19,18 +19,6 @@ interface ExpressionContext {
}
export class ExpressionValidator {
// Bare n8n variable references missing {{ }} wrappers
private static readonly BARE_EXPRESSION_PATTERNS: Array<{ pattern: RegExp; name: string }> = [
{ pattern: /^\$json[.\[]/, name: '$json' },
{ pattern: /^\$node\[/, name: '$node' },
{ pattern: /^\$input\./, name: '$input' },
{ pattern: /^\$execution\./, name: '$execution' },
{ pattern: /^\$workflow\./, name: '$workflow' },
{ pattern: /^\$prevNode\./, name: '$prevNode' },
{ pattern: /^\$env\./, name: '$env' },
{ pattern: /^\$(now|today|itemIndex|runIndex)$/, name: 'built-in variable' },
];
// Common n8n expression patterns
private static readonly EXPRESSION_PATTERN = /\{\{([\s\S]+?)\}\}/g;
private static readonly VARIABLE_PATTERNS = {
@@ -300,32 +288,6 @@ export class ExpressionValidator {
return combinedResult;
}
/**
* Detect bare n8n variable references missing {{ }} wrappers.
* Emits warnings since the value is technically valid as a literal string.
*/
private static checkBareExpression(
value: string,
path: string,
result: ExpressionValidationResult
): void {
if (value.includes('{{') || value.startsWith('=')) {
return;
}
const trimmed = value.trim();
for (const { pattern, name } of this.BARE_EXPRESSION_PATTERNS) {
if (pattern.test(trimmed)) {
result.warnings.push(
(path ? `${path}: ` : '') +
`Possible unwrapped expression: "${trimmed}" looks like an n8n ${name} reference. ` +
`Use "={{ ${trimmed} }}" to evaluate it as an expression.`
);
return;
}
}
}
/**
* Recursively validate expressions in parameters
*/
@@ -345,9 +307,6 @@ export class ExpressionValidator {
}
if (typeof obj === 'string') {
// Detect bare expressions missing {{ }} wrappers
this.checkBareExpression(obj, path, result);
if (obj.includes('{{')) {
const validation = this.validateExpression(obj, context);

View File

@@ -344,10 +344,10 @@ export function validateWorkflowStructure(workflow: Partial<Workflow>): string[]
});
}
// Validate If/Switch condition structures (version-conditional)
// Validate filter-based nodes (IF v2.2+, Switch v3.2+) have complete metadata
if (workflow.nodes) {
workflow.nodes.forEach((node, index) => {
const filterErrors = validateConditionNodeStructure(node);
const filterErrors = validateFilterBasedNodeMetadata(node);
if (filterErrors.length > 0) {
errors.push(...filterErrors.map(err => `Node "${node.name}" (index ${index}): ${err}`));
}
@@ -488,79 +488,104 @@ export function hasWebhookTrigger(workflow: Workflow): boolean {
}
/**
* Validate If/Switch node conditions structure for ANY version.
* Version-conditional: validates the correct structure per version.
* Validate filter-based node metadata (IF v2.2+, Switch v3.2+)
* Returns array of error messages
*/
export function validateConditionNodeStructure(node: WorkflowNode): string[] {
const errors: string[] = [];
const typeVersion = node.typeVersion || 1;
if (node.type === 'n8n-nodes-base.if') {
if (typeVersion >= 2.2) {
errors.push(...validateFilterOptionsRequired(node.parameters?.conditions, 'conditions'));
errors.push(...validateFilterConditionOperators(node.parameters?.conditions, 'conditions'));
} else if (typeVersion >= 2) {
// v2 has conditions but no options requirement; just validate operators
errors.push(...validateFilterConditionOperators(node.parameters?.conditions as any, 'conditions'));
}
} else if (node.type === 'n8n-nodes-base.switch') {
if (typeVersion >= 3.2) {
const rules = node.parameters?.rules as any;
if (rules?.rules && Array.isArray(rules.rules)) {
rules.rules.forEach((rule: any, i: number) => {
errors.push(...validateFilterOptionsRequired(rule.conditions, `rules.rules[${i}].conditions`));
errors.push(...validateFilterConditionOperators(rule.conditions, `rules.rules[${i}].conditions`));
});
}
}
}
return errors;
}
function validateFilterOptionsRequired(conditions: any, path: string): string[] {
const errors: string[] = [];
if (!conditions || typeof conditions !== 'object') return errors;
if (!conditions.options) {
errors.push(
`Missing required "${path}.options". ` +
'Filter-based nodes require: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
);
} else {
const requiredFields: [string, string][] = [
['version', '2'],
['leftValue', '""'],
['caseSensitive', 'true'],
['typeValidation', '"strict"'],
];
for (const [field, display] of requiredFields) {
if (!(field in conditions.options)) {
errors.push(
`Missing required field "${path}.options.${field}". Expected value: ${display}`
);
}
}
}
return errors;
}
function validateFilterConditionOperators(conditions: any, path: string): string[] {
const errors: string[] = [];
if (!conditions?.conditions || !Array.isArray(conditions.conditions)) return errors;
conditions.conditions.forEach((condition: any, i: number) => {
errors.push(...validateOperatorStructure(
condition.operator,
`${path}.conditions[${i}].operator`
));
});
return errors;
}
/** @deprecated Use validateConditionNodeStructure instead */
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
return validateConditionNodeStructure(node);
const errors: string[] = [];
// Check if node is filter-based
const isIFNode = node.type === 'n8n-nodes-base.if' && node.typeVersion >= 2.2;
const isSwitchNode = node.type === 'n8n-nodes-base.switch' && node.typeVersion >= 3.2;
if (!isIFNode && !isSwitchNode) {
return errors; // Not a filter-based node
}
// Validate IF node
if (isIFNode) {
const conditions = (node.parameters.conditions as any);
// Check conditions.options exists
if (!conditions?.options) {
errors.push(
'Missing required "conditions.options". ' +
'IF v2.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
);
} else {
// Validate required fields
const requiredFields = {
version: 2,
leftValue: '',
caseSensitive: 'boolean',
typeValidation: 'strict'
};
for (const [field, expectedValue] of Object.entries(requiredFields)) {
if (!(field in conditions.options)) {
errors.push(
`Missing required field "conditions.options.${field}". ` +
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
);
}
}
}
// Validate operators in conditions
if (conditions?.conditions && Array.isArray(conditions.conditions)) {
conditions.conditions.forEach((condition: any, i: number) => {
const operatorErrors = validateOperatorStructure(condition.operator, `conditions.conditions[${i}].operator`);
errors.push(...operatorErrors);
});
}
}
// Validate Switch node
if (isSwitchNode) {
const rules = (node.parameters.rules as any);
if (rules?.rules && Array.isArray(rules.rules)) {
rules.rules.forEach((rule: any, ruleIndex: number) => {
// Check rule.conditions.options
if (!rule.conditions?.options) {
errors.push(
`Missing required "rules.rules[${ruleIndex}].conditions.options". ` +
'Switch v3.2+ requires: {version: 2, leftValue: "", caseSensitive: true, typeValidation: "strict"}'
);
} else {
// Validate required fields
const requiredFields = {
version: 2,
leftValue: '',
caseSensitive: 'boolean',
typeValidation: 'strict'
};
for (const [field, expectedValue] of Object.entries(requiredFields)) {
if (!(field in rule.conditions.options)) {
errors.push(
`Missing required field "rules.rules[${ruleIndex}].conditions.options.${field}". ` +
`Expected value: ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}`
);
}
}
}
// Validate operators in rule conditions
if (rule.conditions?.conditions && Array.isArray(rule.conditions.conditions)) {
rule.conditions.conditions.forEach((condition: any, condIndex: number) => {
const operatorErrors = validateOperatorStructure(
condition.operator,
`rules.rules[${ruleIndex}].conditions.conditions[${condIndex}].operator`
);
errors.push(...operatorErrors);
});
}
});
}
}
return errors;
}
/**

View File

@@ -1713,16 +1713,12 @@ export class NodeSpecificValidators {
// Validate mode-specific requirements
if (config.mode === 'manual') {
// In manual mode, at least one field should be defined
const hasFieldsViaValues = config.values && Object.keys(config.values).length > 0;
const hasFieldsViaAssignments = config.assignments?.assignments
&& Array.isArray(config.assignments.assignments)
&& config.assignments.assignments.length > 0;
const hasFields = hasFieldsViaValues || hasFieldsViaAssignments;
const hasFields = config.values && Object.keys(config.values).length > 0;
if (!hasFields && !config.jsonOutput) {
warnings.push({
type: 'missing_common',
message: 'Set node has no fields configured - will output empty items',
suggestion: 'Add field assignments or use JSON mode'
suggestion: 'Add fields in the Values section or use JSON mode'
});
}
}

View File

@@ -15,7 +15,6 @@ import { validateAISpecificNodes, hasAINodes, AI_CONNECTION_TYPES } from './ai-n
import { isAIToolSubNode } from './ai-tool-validators';
import { isTriggerNode } from '../utils/node-type-utils';
import { isNonExecutableNode } from '../utils/node-classification';
import { validateConditionNodeStructure } from './n8n-validation';
import { ToolVariantGenerator } from './tool-variant-generator';
const logger = new Logger({ prefix: '[WorkflowValidator]' });
@@ -580,19 +579,6 @@ export class WorkflowValidator {
});
});
// Validate If/Switch conditions structure (version-conditional)
if (node.type === 'n8n-nodes-base.if' || node.type === 'n8n-nodes-base.switch') {
const conditionErrors = validateConditionNodeStructure(node as any);
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',

View File

@@ -105,78 +105,6 @@ describe('ExpressionValidator', () => {
});
});
describe('bare expression detection', () => {
it('should warn on bare $json.name', () => {
const params = { value: '$json.name' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $node["Webhook"].json', () => {
const params = { value: '$node["Webhook"].json' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $now', () => {
const params = { value: '$now' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $execution.id', () => {
const params = { value: '$execution.id' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $env.API_KEY', () => {
const params = { value: '$env.API_KEY' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should warn on bare $input.item.json.field', () => {
const params = { value: '$input.item.json.field' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
it('should NOT warn on properly wrapped ={{ $json.name }}', () => {
const params = { value: '={{ $json.name }}' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should NOT warn on properly wrapped {{ $json.name }}', () => {
const params = { value: '{{ $json.name }}' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should NOT warn when $json appears mid-string', () => {
const params = { value: 'The $json data is ready' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should NOT warn on plain text', () => {
const params = { value: 'Hello World' };
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(false);
});
it('should detect bare expression in nested structure', () => {
const params = {
assignments: {
assignments: [{ value: '$json.name' }]
}
};
const result = ExpressionValidator.validateNodeExpressions(params, defaultContext);
expect(result.warnings.some(w => w.includes('unwrapped expression'))).toBe(true);
});
});
describe('edge cases', () => {
it('should handle empty expressions', () => {
const result = ExpressionValidator.validateExpression('{{ }}', defaultContext);

View File

@@ -2384,73 +2384,6 @@ return [{"json": {"result": result}}]
});
});
describe('validateSet', () => {
it('should not warn when Set v3 has populated assignments', () => {
context.config = {
mode: 'manual',
assignments: {
assignments: [
{ id: '1', name: 'status', value: 'active', type: 'string' }
]
}
};
NodeSpecificValidators.validateSet(context);
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
expect(fieldWarnings).toHaveLength(0);
});
it('should not warn when Set v2 has populated values', () => {
context.config = {
mode: 'manual',
values: {
string: [{ name: 'field', value: 'val' }]
}
};
NodeSpecificValidators.validateSet(context);
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
expect(fieldWarnings).toHaveLength(0);
});
it('should warn when Set v3 has empty assignments array', () => {
context.config = {
mode: 'manual',
assignments: { assignments: [] }
};
NodeSpecificValidators.validateSet(context);
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
expect(fieldWarnings).toHaveLength(1);
});
it('should warn when Set manual mode has no values or assignments', () => {
context.config = {
mode: 'manual'
};
NodeSpecificValidators.validateSet(context);
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
expect(fieldWarnings).toHaveLength(1);
});
it('should not warn when Set manual mode has jsonOutput', () => {
context.config = {
mode: 'manual',
jsonOutput: '{"key":"value"}'
};
NodeSpecificValidators.validateSet(context);
const fieldWarnings = context.warnings.filter(w => w.message.includes('no fields configured'));
expect(fieldWarnings).toHaveLength(0);
});
});
describe('validateAIAgent', () => {
let context: NodeValidationContext;

View File

@@ -4,7 +4,6 @@ import { NodeRepository } from '@/database/node-repository';
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
import { ExpressionValidator } from '@/services/expression-validator';
import { createWorkflow } from '@tests/utils/builders/workflow.builder';
import { validateConditionNodeStructure } from '@/services/n8n-validation';
// Mock dependencies
vi.mock('@/database/node-repository');
@@ -744,156 +743,4 @@ describe('WorkflowValidator', () => {
expect(result.statistics.validConnections).toBe(3);
});
});
// ─── If/Switch conditions validation ──────────────────────────────
describe('If/Switch conditions validation (validateConditionNodeStructure)', () => {
it('If v2.3 missing conditions.options → error', () => {
const node = {
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.3,
position: [0, 0] as [number, number],
parameters: {
conditions: {
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
combinator: 'and'
}
}
};
const errors = validateConditionNodeStructure(node);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('options'))).toBe(true);
});
it('If v2.3 with complete options → no error', () => {
const node = {
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.3,
position: [0, 0] as [number, number],
parameters: {
conditions: {
options: { version: 2, leftValue: '', caseSensitive: true, typeValidation: 'strict' },
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
combinator: 'and'
}
}
};
const errors = validateConditionNodeStructure(node);
expect(errors).toHaveLength(0);
});
it('If v2.0 without options → no error', () => {
const node = {
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.0,
position: [0, 0] as [number, number],
parameters: {
conditions: {
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
combinator: 'and'
}
}
};
const errors = validateConditionNodeStructure(node);
expect(errors).toHaveLength(0);
});
it('If v2.0 with bad operator (missing type) → operator error', () => {
const node = {
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.0,
position: [0, 0] as [number, number],
parameters: {
conditions: {
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { operation: 'equals' } }],
combinator: 'and'
}
}
};
const errors = validateConditionNodeStructure(node);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('type'))).toBe(true);
});
it('If v1 with old format → no errors', () => {
const node = {
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {
conditions: { string: [{ value1: '={{ $json.x }}', value2: 'a', operation: 'equals' }] }
}
};
const errors = validateConditionNodeStructure(node);
expect(errors).toHaveLength(0);
});
it('Switch v3.2 missing rule options → error', () => {
const node = {
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.2,
position: [0, 0] as [number, number],
parameters: {
rules: {
rules: [{
conditions: {
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
combinator: 'and'
},
outputKey: 'Branch 1'
}]
}
}
};
const errors = validateConditionNodeStructure(node);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.includes('rules.rules[0].conditions.options'))).toBe(true);
});
it('Switch v3.2 with complete options → no error', () => {
const node = {
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.2,
position: [0, 0] as [number, number],
parameters: {
rules: {
rules: [{
conditions: {
options: { version: 2, leftValue: '', caseSensitive: true, typeValidation: 'strict' },
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
combinator: 'and'
},
outputKey: 'Branch 1'
}]
}
}
};
const errors = validateConditionNodeStructure(node);
expect(errors).toHaveLength(0);
});
it('If v2.2 with empty parameters (missing conditions) → no error (graceful)', () => {
const node = {
id: '1', name: 'IF', type: 'n8n-nodes-base.if', typeVersion: 2.2,
position: [0, 0] as [number, number],
parameters: {}
};
const errors = validateConditionNodeStructure(node);
// Empty parameters are allowed — draft/incomplete nodes are valid at this level
expect(errors).toHaveLength(0);
});
it('Switch v3.0 without options → no error', () => {
const node = {
id: '1', name: 'Switch', type: 'n8n-nodes-base.switch', typeVersion: 3.0,
position: [0, 0] as [number, number],
parameters: {
rules: {
rules: [{
conditions: {
conditions: [{ leftValue: '={{ $json.x }}', rightValue: 'a', operator: { type: 'string', operation: 'equals' } }],
combinator: 'and'
},
outputKey: 'Branch 1'
}]
}
}
};
const errors = validateConditionNodeStructure(node);
expect(errors).toHaveLength(0);
});
});
});