Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 `parameters.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. A leading `=` is stripped before parsing — these are `noDataExpression` fields, and that is exactly what n8n does before running them. `pythonCode`, non-code fields, and code over 1MB are not checked; the check parses only and never executes the code, and steps aside whenever it cannot parse (rather than block what it cannot judge). 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 parameters.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. A leading = is stripped before parsing, matching how n8n runs these noDataExpression fields. Fields that were already invalid before patching, pythonCode, and code over 1MB are not checked. The parse runs on the MCP server\'s Node.js: in the rare case it rejects newer syntax your n8n runtime accepts, set the full field value via updateNode instead (not guarded)',
'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
63 changes: 61 additions & 2 deletions src/services/workflow-diff-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,61 @@ 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;

// Parsing is synchronous on the event loop; a 60 MiB body costs ~1s. Real
// Code-node sources are kilobytes — beyond this the guard steps aside rather
// than become a DoS lever (Codex review on #1014).
const MAX_SYNTAX_CHECKED_LENGTH = 1_000_000;

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

// jsCode/functionCode are noDataExpression fields: n8n strips one leading "="
// before executing them (node-helpers), so an "=" value is NOT an expression
// there — parse what will actually run (Codex review on #1014).
function stripExpressionPrefix(value: string): string {
return value.startsWith('=') ? value.slice(1) : value;
}

/**
* 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. 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;

const syntaxError = jsSyntaxErrorOf(stripExpressionPrefix(patched));
if (!syntaxError) return;
if (jsSyntaxErrorOf(stripExpressionPrefix(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 +1130,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 +1144,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 +1212,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 +1263,7 @@ export class WorkflowDiffEngine {
}
}

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

// Sanitize node after updates
Expand Down
Loading
Loading