Compare commits

...

3 Commits

Author SHA1 Message Date
Romuald Członkowski
20ebfbb0fc fix: validation bugs — If/Switch version check, Set false positive, bare expressions (#675, #676, #677) (#678)
- #675: Wire `validateConditionNodeStructure` into `WorkflowValidator` with
  version-conditional checks (If v2.2+ requires options, v2.0-2.1 validates
  operators, v1.x skipped; Switch v3.2+ requires options)
- #676: Fix `validateSet` to check `assignments.assignments` (v3+) alongside
  `config.values` (v1/v2), eliminating false positive warnings
- #677: Add anchored heuristic pre-pass in `ExpressionValidator` detecting bare
  `$json`, `$node`, `$input`, `$execution`, `$workflow`, `$prevNode`, `$env`,
  `$now`, `$today`, `$itemIndex`, `$runIndex` references missing `={{ }}`

25 new tests across 3 test files.

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

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:29:04 +02:00
Romuald Członkowski
6e4a9d520d fix: raise session timeout default, fix VS Code MCP compatibility (#674)
* 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>

* 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>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:56:30 +01:00
Romuald Członkowski
fb2d306dc3 fix: intercept stdout writes to prevent JSON-RPC corruption in stdio mode (#673)
* fix: intercept process.stdout.write to prevent JSON-RPC corruption in stdio mode (#628, #627, #567)

Console method suppression alone was insufficient — native modules, n8n packages,
and third-party code can call process.stdout.write() directly, leaking debug output
(refCount, dbPath, clientVersion, protocolVersion, etc.) into the MCP JSON-RPC stream.

Added stdout write interceptor that only allows JSON-RPC messages through (objects
containing "jsonrpc" field). All other writes are redirected to stderr. This fixes
the flood of "Unexpected token is not valid JSON" warnings on every new Claude
Desktop chat.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: add Docker Hub login to fix buildx bootstrap rate limiting

GitHub-hosted runners hit Docker Hub anonymous pull limits when
setup-buildx-action pulls moby/buildkit. Add docker/login-action
for Docker Hub before setup-buildx-action in all 4 workflows:
docker-build.yml, docker-build-fast.yml, docker-build-n8n.yml, release.yml.

Uses DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repository secrets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:26:43 +01:00
21 changed files with 539 additions and 122 deletions

View File

@@ -29,9 +29,15 @@ jobs:
with:
lfs: true
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3

View File

@@ -55,6 +55,12 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

View File

@@ -71,13 +71,19 @@ jobs:
"
echo "✅ Synced package.runtime.json to version $VERSION"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
@@ -85,7 +91,7 @@ jobs:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
@@ -173,13 +179,19 @@ jobs:
"
echo "✅ Synced package.runtime.json to version $VERSION"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3

View File

@@ -441,12 +441,18 @@ jobs:
"
echo "✅ Synced package.runtime.json to version $VERSION"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:

View File

@@ -7,6 +7,38 @@ 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
- **Session timeout default too low** (Issue #626): Raised `SESSION_TIMEOUT_MINUTES` default from 5 to 30 minutes. The 5-minute default caused sessions to expire mid-operation during complex multi-step workflows (validate → get structure → patch → validate), forcing users to retry. Configurable via environment variable.
- **Operations array received as string from VS Code** (Issue #600): Added `z.preprocess` JSON string parsing to the `operations` parameter in `n8n_update_partial_workflow`. The VS Code MCP extension serializes arrays as JSON strings — the Zod schema now transparently parses them before validation.
- **`undefined` values rejected in MCP tool calls from VS Code** (Issue #611): Strip explicit `undefined` values from tool arguments before Zod validation. VS Code sends `undefined` as a value which Zod's `.optional()` rejects (it expects the field to be missing, not present-but-undefined).
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
## [2.41.2] - 2026-03-27
### Fixed
- **MCP initialization floods Claude Desktop with JSON parse errors** (Issues #628, #627, #567): Intercept `process.stdout.write` in stdio mode to redirect non-JSON-RPC output to stderr. Console method suppression alone was insufficient — native modules (better-sqlite3), n8n packages, and third-party code can call `process.stdout.write()` directly, corrupting the JSON-RPC stream. Only writes containing valid JSON-RPC messages (`{"jsonrpc":...}`) are now allowed through stdout; everything else is redirected to stderr. This fixes the flood of "Unexpected token is not valid JSON" warnings on every new chat in Claude Desktop, including leaked `refCount`, `dbPath`, `clientVersion`, `protocolVersion`, and other debug strings.
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
## [2.41.1] - 2026-03-27
### Fixed

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "n8n-mcp",
"version": "2.41.1",
"version": "2.41.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "n8n-mcp",
"version": "2.41.1",
"version": "2.41.3",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "1.28.0",

View File

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

View File

@@ -107,11 +107,10 @@ export class SingleSessionHTTPServer {
private session: Session | null = null; // Keep for SSE compatibility
private consoleManager = new ConsoleManager();
private expressServer: any;
// Session timeout reduced from 30 minutes to 5 minutes for faster cleanup
// Configurable via SESSION_TIMEOUT_MINUTES environment variable
// This prevents memory buildup from stale sessions
// Session timeout — configurable via SESSION_TIMEOUT_MINUTES environment variable
// Default 30 minutes: balances memory cleanup with real editing sessions (#626)
private sessionTimeout = parseInt(
process.env.SESSION_TIMEOUT_MINUTES || '5', 10
process.env.SESSION_TIMEOUT_MINUTES || '30', 10
) * 60 * 1000;
private authToken: string | null = null;
private cleanupTimer: NodeJS.Timeout | null = null;

View File

@@ -2731,7 +2731,7 @@ const updateTableSchema = tableIdSchema.extend({
// MCP transports may serialize JSON objects/arrays as strings.
// Parse them back, but return the original value on failure so Zod reports a proper type error.
function tryParseJson(val: unknown): unknown {
export function tryParseJson(val: unknown): unknown {
if (typeof val !== 'string') return val;
try { return JSON.parse(val); } catch { return val; }
}

View File

@@ -7,7 +7,7 @@ import { z } from 'zod';
import { McpToolResponse } from '../types/n8n-api';
import { WorkflowDiffRequest, WorkflowDiffOperation, WorkflowDiffValidationError } from '../types/workflow-diff';
import { WorkflowDiffEngine } from '../services/workflow-diff-engine';
import { getN8nApiClient } from './handlers-n8n-manager';
import { getN8nApiClient, tryParseJson } from './handlers-n8n-manager';
import { N8nApiError, getUserFriendlyErrorMessage } from '../utils/n8n-errors';
import { logger } from '../utils/logger';
import { InstanceContext } from '../types/instance-context';
@@ -39,7 +39,7 @@ const NODE_TARGETING_OPERATIONS = new Set([
// Zod schema for the diff request
const workflowDiffSchema = z.object({
id: z.string(),
operations: z.array(z.object({
operations: z.preprocess(tryParseJson, z.array(z.object({
type: z.string(),
description: z.string().optional(),
// Node operations
@@ -87,7 +87,7 @@ const workflowDiffSchema = z.object({
}
}
return op;
})),
}))),
validateOnly: z.boolean().optional(),
continueOnError: z.boolean().optional(),
createBackup: z.boolean().optional(),

View File

@@ -748,6 +748,13 @@ export class N8NDocumentationMCPServer {
// tool's inputSchema as the source of truth.
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
// Strip undefined values from args (#611) — VS Code extension sends
// explicit undefined values which Zod's .optional() rejects.
// Removing them makes Zod treat them as missing (which .optional() allows).
if (processedArgs) {
processedArgs = JSON.parse(JSON.stringify(processedArgs));
}
try {
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
const startTime = Date.now();

View File

@@ -39,6 +39,25 @@ console.clear = () => {};
console.count = () => {};
console.countReset = () => {};
// CRITICAL: Intercept process.stdout.write to prevent non-JSON-RPC output (#628, #627, #567)
// Console suppression alone is insufficient — native modules (better-sqlite3), n8n packages,
// and third-party code can call process.stdout.write() directly, corrupting the JSON-RPC stream.
// Only allow writes that look like JSON-RPC messages; redirect everything else to stderr.
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const stderrWrite = process.stderr.write.bind(process.stderr);
process.stdout.write = function (chunk: any, encodingOrCallback?: any, callback?: any): boolean {
const str = typeof chunk === 'string' ? chunk : chunk.toString();
// JSON-RPC messages are JSON objects with "jsonrpc" field — let those through
// The MCP SDK sends one JSON object per write call
const trimmed = str.trimStart();
if (trimmed.startsWith('{') && trimmed.includes('"jsonrpc"')) {
return originalStdoutWrite(chunk, encodingOrCallback, callback);
}
// Redirect everything else to stderr so it doesn't corrupt the protocol
return stderrWrite(chunk, encodingOrCallback, callback);
} as typeof process.stdout.write;
// Import and run the server AFTER suppressing output
import { N8NDocumentationMCPServer } from './server';

View File

@@ -19,6 +19,18 @@ 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 = {
@@ -288,6 +300,32 @@ 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
*/
@@ -307,6 +345,9 @@ 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 filter-based nodes (IF v2.2+, Switch v3.2+) have complete metadata
// Validate If/Switch condition structures (version-conditional)
if (workflow.nodes) {
workflow.nodes.forEach((node, index) => {
const filterErrors = validateFilterBasedNodeMetadata(node);
const filterErrors = validateConditionNodeStructure(node);
if (filterErrors.length > 0) {
errors.push(...filterErrors.map(err => `Node "${node.name}" (index ${index}): ${err}`));
}
@@ -488,106 +488,81 @@ export function hasWebhookTrigger(workflow: Workflow): boolean {
}
/**
* Validate filter-based node metadata (IF v2.2+, Switch v3.2+)
* Returns array of error messages
* Validate If/Switch node conditions structure for ANY version.
* Version-conditional: validates the correct structure per version.
*/
export function validateFilterBasedNodeMetadata(node: WorkflowNode): string[] {
export function validateConditionNodeStructure(node: WorkflowNode): string[] {
const errors: string[] = [];
const typeVersion = node.typeVersion || 1;
// 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}`
);
}
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`));
});
}
}
// 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;
}
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);
}
/**
* Validate operator structure
* Ensures operator has correct format: {type, operation, singleValue?}

View File

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

View File

@@ -15,6 +15,7 @@ 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]' });
@@ -579,6 +580,19 @@ 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

@@ -334,14 +334,14 @@ describe('HTTP Server Session Management', () => {
server = new SingleSessionHTTPServer();
// Mock expired sessions
// Note: Default session timeout is 5 minutes (configurable via SESSION_TIMEOUT_MINUTES)
// Note: Default session timeout is 30 minutes (configurable via SESSION_TIMEOUT_MINUTES)
const mockSessionMetadata = {
'session-1': {
lastAccess: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago (expired with 5 min timeout)
lastAccess: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago (expired with 30 min timeout)
createdAt: new Date(Date.now() - 60 * 60 * 1000)
},
'session-2': {
lastAccess: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago (not expired with 5 min timeout)
lastAccess: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago (not expired with 30 min timeout)
createdAt: new Date(Date.now() - 20 * 60 * 1000)
}
};
@@ -517,15 +517,15 @@ describe('HTTP Server Session Management', () => {
it('should get session metrics correctly', async () => {
server = new SingleSessionHTTPServer();
// Note: Default session timeout is 5 minutes (configurable via SESSION_TIMEOUT_MINUTES)
// Note: Default session timeout is 30 minutes (configurable via SESSION_TIMEOUT_MINUTES)
const now = Date.now();
(server as any).sessionMetadata = {
'active-session': {
lastAccess: new Date(now - 2 * 60 * 1000), // 2 minutes ago (not expired with 5 min timeout)
lastAccess: new Date(now - 10 * 60 * 1000), // 10 minutes ago (not expired with 30 min timeout)
createdAt: new Date(now - 20 * 60 * 1000)
},
'expired-session': {
lastAccess: new Date(now - 10 * 60 * 1000), // 10 minutes ago (expired with 5 min timeout)
lastAccess: new Date(now - 45 * 60 * 1000), // 45 minutes ago (expired with 30 min timeout)
createdAt: new Date(now - 60 * 60 * 1000)
}
};

View File

@@ -17,9 +17,13 @@ vi.mock('@/services/workflow-diff-engine');
vi.mock('@/services/n8n-api-client');
vi.mock('@/config/n8n-api');
vi.mock('@/utils/logger');
vi.mock('@/mcp/handlers-n8n-manager', () => ({
getN8nApiClient: vi.fn(),
}));
vi.mock('@/mcp/handlers-n8n-manager', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/mcp/handlers-n8n-manager')>();
return {
...actual,
getN8nApiClient: vi.fn(),
};
});
// Import mocked modules
import { getN8nApiClient } from '@/mcp/handlers-n8n-manager';

View File

@@ -105,6 +105,78 @@ 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,6 +2384,73 @@ 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,6 +4,7 @@ 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');
@@ -743,4 +744,156 @@ 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);
});
});
});