mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-30 14:13:12 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ebfbb0fc | ||
|
|
6e4a9d520d | ||
|
|
fb2d306dc3 |
8
.github/workflows/docker-build-fast.yml
vendored
8
.github/workflows/docker-build-fast.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/docker-build-n8n.yml
vendored
6
.github/workflows/docker-build-n8n.yml
vendored
@@ -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
|
||||
|
||||
|
||||
22
.github/workflows/docker-build.yml
vendored
22
.github/workflows/docker-build.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -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:
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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?}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user