Skip to content

Commit 2c1ec73

Browse files
czlonkowskiclaude
andauthored
fix: strip settings.engineType so workflow updates survive n8n >= 2.36.0 (v2.76.1) (#1044)
* fix: strip settings.engineType so workflow updates survive n8n >= 2.36.0 (v2.76.1) n8n 2.36.0 added engineType to the workflow entity's persisted settings without adding it to the Public API write schema, which is additionalProperties: false. Our updates read-modify-write, so GET handed us the property and the echoed PUT failed with "settings must NOT have additional properties" (#1043). engineType is now marked derived and stripped from every create and update, like binaryMode before it; n8n keeps stored settings for omitted keys, so the value is preserved. check-settings-drift gains the axis that was blind to this class: it diffs IWorkflowSettings from the installed n8n-workflow package against the OpenAPI schema, fails on an unhandled entity-only property, and fails again when n8n later publishes a stripped property to the schema. The entity parser fails loudly on extends/declaration merging, and the classification gate is a pure function with direct tests plus an offline CI assertion that every entity property is known to the table. Verified end-to-end against n8n 2.36.7: a workflow with settings.engineType = "v2" injected updates successfully through the fixed server, and the setting survives the write unchanged. Fixes #1043 Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXDW1LGvRXaQydK21X89sj * fix: harden the entity axis of check-settings-drift (review round 2) Codex and Copilot findings on the new drift axis, all adopted: - Verify the fetched schema's n8n release actually ships the installed n8n-workflow (exact pin in its package.json) and warn on a mismatch - the nodes-base pin names a sibling release, not the meta-package. - An entity-only property is handled only when marked BOTH derived and entityOnly, and the remediation message says so - derived alone would leave the published-upstream detector disarmed. - The entity parser strips block comments (a declaration-shaped line or stray brace inside one no longer derails it) and throws when content shares the opening-brace line instead of skipping it silently. - Without an entity set, a derived property from a later n8n than the target is classified ahead-of-the-pin, not "entity-only, expected". - An unhandled entity-only property is no longer double-reported as expected by the info line. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXDW1LGvRXaQydK21X89sj * fix: resolve the true n8n release and parse entity types with the TS AST (review round 3) - The schema is now fetched from the n8n release whose published pins match the installed packages, found by scanning the newest releases' package.json pins (nodes-base@2.36.4 ships in n8n@2.36.7, while n8n@2.36.4 pins nodes-base@2.36.3 - the same-number mapping read a neighbouring release's schema). Falls back to the old mapping with a pin warning when the registry is unreachable or nothing matches. - The entity parser is the real TypeScript parser instead of a hand-rolled lexer: line comments, string literal types, inline braces and split declarations no longer silently truncate the property set, and any member it cannot enumerate (index signature, method, computed name) fails closed. - Restore the 2.76.0 changelog header that the 2.76.1 entry accidentally swallowed, and bump package.runtime.json to 2.76.1 (both Copilot). Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXDW1LGvRXaQydK21X89sj * fix: fail closed on partial parses and widen the skew warning (review round 4) - Reject any syntactic diagnostic before walking the AST: createSourceFile recovers from errors, so a truncated declarations file would otherwise yield a partial property set that reads as "no entity-only properties". The internal parseDiagnostics field disappearing also throws. - Collect only top-level IWorkflowSettings declarations - a same-named interface inside a namespace does not merge with the export. - The residual-skew warning now covers both pins (the fallback release can match n8n-workflow while shipping a different n8n-nodes-base) and the pins-unfetchable case. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXDW1LGvRXaQydK21X89sj * fix: compare release pins against installed versions, not declared ones (review round 5) The skew warning and release resolution read the installed n8n-nodes-base version from node_modules instead of the package.json pin, so a stale install cannot pair this run's entity types with a schema neither belongs to; an unreadable install is itself reported. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXDW1LGvRXaQydK21X89sj * fix: read the installed n8n-workflow version via the package.json subpath (Copilot round 3) Consistent with installedNodesBaseVersion; the resolved-entry path walk stays as a fallback for a future exports map that blocks the subpath. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXDW1LGvRXaQydK21X89sj --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f895e5e commit 2c1ec73

9 files changed

Lines changed: 689 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.76.1] - 2026-08-31
11+
12+
### Fixed
13+
14+
- **Workflow updates no longer fail with `settings must NOT have additional properties` against n8n ≥ 2.36.0** ([#1043](https://github.com/czlonkowski/n8n-mcp/issues/1043)). n8n 2.36.0 added `engineType` to the workflow's persisted settings without adding it to the Public API write schema, so `n8n_update_partial_workflow` and `n8n_update_full_workflow` — which read the workflow, apply the change and write it back — echoed the property into a `PUT` the schema rejects. `engineType` is now stripped from every create and update payload, like `binaryMode` and `credentialResolverId` before it. Stripping does not change the setting on the instance: n8n keeps stored settings for keys the request omits.
15+
16+
### Changed
17+
18+
- `npm run check:settings-drift` now also diffs n8n's workflow entity settings (`IWorkflowSettings` from the installed `n8n-workflow` package) against the Public API schema. A property n8n persists but the write schema rejects — the exact shape of #1043, invisible to the schema-only check — now fails the n8n dependency update until it is marked as stripped.
19+
1020
## [2.76.0] - 2026-08-28
1121

1222
### Added

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-mcp",
3-
"version": "2.76.0",
3+
"version": "2.76.1",
44
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

package.runtime.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-mcp-runtime",
3-
"version": "2.76.0",
3+
"version": "2.76.1",
44
"description": "n8n MCP Server Runtime Dependencies Only",
55
"private": true,
66
"dependencies": {

scripts/check-settings-drift.ts

Lines changed: 345 additions & 9 deletions
Large diffs are not rendered by default.

src/constants/workflow-settings.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,25 @@ export interface SettingsVersion {
3030
export interface WorkflowSettingProperty {
3131
/**
3232
* First n8n version whose Public API schema accepted this property. `0.0.0` means it predates
33-
* every version we filter for.
33+
* every version we filter for. For a {@link derived} property the schema may never accept it -
34+
* there it records the first version whose GET responses can carry the property.
3435
*/
3536
since: SettingsVersion;
3637
/**
37-
* n8n derives this server-side: it documents the property as ignored on create and update but
38-
* still echoes it back on GET. Our writes merge over a GET, so these are always stripped -
39-
* sending them back changes nothing on the instance that produced them and rejects the whole
40-
* request on an older one.
38+
* n8n manages this property server-side and does not take it from a write. Two flavours:
39+
* the schema documents it as ignored on create and update (`binaryMode`), or the property is
40+
* persisted on the workflow entity but missing from the write schema entirely (`engineType`),
41+
* where `additionalProperties: false` rejects the whole request. GET echoes both back, and our
42+
* writes merge over a GET, so these are always stripped - sending them back changes nothing on
43+
* the instance that produced them and rejects the request on one that doesn't accept them.
4144
*/
4245
derived?: true;
46+
/**
47+
* The second {@link derived} flavour: persisted on the workflow entity but absent from the
48+
* Public API schema. The drift check fails when n8n later publishes such a property to the
49+
* schema, because stripping then stops being the only option - callers might want to set it.
50+
*/
51+
entityOnly?: true;
4352
}
4453

4554
const v = (major: number, minor: number, patch = 0): SettingsVersion => ({ major, minor, patch });
@@ -69,6 +78,12 @@ export const WORKFLOW_SETTINGS_PROPERTIES: Record<string, WorkflowSettingPropert
6978
binaryMode: { since: v(2, 33, 0), derived: true },
7079
timeSavedMode: { since: v(2, 33, 0) },
7180
credentialResolverId: { since: v(2, 33, 0), derived: true },
81+
82+
// n8n 2.36.0 (n8n-io/n8n#36428): persisted on the workflow entity (the engine-v2 dispatcher
83+
// reads settings.engineType === 'v2') but absent from the Public API write schema, so echoing
84+
// back what GET returned rejects the whole write. Stripping is lossless: WorkflowService.update
85+
// spreads stored settings under the request body, so an omitted key is preserved, not cleared.
86+
engineType: { since: v(2, 36, 0), derived: true, entityOnly: true },
7287
};
7388

7489
/**

tests/unit/scripts/check-settings-drift.test.ts

Lines changed: 291 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
import { readFileSync } from 'fs';
2+
import { dirname, join } from 'path';
13
import { describe, it, expect } from 'vitest';
2-
import { parseSchemaProperties } from '../../../scripts/check-settings-drift';
4+
import {
5+
diffSettingsProperties,
6+
parseEntitySettingsProperties,
7+
parseSchemaProperties,
8+
} from '../../../scripts/check-settings-drift';
9+
import { WORKFLOW_SETTINGS_PROPERTIES } from '../../../src/constants/workflow-settings';
310

411
/**
512
* The drift check reads n8n's OpenAPI schema with a small hand-rolled parser rather than a YAML
@@ -75,3 +82,286 @@ describe('check-settings-drift parseSchemaProperties', () => {
7582
expect(() => parseSchemaProperties('<!doctype html><html>404</html>')).toThrow();
7683
});
7784
});
85+
86+
/**
87+
* The entity parser reads IWorkflowSettings out of n8n-workflow's type declarations. It exists
88+
* because the schema-only diff is blind to properties n8n persists but never published to the
89+
* Public API schema - engineType broke every workflow update that way (issue #1043). Same
90+
* contract as the schema parser: malformed input must throw, never yield an empty set.
91+
*/
92+
describe('check-settings-drift parseEntitySettingsProperties', () => {
93+
it('reads the property names of the IWorkflowSettings interface', () => {
94+
const dts = [
95+
'export interface ISomethingElse {',
96+
' unrelated?: string;',
97+
'}',
98+
'export interface IWorkflowSettings {',
99+
" timezone?: 'DEFAULT' | string;",
100+
" engineType?: 'v1' | 'v2';",
101+
' customTelemetryTags?: ICustomTelemetryTag[];',
102+
'}',
103+
'export interface WorkflowFEMeta {',
104+
' onboardingId?: string;',
105+
'}',
106+
].join('\n');
107+
108+
expect([...parseEntitySettingsProperties(dts)]).toEqual([
109+
'timezone',
110+
'engineType',
111+
'customTelemetryTags',
112+
]);
113+
});
114+
115+
it('registers a nested object property without leaking its members', () => {
116+
const dts = [
117+
'export interface IWorkflowSettings {',
118+
' executionTimeout?: number;',
119+
' someNested?: {',
120+
' inner?: string;',
121+
' };',
122+
'}',
123+
].join('\n');
124+
125+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['executionTimeout', 'someNested']);
126+
});
127+
128+
it('throws when n8n renames the interface', () => {
129+
const dts = 'export interface IWorkflowConfig {\n timezone?: string;\n}';
130+
expect(() => parseEntitySettingsProperties(dts)).toThrow(/IWorkflowSettings/);
131+
});
132+
133+
it('throws rather than reporting an empty property set', () => {
134+
const dts = 'export interface IWorkflowSettings {\n}';
135+
expect(() => parseEntitySettingsProperties(dts)).toThrow(/zero properties/);
136+
});
137+
138+
it('throws when the interface extends a base type instead of missing inherited properties', () => {
139+
const dts = [
140+
'export interface IWorkflowSettings extends IBaseSettings {',
141+
' timezone?: string;',
142+
'}',
143+
].join('\n');
144+
expect(() => parseEntitySettingsProperties(dts)).toThrow(/extends/);
145+
});
146+
147+
it('merges split declarations instead of reading only the first block', () => {
148+
const dts = [
149+
'export interface IWorkflowSettings {',
150+
' timezone?: string;',
151+
'}',
152+
'export interface IWorkflowSettings {',
153+
' engineType?: string;',
154+
'}',
155+
].join('\n');
156+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['timezone', 'engineType']);
157+
});
158+
159+
it('does not mistake a comment mentioning the interface for its declaration', () => {
160+
const dts = [
161+
'// The shape of interface IWorkflowSettings mirrors the schema',
162+
'export interface IWorkflowSettings {',
163+
' timezone?: string;',
164+
'}',
165+
].join('\n');
166+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['timezone']);
167+
});
168+
169+
it('ignores a declaration-shaped line inside a block comment', () => {
170+
const dts = [
171+
'/*',
172+
'export interface IWorkflowSettings {',
173+
' ghost?: string;',
174+
'}',
175+
'*/',
176+
'export interface IWorkflowSettings {',
177+
' timezone?: string;',
178+
'}',
179+
].join('\n');
180+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['timezone']);
181+
});
182+
183+
it('is not derailed by an unbalanced brace inside a block comment', () => {
184+
const dts = [
185+
'export interface IWorkflowSettings {',
186+
' /* weird note: { */',
187+
' timezone?: string;',
188+
'}',
189+
].join('\n');
190+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['timezone']);
191+
});
192+
193+
it('reads a property that shares the opening-brace line instead of skipping it', () => {
194+
const dts = 'export interface IWorkflowSettings { engineType?: string;\n timezone?: string;\n}';
195+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['engineType', 'timezone']);
196+
});
197+
198+
it('is not derailed by braces inside line comments or string literal types', () => {
199+
const dts = [
200+
'export interface IWorkflowSettings {',
201+
' first?: string; // }',
202+
" second?: '{';",
203+
' third?: string;',
204+
'}',
205+
].join('\n');
206+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['first', 'second', 'third']);
207+
});
208+
209+
it('throws on a truncated file instead of returning the partial property set', () => {
210+
const dts = 'export interface IWorkflowSettings {\n timezone?: string;';
211+
expect(() => parseEntitySettingsProperties(dts)).toThrow(/parse cleanly/);
212+
});
213+
214+
it('does not accept a same-named interface nested in a namespace as the target', () => {
215+
const dts = [
216+
'export namespace Other {',
217+
' export interface IWorkflowSettings {',
218+
' ghost?: string;',
219+
' }',
220+
'}',
221+
'export interface IWorkflowSettings {',
222+
' timezone?: string;',
223+
'}',
224+
].join('\n');
225+
expect([...parseEntitySettingsProperties(dts)]).toEqual(['timezone']);
226+
});
227+
228+
it('throws on a member it cannot enumerate rather than skipping it', () => {
229+
const dts = [
230+
'export interface IWorkflowSettings {',
231+
' timezone?: string;',
232+
' [key: string]: unknown;',
233+
'}',
234+
].join('\n');
235+
expect(() => parseEntitySettingsProperties(dts)).toThrow(/cannot enumerate/);
236+
});
237+
238+
it('parses the installed n8n-workflow declarations, which must cover our derived properties', () => {
239+
// Runs against the real package so a reformat of its .d.ts fails here instead of making
240+
// the drift check throw (or worse, quietly agree) during the next n8n update.
241+
const dts = readFileSync(
242+
join(dirname(require.resolve('n8n-workflow')), 'interfaces.d.ts'),
243+
'utf8'
244+
);
245+
246+
const entityProperties = parseEntitySettingsProperties(dts);
247+
expect(entityProperties.has('executionOrder')).toBe(true);
248+
expect(entityProperties.has('engineType')).toBe(true);
249+
250+
// Every property we strip as derived should still exist on the entity - one that vanished
251+
// from n8n entirely is a stale entry this table no longer needs.
252+
for (const [name, meta] of Object.entries(WORKFLOW_SETTINGS_PROPERTIES)) {
253+
if (meta.derived) {
254+
expect(entityProperties.has(name), `${name} is marked derived but not on the entity`).toBe(true);
255+
}
256+
}
257+
258+
// The reverse: every entity property must be in our table. "On the entity but unknown to us"
259+
// is the engineType signature (#1043) - a property GET echoes into our read-modify-write that
260+
// no strip or filter knows about. The full drift check only runs inside `npm run update:n8n`;
261+
// this offline approximation makes the same class fail in CI on any n8n-workflow bump.
262+
for (const name of entityProperties) {
263+
expect(
264+
name in WORKFLOW_SETTINGS_PROPERTIES,
265+
`entity settings property ${name} is missing from WORKFLOW_SETTINGS_PROPERTIES`
266+
).toBe(true);
267+
}
268+
});
269+
});
270+
271+
/**
272+
* The gate itself: which bucket each property lands in decides whether the check fails, so the
273+
* classification is tested directly against the real table rather than only via parsers.
274+
*/
275+
describe('check-settings-drift diffSettingsProperties', () => {
276+
const v236 = { major: 2, minor: 36, patch: 4 };
277+
// The published schema of n8n 2.36 as the table models it: everything except derived-only keys
278+
const schemaOf236 = new Set(
279+
Object.entries(WORKFLOW_SETTINGS_PROPERTIES)
280+
.filter(([, meta]) => !meta.entityOnly)
281+
.map(([name]) => name)
282+
);
283+
const entityOf236 = new Set([...schemaOf236, 'engineType']);
284+
285+
it('reports no drift for a consistent pinned set', () => {
286+
const drift = diffSettingsProperties(schemaOf236, entityOf236, v236);
287+
288+
expect(drift.missing).toEqual([]);
289+
expect(drift.removed).toEqual([]);
290+
expect(drift.unhandledEntityOnly).toEqual([]);
291+
expect(drift.publishedEntityOnly).toEqual([]);
292+
expect(drift.entityOnly).toEqual(['engineType']);
293+
});
294+
295+
it('flags an entity property the schema rejects and the table does not strip', () => {
296+
const entity = new Set([...entityOf236, 'someNewInternalSetting']);
297+
const drift = diffSettingsProperties(schemaOf236, entity, v236);
298+
299+
expect(drift.unhandledEntityOnly).toEqual(['someNewInternalSetting']);
300+
});
301+
302+
it('still flags an entity-only property marked derived without entityOnly (detector must stay armed)', () => {
303+
// binaryMode is derived but not entityOnly. If the schema stopped naming it while the
304+
// entity kept it, derived alone must not count as handled - without entityOnly the
305+
// published-upstream detector would never fire for it.
306+
const schema = new Set(schemaOf236);
307+
schema.delete('binaryMode');
308+
const drift = diffSettingsProperties(schema, entityOf236, v236);
309+
310+
expect(drift.unhandledEntityOnly).toEqual(['binaryMode']);
311+
// And it is not simultaneously soft-reported as expected or stale
312+
expect(drift.entityOnly).toEqual(['engineType']);
313+
expect(drift.removed).toEqual([]);
314+
});
315+
316+
it('flags a stripped entity-only property once n8n publishes it to the schema', () => {
317+
const schema = new Set([...schemaOf236, 'engineType']);
318+
const drift = diffSettingsProperties(schema, entityOf236, v236);
319+
320+
expect(drift.publishedEntityOnly).toEqual(['engineType']);
321+
expect(drift.unhandledEntityOnly).toEqual([]);
322+
expect(drift.entityOnly).toEqual([]);
323+
});
324+
325+
it('flags a new schema property missing from the table', () => {
326+
const schema = new Set([...schemaOf236, 'brandNewSetting']);
327+
const drift = diffSettingsProperties(schema, entityOf236, v236);
328+
329+
expect(drift.missing).toEqual(['brandNewSetting']);
330+
});
331+
332+
it('splits table properties the schema lacks into removed vs ahead by the target version', () => {
333+
const schema = new Set(schemaOf236);
334+
schema.delete('timezone'); // since 0.0.0 - claiming this version has it makes its absence drift
335+
schema.delete('redactionPolicy'); // since 2.26.0 - ahead of a 2.20 target, expected
336+
const entity = new Set([...schema, 'engineType']);
337+
const drift = diffSettingsProperties(schema, entity, { major: 2, minor: 20, patch: 0 });
338+
339+
expect(drift.removed).toEqual(['timezone']);
340+
expect(drift.ahead).toEqual(['redactionPolicy']);
341+
});
342+
343+
it('treats a derived property gone from the entity as well as stale, not entity-only', () => {
344+
const entity = new Set(schemaOf236); // no engineType anywhere any more
345+
const drift = diffSettingsProperties(schemaOf236, entity, v236);
346+
347+
expect(drift.entityOnly).toEqual([]);
348+
expect(drift.removed).toContain('engineType');
349+
});
350+
351+
it('assumes derived properties are entity-only when no entity set is available', () => {
352+
const drift = diffSettingsProperties(schemaOf236, null, v236);
353+
354+
expect(drift.entityOnly).toEqual(['engineType']);
355+
expect(drift.unhandledEntityOnly).toEqual([]);
356+
expect(drift.removed).toEqual([]);
357+
});
358+
359+
it('classifies a derived property from a later n8n as ahead, not entity-only, without an entity set', () => {
360+
// For a 2.20 target, engineType (since 2.36) cannot be on the entity yet - calling it
361+
// "entity-only, expected" would be misleading; it is simply ahead of the pin.
362+
const drift = diffSettingsProperties(schemaOf236, null, { major: 2, minor: 20, patch: 0 });
363+
364+
expect(drift.ahead).toContain('engineType');
365+
expect(drift.entityOnly).toEqual([]);
366+
});
367+
});

0 commit comments

Comments
 (0)