Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 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 leaves invalid syntax fails the operation. Only the final result of one operation\'s patches array is checked, so apply dependent edits in a single operation. 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
45 changes: 45 additions & 0 deletions src/services/workflow-diff-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,35 @@ 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 = Object.getPrototypeOf(async function () {})
.constructor as new (...args: string[]) => unknown;

/**
* 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). Values starting with "=" are n8n expressions, not plain
* JS, and are skipped. Returns the syntax error message, or null when the code
* parses or cannot be checked here (a non-SyntaxError such as a CSP EvalError
* must not block the operation).
*/
function getJsSyntaxError(fieldPath: string, code: string): string | null {
const fieldName = fieldPath.split('.').pop() ?? '';
if (!JS_CODE_FIELD_NAMES.has(fieldName)) return null;
if (code.startsWith('=')) return null;
try {
new AsyncFunctionCtor(code);
return null;
} catch (error) {
return error instanceof SyntaxError ? error.message : null;
}
}

function operationReferencesAddedNode(
operation: WorkflowDiffOperation,
addedNode: AddNodeOperation['node']
Expand Down Expand Up @@ -1088,6 +1117,13 @@ export class WorkflowDiffEngine {
// read "$&", "$'" etc. in it as JS replacement patterns (#1012).
current = current.replace(patch.find, () => patch.replace);
}
const syntaxError = getJsSyntaxError(path, current);
if (syntaxError) {
throw new Error(
`__patch_find_replace: patches would leave "${path}" with invalid JavaScript (${syntaxError}). ` +
`The workflow was not modified.`
);
}
this.setNestedProperty(draft, path, current);
} else {
this.setNestedProperty(draft, path, value);
Expand Down Expand Up @@ -1205,6 +1241,15 @@ export class WorkflowDiffEngine {
}
}

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

this.setNestedProperty(node, operation.fieldPath, current);

// Sanitize node after updates
Expand Down
182 changes: 182 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,154 @@ 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', async () => {
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',
// 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');
});

it('should leave the workflow unchanged when the syntax guard rejects a patch', 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',
patches: [{ find: 'i.ok);', replace: 'i.ok;' }]
}]
});

expect(result.success).toBe(false);
const codeNode = workflow.nodes.find((n: any) => n.name === 'Code');
expect(codeNode?.parameters.jsCode).toBe(original);
});

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