Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.71.1] - 2026-08-18

### Fixed

- **`patchNodeField` no longer corrupts a field when the replacement text contains `$`.** In literal mode (the default), the replacement string was handed straight to `String.prototype.replace()`, which interprets JS replacement patterns inside it: `$'` splices in everything after the match, `` $` `` everything before it, `$&` the match itself. Patching a Code node with something as ordinary as `const money = '$' + amount.toFixed(2)` duplicated the rest of the node's source into the middle of the insertion, saved the workflow in that state — live, if the workflow was active — and reported success. Literal mode now inserts the replacement verbatim in the single-occurrence case as well as under `replaceAll`, as does the older `__patch_find_replace` path in `updateNode`. With `regex: true`, replacement patterns remain available by design — `$1` for capture groups, `$$` for a literal `$` — and the tool documentation now states the distinction. Reported with root cause and both candidate fixes by @NextLevelManagementAdvisors. (#1012)

## [2.71.0] - 2026-08-18

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.71.0",
"version": "2.71.1",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion package.runtime.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "n8n-mcp-runtime",
"version": "2.70.1",
"version": "2.71.1",
"description": "n8n MCP Server Runtime Dependencies Only",
"private": true,
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ n8n_update_partial_workflow({
'**patchNodeField is strict**: it ERRORS if the find string is not found (unlike __patch_find_replace which only warns)',
'**patchNodeField detects ambiguity**: if find matches multiple times, it ERRORS unless replaceAll: true is set',
'When using regex: true in patchNodeField, escape special regex characters (., *, +, etc.) if you want literal matching',
'patchNodeField literal mode (regex not set) inserts replace verbatim, so $ needs no escaping. With regex: true, replace supports JS replacement patterns: $1 for a capture group, $$ for a literal $',
'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',
Expand Down
13 changes: 7 additions & 6 deletions src/services/workflow-diff-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1084,7 +1084,9 @@ export class WorkflowDiffEngine {
});
continue;
}
current = current.replace(patch.find, patch.replace);
// Function replacer keeps the replacement verbatim — a bare string would
// read "$&", "$'" etc. in it as JS replacement patterns (#1012).
current = current.replace(patch.find, () => patch.replace);
}
this.setNestedProperty(draft, path, current);
} else {
Expand Down Expand Up @@ -1196,11 +1198,10 @@ export class WorkflowDiffEngine {
);
}

if (patch.replaceAll) {
current = current.split(patch.find).join(patch.replace);
} else {
current = current.replace(patch.find, patch.replace);
}
// split/join inserts the replacement verbatim; String.replace would read
// "$&", "$'" and friends in it as JS replacement patterns (#1012). Safe
// for the single-occurrence case too: the checks above leave exactly one.
current = current.split(patch.find).join(patch.replace);
}
}

Expand Down
112 changes: 112 additions & 0 deletions tests/unit/services/workflow-diff-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,37 @@ describe('WorkflowDiffEngine', () => {
expect(codeNode?.parameters.jsCode).toBe('const x = 1;\nreturn x + 3;');
});

it('should insert __patch_find_replace replacement literally when it contains $ patterns (#1012)', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: '// anchor\nreturn items;' }
});

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'updateNode' as const,
nodeName: 'Code',
updates: {
'parameters.jsCode': {
__patch_find_replace: [
{ find: '// anchor', replace: "const money = '$' + total;" }
]
}
}
}]
});

expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe("const money = '$' + total;\nreturn items;");
});

it('should apply multiple sequential __patch_find_replace patches', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
Expand Down Expand Up @@ -878,6 +909,87 @@ describe('WorkflowDiffEngine', () => {
expect(codeNode?.parameters.jsCode).toBe('const a = 10;\nconst b = 20;\nreturn a + b;');
});

it('should insert replacement text literally when it contains $ patterns (#1012)', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: '// anchor\nconst rest = 1;\nreturn rest;' }
});

// "$'" is the dangerous case from #1012: a bare-string replacer would
// splice everything after the match into the insertion.
const replacement = "const money = '$' + amount.toFixed(2); // $& $` $' $1 $$ $<name>";
const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: '// anchor', replace: replacement }]
}]
});

expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe(`${replacement}\nconst rest = 1;\nreturn rest;`);
});

it('should keep $ literal in literal mode with replaceAll', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const a = AMOUNT;\nconst b = AMOUNT;' }
});

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'AMOUNT', replace: "'$' + n", replaceAll: true }]
}]
});

expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe("const a = '$' + n;\nconst b = '$' + n;");
});

it('should support capture group references in regex mode replacements', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [900, 300],
parameters: { jsCode: 'const limit = 42;' }
});

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'const limit = (\\d+)', replace: 'const limit = $1 * 2', regex: true }]
}]
});

expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('const limit = 42 * 2;');
});

it('should support regex pattern matching', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
Expand Down
Loading