mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-31 22:53:07 +00:00
fix: correct operator names, connection types, and implement __patch_find_replace (#665, #659, #642) (#672)
Three critical fixes in n8n_update_partial_workflow: - **#665**: Replace incorrect `isNotEmpty`/`isEmpty` operator names with `notEmpty`/`empty` across validators, sanitizer, docs, and error messages. Add auto-correction in sanitizer. Unknown operators silently returned false in n8n's execution engine. - **#659**: Remap numeric `targetInput` values (e.g., "0") to "main" in addConnection. Relax sourceOutput remapping guard for redundant sourceOutput+sourceIndex combinations. Also resolves #653 (dangling connections caused by malformed type:"0" connections). - **#642**: Implement __patch_find_replace for surgical string edits in updateNode. Previously stored patch objects literally as jsCode, producing [object Object]. Now reads current value, applies find/replace sequentially, writes back the string. Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
de2abaf89d
commit
6be9ffa53e
@@ -259,9 +259,9 @@ export async function handleUpdatePartialWorkflow(
|
||||
// Build recovery guidance based on error types
|
||||
const recoverySteps = [];
|
||||
if (errorTypes.has('operator_issues')) {
|
||||
recoverySteps.push('Operator structure issue detected. Use validate_node_operation to check specific nodes.');
|
||||
recoverySteps.push('Operator structure issue detected. Use validate_node to check specific nodes.');
|
||||
recoverySteps.push('Binary operators (equals, contains, greaterThan, etc.) must NOT have singleValue:true');
|
||||
recoverySteps.push('Unary operators (isEmpty, isNotEmpty, true, false) REQUIRE singleValue:true');
|
||||
recoverySteps.push('Unary operators (empty, notEmpty, true, false) REQUIRE singleValue:true');
|
||||
}
|
||||
if (errorTypes.has('connection_issues')) {
|
||||
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
||||
|
||||
@@ -119,8 +119,8 @@ When ANY workflow update is made, ALL nodes in the workflow are automatically sa
|
||||
|
||||
1. **Operator Structure Fixes**:
|
||||
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
||||
- Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added
|
||||
- Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\`
|
||||
- Unary operators (empty, notEmpty, true, false) automatically get \`singleValue: true\` added
|
||||
- Invalid operator structures (e.g., \`{type: "notEmpty"}\`) are corrected to \`{type: "object", operation: "notEmpty"}\`
|
||||
|
||||
2. **Missing Metadata Added**:
|
||||
- IF nodes with conditions get complete \`conditions.options\` structure if missing
|
||||
@@ -333,6 +333,8 @@ n8n_update_partial_workflow({
|
||||
'// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "vwx", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})',
|
||||
'// Update node parameter\nn8n_update_partial_workflow({id: "yza", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})',
|
||||
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
|
||||
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
|
||||
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
|
||||
'\n// ============ AI CONNECTION EXAMPLES ============',
|
||||
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
|
||||
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
|
||||
@@ -411,10 +413,12 @@ n8n_update_partial_workflow({
|
||||
'**CRITICAL**: For Switch nodes, ALWAYS use case=N instead of sourceIndex. Using same sourceIndex for multiple connections will put them on the same case output.',
|
||||
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||
'replaceConnections overwrites entire connections object - all previous connections lost',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (empty, notEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)',
|
||||
'**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches',
|
||||
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
|
||||
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
|
||||
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
|
||||
'To remove a property, set it to null in the updates object',
|
||||
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
|
||||
'Removing a required property may cause validation errors - check node documentation first',
|
||||
|
||||
@@ -1209,30 +1209,30 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
'empty', 'notEmpty', 'equals', 'notEquals',
|
||||
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
||||
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
||||
'exists', 'notExists', 'isNotEmpty' // exists checks field presence, isNotEmpty alias for notEmpty
|
||||
'exists', 'notExists'
|
||||
],
|
||||
number: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
dateTime: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
boolean: [
|
||||
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
array: [
|
||||
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
||||
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
object: [
|
||||
'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
any: ['exists', 'notExists', 'isNotEmpty']
|
||||
any: ['exists', 'notExists']
|
||||
};
|
||||
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
|
||||
@@ -621,13 +621,13 @@ export function validateOperatorStructure(operator: any, path: string): string[]
|
||||
if (!operator.operation) {
|
||||
errors.push(
|
||||
`${path}: missing required field "operation". ` +
|
||||
'Operation specifies the comparison type (e.g., "equals", "contains", "isNotEmpty")'
|
||||
'Operation specifies the comparison type (e.g., "equals", "contains", "notEmpty")'
|
||||
);
|
||||
}
|
||||
|
||||
// Check singleValue based on operator type
|
||||
if (operator.operation) {
|
||||
const unaryOperators = ['isEmpty', 'isNotEmpty', 'true', 'false', 'isNumeric'];
|
||||
const unaryOperators = ['empty', 'notEmpty', 'true', 'false', 'isNumeric', 'exists', 'notExists'];
|
||||
const isUnary = unaryOperators.includes(operator.operation);
|
||||
|
||||
if (isUnary) {
|
||||
@@ -643,7 +643,7 @@ export function validateOperatorStructure(operator: any, path: string): string[]
|
||||
if (operator.singleValue === true) {
|
||||
errors.push(
|
||||
`${path}: binary operator "${operator.operation}" should not have "singleValue: true". ` +
|
||||
'Only unary operators (isEmpty, isNotEmpty, true, false, isNumeric) need this property.'
|
||||
'Only unary operators (empty, notEmpty, true, false, isNumeric, exists, notExists) need this property.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,23 @@ import { INodeParameters } from 'n8n-workflow';
|
||||
import { logger } from '../utils/logger';
|
||||
import { WorkflowNode } from '../types/n8n-api';
|
||||
|
||||
/** Legacy operator names that n8n no longer recognizes, mapped to their correct names. */
|
||||
const OPERATOR_CORRECTIONS: Record<string, string> = {
|
||||
'isEmpty': 'empty',
|
||||
'isNotEmpty': 'notEmpty',
|
||||
};
|
||||
|
||||
/** Operators that take no right-hand value and require singleValue: true. */
|
||||
const UNARY_OPERATORS = new Set([
|
||||
'true',
|
||||
'false',
|
||||
'isNumeric',
|
||||
'empty',
|
||||
'notEmpty',
|
||||
'exists',
|
||||
'notExists',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sanitize a single node by adding required metadata
|
||||
*/
|
||||
@@ -162,29 +179,28 @@ function sanitizeOperator(operator: any): any {
|
||||
const sanitized = { ...operator };
|
||||
|
||||
// Fix common mistake: type field used for operation name
|
||||
// WRONG: {type: "isNotEmpty"}
|
||||
// RIGHT: {type: "string", operation: "isNotEmpty"}
|
||||
// WRONG: {type: "notEmpty"}
|
||||
// RIGHT: {type: "string", operation: "notEmpty"}
|
||||
if (sanitized.type && !sanitized.operation) {
|
||||
// Check if type value looks like an operation (lowercase, no dots)
|
||||
const typeValue = sanitized.type as string;
|
||||
if (isOperationName(typeValue)) {
|
||||
logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
||||
|
||||
// Infer data type from operation
|
||||
const dataType = inferDataType(typeValue);
|
||||
sanitized.type = dataType;
|
||||
sanitized.type = inferDataType(typeValue);
|
||||
sanitized.operation = typeValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-correct legacy operator names to n8n-recognized names
|
||||
if (sanitized.operation && OPERATOR_CORRECTIONS[sanitized.operation]) {
|
||||
sanitized.operation = OPERATOR_CORRECTIONS[sanitized.operation];
|
||||
}
|
||||
|
||||
// Set singleValue based on operator type
|
||||
if (sanitized.operation) {
|
||||
if (isUnaryOperator(sanitized.operation)) {
|
||||
// Unary operators require singleValue: true
|
||||
sanitized.singleValue = true;
|
||||
} else {
|
||||
// Binary operators should NOT have singleValue (or it should be false/undefined)
|
||||
// Remove it to prevent UI errors
|
||||
// Binary operators should NOT have singleValue — remove it to prevent UI errors
|
||||
delete sanitized.singleValue;
|
||||
}
|
||||
}
|
||||
@@ -207,7 +223,7 @@ function isOperationName(value: string): boolean {
|
||||
*/
|
||||
function inferDataType(operation: string): string {
|
||||
// Boolean operations
|
||||
const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty'];
|
||||
const booleanOps = ['true', 'false'];
|
||||
if (booleanOps.includes(operation)) {
|
||||
return 'boolean';
|
||||
}
|
||||
@@ -225,7 +241,6 @@ function inferDataType(operation: string): string {
|
||||
}
|
||||
|
||||
// Object operations: empty/notEmpty/exists/notExists are generic object-level checks
|
||||
// (distinct from isEmpty/isNotEmpty which are boolean-typed operations)
|
||||
const objectOps = ['empty', 'notEmpty', 'exists', 'notExists'];
|
||||
if (objectOps.includes(operation)) {
|
||||
return 'object';
|
||||
@@ -239,18 +254,7 @@ function inferDataType(operation: string): string {
|
||||
* Check if operator is unary (requires singleValue: true)
|
||||
*/
|
||||
function isUnaryOperator(operation: string): boolean {
|
||||
const unaryOps = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'true',
|
||||
'false',
|
||||
'isNumeric',
|
||||
'empty',
|
||||
'notEmpty',
|
||||
'exists',
|
||||
'notExists'
|
||||
];
|
||||
return unaryOps.includes(operation);
|
||||
return UNARY_OPERATORS.has(operation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -470,6 +470,31 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate __patch_find_replace syntax (#642)
|
||||
for (const [path, value] of Object.entries(operation.updates)) {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& '__patch_find_replace' in value) {
|
||||
const patches = value.__patch_find_replace;
|
||||
if (!Array.isArray(patches)) {
|
||||
return `Invalid __patch_find_replace at "${path}": must be an array of {find, replace} objects`;
|
||||
}
|
||||
for (let i = 0; i < patches.length; i++) {
|
||||
const patch = patches[i];
|
||||
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
|
||||
return `Invalid __patch_find_replace entry at "${path}[${i}]": each entry must have "find" (string) and "replace" (string)`;
|
||||
}
|
||||
}
|
||||
// node was already found above — reuse it
|
||||
const currentValue = this.getNestedProperty(node, path);
|
||||
if (currentValue === undefined) {
|
||||
return `Cannot apply __patch_find_replace to "${path}": property does not exist on node`;
|
||||
}
|
||||
if (typeof currentValue !== 'string') {
|
||||
return `Cannot apply __patch_find_replace to "${path}": current value is ${typeof currentValue}, expected string`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -721,7 +746,26 @@ export class WorkflowDiffEngine {
|
||||
|
||||
// Apply updates using dot notation
|
||||
Object.entries(operation.updates).forEach(([path, value]) => {
|
||||
this.setNestedProperty(node, path, value);
|
||||
// Handle __patch_find_replace for surgical string edits (#642)
|
||||
// Format and type validation already passed in validateUpdateNode()
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& '__patch_find_replace' in value) {
|
||||
const patches = value.__patch_find_replace as Array<{ find: string; replace: string }>;
|
||||
let current = this.getNestedProperty(node, path) as string;
|
||||
for (const patch of patches) {
|
||||
if (!current.includes(patch.find)) {
|
||||
this.warnings.push({
|
||||
operation: -1,
|
||||
message: `__patch_find_replace: "${patch.find.substring(0, 50)}" not found in "${path}". Skipped.`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current = current.replace(patch.find, patch.replace);
|
||||
}
|
||||
this.setNestedProperty(node, path, current);
|
||||
} else {
|
||||
this.setNestedProperty(node, path, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize node after updates to ensure metadata is complete
|
||||
@@ -766,11 +810,13 @@ export class WorkflowDiffEngine {
|
||||
let sourceOutput = String(operation.sourceOutput ?? 'main');
|
||||
let sourceIndex = operation.sourceIndex ?? 0;
|
||||
|
||||
// Remap numeric sourceOutput (e.g., "0", "1") to "main" with sourceIndex (#537)
|
||||
// Remap numeric sourceOutput (e.g., "0", "1") to "main" with sourceIndex (#537, #659)
|
||||
// Skip when smart parameters (branch, case) are present — they take precedence
|
||||
if (/^\d+$/.test(sourceOutput) && operation.sourceIndex === undefined
|
||||
const numericOutput = /^\d+$/.test(sourceOutput) ? parseInt(sourceOutput, 10) : null;
|
||||
if (numericOutput !== null
|
||||
&& (operation.sourceIndex === undefined || operation.sourceIndex === numericOutput)
|
||||
&& operation.branch === undefined && operation.case === undefined) {
|
||||
sourceIndex = parseInt(sourceOutput, 10);
|
||||
sourceIndex = numericOutput;
|
||||
sourceOutput = 'main';
|
||||
}
|
||||
|
||||
@@ -823,7 +869,11 @@ export class WorkflowDiffEngine {
|
||||
// Use nullish coalescing to properly handle explicit 0 values
|
||||
// Default targetInput to sourceOutput to preserve connection type for AI connections (ai_tool, ai_memory, etc.)
|
||||
// Coerce to string to handle numeric values passed as sourceOutput/targetInput
|
||||
const targetInput = String(operation.targetInput ?? sourceOutput);
|
||||
let targetInput = String(operation.targetInput ?? sourceOutput);
|
||||
// Remap numeric targetInput (e.g., "0") to "main" — connection types are named strings (#659)
|
||||
if (/^\d+$/.test(targetInput)) {
|
||||
targetInput = 'main';
|
||||
}
|
||||
const targetIndex = operation.targetIndex ?? 0;
|
||||
|
||||
// Initialize source node connections object
|
||||
@@ -1266,6 +1316,16 @@ export class WorkflowDiffEngine {
|
||||
return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
|
||||
}
|
||||
|
||||
private getNestedProperty(obj: any, path: string): any {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private setNestedProperty(obj: any, path: string, value: any): void {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
Reference in New Issue
Block a user