Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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.72.0] - 2026-08-18

### Added

- **Patches to JavaScript code fields are now syntax-checked before saving.** After `patchNodeField` (and the older `__patch_find_replace` path in `updateNode`) applies its find/replace patches to `parameters.jsCode` or `functionCode`, the engine parses the result as the body of an async function — matching how n8n wraps Code-node JavaScript, so top-level `return` and `await` are valid. A patch that leaves the field unparseable now fails the operation with the parser's message instead of saving broken code to the workflow — live, if the workflow is active. Only regressions the patch introduces are blocked: a field that was already unparseable before patching stays patchable, so pre-existing corruption can be repaired incrementally. Only the final result of one operation's patches array is checked, so several dependent edits that pass through an invalid intermediate state can ride a single operation. Values starting with `=` (n8n expressions), `pythonCode`, and non-code fields are not checked; the check parses only and never executes the code. Proposed by @NextLevelManagementAdvisors in the #1012 report. (#1012)

## [2.71.1] - 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.1",
"version": "2.72.0",
"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.71.1",
"version": "2.72.0",
"description": "n8n MCP Server Runtime Dependencies Only",
"private": true,
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ n8n_update_partial_workflow({
'**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 $',
'Patches to parameters.jsCode or functionCode are parsed as JavaScript after applying: a patch that breaks previously-valid code fails the operation. Only the final result of one operation\'s patches array is checked, so apply dependent edits in a single operation. Fields that were already invalid before patching, values starting with = (expressions), and pythonCode are not checked',
'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
52 changes: 50 additions & 2 deletions src/services/workflow-diff-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,50 @@ function countOccurrences(str: string, search: string): number {
return count;
}

// Fields that hold plain JavaScript: the Code node's jsCode and the legacy
// Function/FunctionItem nodes' functionCode. Python lives in pythonCode.
const JS_CODE_FIELD_NAMES = new Set(['jsCode', 'functionCode']);

// Parses (never executes) code as an async function body, matching n8n's own
// wrapping of Code-node JS — so top-level return/await are valid.
const AsyncFunctionCtor = (async () => {}).constructor as new (...args: string[]) => unknown;

function jsSyntaxErrorOf(code: string): SyntaxError | undefined {
try {
new AsyncFunctionCtor(code);
} catch (error) {
// A non-SyntaxError (e.g. a CSP EvalError) means we could not check,
// not that the code is broken.
if (error instanceof SyntaxError) return error;
}
return undefined;
}

/**
* After a find/replace patch lands on a JavaScript code field, parse the result
* so a patch that leaves broken code fails the operation instead of saving it
* (#1012 expansion). Throws before the caller writes, keeping the operation
* atomic. Values starting with "=" are n8n expressions, not plain JS, and are
* skipped. Only regressions the patch introduced are blocked: when the field
* was already unparseable before patching, an incremental repair must be able
* to pass through still-broken states.
*/
function assertPatchedJsSyntax(operation: string, fieldPath: string, patched: string, original: string): void {
const fieldName = fieldPath.split('.').pop() ?? '';
if (!JS_CODE_FIELD_NAMES.has(fieldName)) return;
if (patched.startsWith('=')) return;

const syntaxError = jsSyntaxErrorOf(patched);
if (!syntaxError) return;
if (jsSyntaxErrorOf(original) !== undefined) return;

throw new Error(
`${operation}: patches would leave "${fieldPath}" with invalid JavaScript (${syntaxError.message}). ` +
`The workflow was not modified. If several dependent edits pass through an invalid intermediate state, ` +
`apply them as one operation — only the final result of the patches array is checked.`
);
}

function operationReferencesAddedNode(
operation: WorkflowDiffOperation,
addedNode: AddNodeOperation['node']
Expand Down Expand Up @@ -1075,7 +1119,8 @@ export class WorkflowDiffEngine {
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(draft, path) as string;
const original = this.getNestedProperty(draft, path) as string;
let current = original;
for (const patch of patches) {
if (!current.includes(patch.find)) {
this.warnings.push({
Expand All @@ -1088,6 +1133,7 @@ export class WorkflowDiffEngine {
// read "$&", "$'" etc. in it as JS replacement patterns (#1012).
current = current.replace(patch.find, () => patch.replace);
}
assertPatchedJsSyntax('__patch_find_replace', path, current, original);
this.setNestedProperty(draft, path, current);
} else {
this.setNestedProperty(draft, path, value);
Expand Down Expand Up @@ -1155,7 +1201,8 @@ export class WorkflowDiffEngine {

this.modifiedNodeIds.add(node.id);

let current = this.getNestedProperty(node, operation.fieldPath) as string;
const original = this.getNestedProperty(node, operation.fieldPath) as string;
let current = original;

for (let i = 0; i < operation.patches.length; i++) {
const patch = operation.patches[i];
Expand Down Expand Up @@ -1205,6 +1252,7 @@ export class WorkflowDiffEngine {
}
}

assertPatchedJsSyntax('patchNodeField', operation.fieldPath, current, original);
this.setNestedProperty(node, operation.fieldPath, current);

// Sanitize node after updates
Expand Down
187 changes: 187 additions & 0 deletions tests/unit/services/workflow-diff-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,40 @@ describe('WorkflowDiffEngine', () => {
expect(result.warnings!.some(w => w.message.includes('not found'))).toBe(true);
});

it('should reject __patch_find_replace patches that leave jsCode with a syntax error', async () => {
const original = 'const items = getItems();\nreturn items.filter(i => i.ok);';
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: original }
});

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'updateNode' as const,
nodeName: 'Code',
updates: {
'parameters.jsCode': {
__patch_find_replace: [
// Removes the closing paren of filter(...) — corrupts the code
{ find: 'i.ok);', replace: 'i.ok;' }
]
}
}
}]
});

expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('invalid JavaScript');
const codeNode = workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe(original);
});

it.each([false, true])('should validate connection operations before later rename projections when validateOnly=%s', async (validateOnly) => {
const result = await diffEngine.applyDiff(baseWorkflow, {
id: 'test-workflow',
Expand Down Expand Up @@ -1369,6 +1403,159 @@ describe('WorkflowDiffEngine', () => {
const codeNode = result.workflow.nodes.find((n: any) => n.id === 'code-1');
expect(codeNode?.parameters.jsCode).toBe('const x = 2;');
});

describe('JavaScript syntax guard on code fields', () => {
const codeWorkflow = (jsCode: string) => {
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 }
});
return workflow;
};
Comment on lines +1439 to +1451

it('should reject a patch that leaves parameters.jsCode with a syntax error, without touching the workflow', async () => {
const original = 'const items = getItems();\nreturn items.filter(i => i.ok);';
const workflow = codeWorkflow(original);

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
// Removes the closing paren of filter(...) — corrupts the code
patches: [{ find: 'i.ok);', replace: 'i.ok;' }]
}]
});

expect(result.success).toBe(false);
expect(result.errors?.[0]?.message).toContain('invalid JavaScript');
expect(result.errors?.[0]?.message).toContain('parameters.jsCode');
const codeNode = workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe(original);
});

it('should not block patches to a field that was already broken before patching', async () => {
// Incremental repair of pre-existing corruption (e.g. saved by the
// pre-2.71.1 bug) must not be blamed on the patch.
const workflow = codeWorkflow('const items = getItems(;\nreturn items.filter(i => i.ok);');

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
// Fixes one problem while the code stays broken overall
patches: [{ find: 'i => i.ok);', replace: 'i => i.ok;' }]
}]
});

expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe('const items = getItems(;\nreturn items.filter(i => i.ok;');
});

it('should accept patched code with top-level return and await', async () => {
const workflow = codeWorkflow('const res = await fetch(url);\nreturn res;');

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'fetch(url)', replace: 'fetch(url, { method: "POST" })' }]
}]
});

expect(result.success).toBe(true);
});

it('should only check the final result of one operation\'s patches array', async () => {
const workflow = codeWorkflow('function pick(item) { return item.id; }\nreturn items.map(pick);');

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
// First patch alone leaves an unbalanced brace; second restores balance
patches: [
{ find: 'return item.id; }', replace: 'return item.id ?? null;' },
{ find: '?? null;', replace: '?? null; }' }
]
}]
});

expect(result.success).toBe(true);
const codeNode = result.workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe(
'function pick(item) { return item.id ?? null; }\nreturn items.map(pick);'
);
});

it('should not check jsCode values that are n8n expressions (leading =)', async () => {
const workflow = codeWorkflow('={{ $json.dynamicCode }} broken (');

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.jsCode',
patches: [{ find: 'dynamicCode', replace: 'otherCode' }]
}]
});

expect(result.success).toBe(true);
});

it('should not check pythonCode fields', async () => {
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
workflow.nodes.push({
id: 'code-1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 2,
position: [900, 300],
parameters: { language: 'python', pythonCode: 'def pick(item):\n return item' }
});

const result = await diffEngine.applyDiff(workflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeName: 'Code',
fieldPath: 'parameters.pythonCode',
patches: [{ find: 'return item', replace: 'return item.get("id")' }]
}]
});

expect(result.success).toBe(true);
});

it('should not check non-code string fields', async () => {
const result = await diffEngine.applyDiff(baseWorkflow, {
id: 'test',
operations: [{
type: 'patchNodeField' as const,
nodeId: 'http-1',
fieldPath: 'parameters.url',
// A URL is not JavaScript; the guard must not reject it
patches: [{ find: 'api.example.com', replace: 'api.example.com/v2(beta' }]
}]
});

expect(result.success).toBe(true);
});
});
});

describe('MoveNode Operation', () => {
Expand Down
Loading