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.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` and non-code fields are not checked, and the check parses only — it never executes the code. Code over 1MB is never parsed at all, so oversized input cannot stall the server; a patch that would turn a verifiably-valid field into something the guard cannot verify (oversized, or nesting beyond the parser's reach) is rejected with a pointer to `updateNode`, which sets the full value unchecked. When the baseline itself is broken or unverifiable, the guard steps aside. 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 and pythonCode are not checked; code over 1MB is never parsed, and patching valid code into something unverifiable is rejected — set the full value via updateNode (unchecked) if that is intended. 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
85 changes: 83 additions & 2 deletions src/services/workflow-diff-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,83 @@ 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 code is never parsed,
// so oversized input cannot become a DoS lever (Codex review on #1014).
const MAX_SYNTAX_CHECKED_LENGTH = 1_000_000;

type JsSyntaxCheck =
| { status: 'valid' }
| { status: 'invalid'; message: string }
// Could not judge the code either way: oversized, or the parser gave up
// with a non-SyntaxError (a CSP EvalError, a RangeError from pathological
// nesting). Distinct from 'valid' so a checkably-valid field cannot be
// patched into an unverifiable blob unnoticed (Codex review on #1014).
| { status: 'uncheckable'; reason: string };

function checkJsSyntax(code: string): JsSyntaxCheck {
if (code.length > MAX_SYNTAX_CHECKED_LENGTH) {
return { status: 'uncheckable', reason: `code exceeds ${MAX_SYNTAX_CHECKED_LENGTH} characters` };
}
try {
new AsyncFunctionCtor(code);
return { status: 'valid' };
} catch (error) {
if (error instanceof SyntaxError) return { status: 'invalid', message: error.message };
return { status: 'uncheckable', reason: `the parser gave up (${error instanceof Error ? error.name : 'unknown error'})` };
}
}

// 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 patchedCheck = checkJsSyntax(stripExpressionPrefix(patched));
if (patchedCheck.status === 'valid') return;

// The gate: judge only against a baseline we could actually judge. A field
// that was already invalid stays patchable (incremental repair), and an
// uncheckable baseline gives no standard to hold the patch to.
if (checkJsSyntax(stripExpressionPrefix(original)).status !== 'valid') return;

if (patchedCheck.status === 'invalid') {
throw new Error(
`${operation}: patches would leave "${fieldPath}" with invalid JavaScript (${patchedCheck.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.`
);
}

throw new Error(
`${operation}: could not verify the JavaScript syntax of "${fieldPath}" after patching (${patchedCheck.reason}). ` +
`The workflow was not modified. To set the field anyway, replace its full value with an updateNode operation, ` +
`which is not syntax-checked.`
);
}

function operationReferencesAddedNode(
operation: WorkflowDiffOperation,
addedNode: AddNodeOperation['node']
Expand Down Expand Up @@ -1075,7 +1152,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 +1166,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 +1234,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 +1285,7 @@ export class WorkflowDiffEngine {
}
}

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

// Sanitize node after updates
Expand Down
Loading
Loading