@pagelines/n8n-mcp 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.1] - 2025-01-13
6
+
7
+ ### Fixed
8
+ - **Critical bug**: `workflow_update`, `workflow_format`, and `workflow_autofix` failing with "request/body must NOT have additional properties" error
9
+ - Root cause: n8n API returns additional read-only properties that were being sent back on PUT requests
10
+ - Solution: Schema-driven field filtering using `N8N_WORKFLOW_WRITABLE_FIELDS` allowlist (source of truth: n8n OpenAPI spec at `/api/v1/openapi.yml`)
11
+
12
+ ### Changed
13
+ - Refactored `updateWorkflow` to use schema-driven approach instead of property denylist
14
+ - Added `pickFields` generic utility for type-safe field filtering
15
+ - Added comprehensive tests for schema-driven filtering
16
+
5
17
  ## [0.3.0] - 2025-01-13
6
18
 
7
19
  ### Added
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ initVersionControl({
37
37
  // ─────────────────────────────────────────────────────────────
38
38
  const server = new Server({
39
39
  name: '@pagelines/n8n-mcp',
40
- version: '0.3.0',
40
+ version: '0.3.1',
41
41
  }, {
42
42
  capabilities: {
43
43
  tools: {},
@@ -2,7 +2,7 @@
2
2
  * n8n REST API Client
3
3
  * Clean, minimal implementation with built-in safety checks
4
4
  */
5
- import type { N8nWorkflow, N8nWorkflowListItem, N8nExecution, N8nExecutionListItem, N8nListResponse, N8nNode, N8nNodeType, PatchOperation } from './types.js';
5
+ import { type N8nWorkflow, type N8nWorkflowListItem, type N8nExecution, type N8nExecutionListItem, type N8nListResponse, type N8nNode, type N8nNodeType, type PatchOperation } from './types.js';
6
6
  export interface N8nClientConfig {
7
7
  apiUrl: string;
8
8
  apiKey: string;
@@ -2,6 +2,7 @@
2
2
  * n8n REST API Client
3
3
  * Clean, minimal implementation with built-in safety checks
4
4
  */
5
+ import { N8N_WORKFLOW_WRITABLE_FIELDS, pickFields, } from './types.js';
5
6
  export class N8nClient {
6
7
  baseUrl;
7
8
  headers;
@@ -56,8 +57,8 @@ export class N8nClient {
56
57
  return this.request('POST', '/api/v1/workflows', workflow);
57
58
  }
58
59
  async updateWorkflow(id, workflow) {
59
- // Strip properties that n8n API doesn't accept on PUT
60
- const { id: _id, createdAt, updatedAt, active, versionId, ...allowed } = workflow;
60
+ // Schema-driven: only send fields n8n accepts (defined in types.ts)
61
+ const allowed = pickFields(workflow, N8N_WORKFLOW_WRITABLE_FIELDS);
61
62
  return this.request('PUT', `/api/v1/workflows/${id}`, allowed);
62
63
  }
63
64
  async deleteWorkflow(id) {
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { N8nClient } from './n8n-client.js';
3
+ import { N8N_WORKFLOW_WRITABLE_FIELDS, pickFields } from './types.js';
3
4
  // Mock fetch globally
4
5
  const mockFetch = vi.fn();
5
6
  global.fetch = mockFetch;
@@ -283,13 +284,99 @@ describe('N8nClient', () => {
283
284
  await client.updateWorkflow('zbB1fCxWgZXgpjB1', formattedWorkflow);
284
285
  const putCall = mockFetch.mock.calls[0];
285
286
  const putBody = JSON.parse(putCall[1].body);
286
- // Critical: these must NOT be in the request body
287
+ // Only writable fields should be sent (schema-driven from N8N_WORKFLOW_WRITABLE_FIELDS)
288
+ const sentKeys = Object.keys(putBody).sort();
289
+ const expectedKeys = ['connections', 'name', 'nodes']; // Only non-undefined writable fields
290
+ expect(sentKeys).toEqual(expectedKeys);
291
+ // Read-only fields must NOT be in request
287
292
  expect(putBody.id).toBeUndefined();
288
293
  expect(putBody.createdAt).toBeUndefined();
289
294
  expect(putBody.updatedAt).toBeUndefined();
290
295
  expect(putBody.active).toBeUndefined();
291
- // Only allowed properties should be sent
292
- expect(Object.keys(putBody).sort()).toEqual(['connections', 'name', 'nodes']);
293
296
  });
297
+ it('filters out any unknown properties using schema-driven approach', async () => {
298
+ // Real n8n API returns many properties not in our type definition
299
+ // Schema-driven filtering ensures only N8N_WORKFLOW_WRITABLE_FIELDS are sent
300
+ const realN8nWorkflow = {
301
+ id: '123',
302
+ name: 'test_workflow',
303
+ active: true,
304
+ nodes: [{ id: 'n1', name: 'node1', type: 'test', typeVersion: 1, position: [0, 0], parameters: {} }],
305
+ connections: {},
306
+ settings: { timezone: 'UTC' },
307
+ staticData: { lastId: 5 },
308
+ tags: [{ id: 't1', name: 'production' }],
309
+ createdAt: '2024-01-01T00:00:00.000Z',
310
+ updatedAt: '2024-01-02T00:00:00.000Z',
311
+ versionId: 'v1',
312
+ // Properties that real n8n returns but aren't in writable fields:
313
+ homeProject: { id: 'proj1', type: 'personal', name: 'My Project' },
314
+ sharedWithProjects: [],
315
+ usedCredentials: [{ id: 'cred1', name: 'My API Key', type: 'apiKey' }],
316
+ meta: { instanceId: 'abc123' },
317
+ pinData: {},
318
+ triggerCount: 5,
319
+ unknownFutureField: 'whatever',
320
+ };
321
+ mockFetch.mockResolvedValueOnce({
322
+ ok: true,
323
+ text: async () => JSON.stringify(realN8nWorkflow),
324
+ });
325
+ await client.updateWorkflow('123', realN8nWorkflow);
326
+ const putCall = mockFetch.mock.calls[0];
327
+ const putBody = JSON.parse(putCall[1].body);
328
+ // Request should ONLY contain fields from N8N_WORKFLOW_WRITABLE_FIELDS
329
+ const sentKeys = Object.keys(putBody).sort();
330
+ const allowedKeys = [...N8N_WORKFLOW_WRITABLE_FIELDS].sort();
331
+ // Every sent key must be in the allowed list
332
+ for (const key of sentKeys) {
333
+ expect(allowedKeys).toContain(key);
334
+ }
335
+ // Verify exact expected keys (all writable fields that had values)
336
+ expect(sentKeys).toEqual(['connections', 'name', 'nodes', 'settings', 'staticData', 'tags']);
337
+ });
338
+ });
339
+ });
340
+ // ─────────────────────────────────────────────────────────────
341
+ // Schema utilities (types.ts)
342
+ // ─────────────────────────────────────────────────────────────
343
+ describe('pickFields utility', () => {
344
+ it('picks only specified fields', () => {
345
+ const obj = { a: 1, b: 2, c: 3, d: 4 };
346
+ const result = pickFields(obj, ['a', 'c']);
347
+ expect(result).toEqual({ a: 1, c: 3 });
348
+ expect(Object.keys(result)).toEqual(['a', 'c']);
349
+ });
350
+ it('ignores undefined values', () => {
351
+ const obj = { a: 1, b: undefined, c: 3 };
352
+ const result = pickFields(obj, ['a', 'b', 'c']);
353
+ expect(result).toEqual({ a: 1, c: 3 });
354
+ expect('b' in result).toBe(false);
355
+ });
356
+ it('ignores fields not in object', () => {
357
+ const obj = { a: 1 };
358
+ const result = pickFields(obj, ['a', 'missing']);
359
+ expect(result).toEqual({ a: 1 });
360
+ });
361
+ it('returns empty object for empty fields array', () => {
362
+ const obj = { a: 1, b: 2 };
363
+ const result = pickFields(obj, []);
364
+ expect(result).toEqual({});
365
+ });
366
+ });
367
+ describe('N8N_WORKFLOW_WRITABLE_FIELDS schema', () => {
368
+ it('contains expected writable fields', () => {
369
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('name');
370
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('nodes');
371
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('connections');
372
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('settings');
373
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('staticData');
374
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('tags');
375
+ });
376
+ it('does NOT contain read-only fields', () => {
377
+ const readOnlyFields = ['id', 'active', 'createdAt', 'updatedAt', 'versionId'];
378
+ for (const field of readOnlyFields) {
379
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).not.toContain(field);
380
+ }
294
381
  });
295
382
  });
package/dist/types.d.ts CHANGED
@@ -130,6 +130,23 @@ export interface N8nListResponse<T> {
130
130
  data: T[];
131
131
  nextCursor?: string;
132
132
  }
133
+ /**
134
+ * Fields that n8n API accepts on PUT /workflows/:id
135
+ * All other fields (id, createdAt, homeProject, etc.) are read-only
136
+ *
137
+ * Derived from: n8n OpenAPI spec (GET {n8n-url}/api/v1/openapi.yml)
138
+ * Path: /workflows/{id} → put → requestBody → content → application/json → schema
139
+ *
140
+ * If n8n adds new writable fields, check the OpenAPI spec and update this array.
141
+ */
142
+ export declare const N8N_WORKFLOW_WRITABLE_FIELDS: readonly ["name", "nodes", "connections", "settings", "staticData", "tags"];
143
+ export type N8nWorkflowWritableField = (typeof N8N_WORKFLOW_WRITABLE_FIELDS)[number];
144
+ export type N8nWorkflowUpdate = Pick<N8nWorkflow, N8nWorkflowWritableField>;
145
+ /**
146
+ * Pick only specified fields from an object (strips everything else)
147
+ * Generic utility for schema-driven field filtering
148
+ */
149
+ export declare function pickFields<T, K extends keyof T>(obj: T, fields: readonly K[]): Pick<T, K>;
133
150
  export interface N8nNodeType {
134
151
  name: string;
135
152
  displayName: string;
package/dist/types.js CHANGED
@@ -2,4 +2,37 @@
2
2
  * n8n API Types
3
3
  * Minimal types for workflow management
4
4
  */
5
- export {};
5
+ // ─────────────────────────────────────────────────────────────
6
+ // Schema-driven field definitions
7
+ // Source of truth: n8n OpenAPI spec at /api/v1/openapi.yml
8
+ // ─────────────────────────────────────────────────────────────
9
+ /**
10
+ * Fields that n8n API accepts on PUT /workflows/:id
11
+ * All other fields (id, createdAt, homeProject, etc.) are read-only
12
+ *
13
+ * Derived from: n8n OpenAPI spec (GET {n8n-url}/api/v1/openapi.yml)
14
+ * Path: /workflows/{id} → put → requestBody → content → application/json → schema
15
+ *
16
+ * If n8n adds new writable fields, check the OpenAPI spec and update this array.
17
+ */
18
+ export const N8N_WORKFLOW_WRITABLE_FIELDS = [
19
+ 'name',
20
+ 'nodes',
21
+ 'connections',
22
+ 'settings',
23
+ 'staticData',
24
+ 'tags',
25
+ ];
26
+ /**
27
+ * Pick only specified fields from an object (strips everything else)
28
+ * Generic utility for schema-driven field filtering
29
+ */
30
+ export function pickFields(obj, fields) {
31
+ const result = {};
32
+ for (const field of fields) {
33
+ if (field in obj && obj[field] !== undefined) {
34
+ result[field] = obj[field];
35
+ }
36
+ }
37
+ return result;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagelines/n8n-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Opinionated MCP server for n8n workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -64,7 +64,7 @@ initVersionControl({
64
64
  const server = new Server(
65
65
  {
66
66
  name: '@pagelines/n8n-mcp',
67
- version: '0.3.0',
67
+ version: '0.3.1',
68
68
  },
69
69
  {
70
70
  capabilities: {
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { N8nClient } from './n8n-client.js';
3
+ import { N8N_WORKFLOW_WRITABLE_FIELDS, pickFields } from './types.js';
3
4
 
4
5
  // Mock fetch globally
5
6
  const mockFetch = vi.fn();
@@ -349,14 +350,118 @@ describe('N8nClient', () => {
349
350
  const putCall = mockFetch.mock.calls[0];
350
351
  const putBody = JSON.parse(putCall[1].body);
351
352
 
352
- // Critical: these must NOT be in the request body
353
+ // Only writable fields should be sent (schema-driven from N8N_WORKFLOW_WRITABLE_FIELDS)
354
+ const sentKeys = Object.keys(putBody).sort();
355
+ const expectedKeys = ['connections', 'name', 'nodes']; // Only non-undefined writable fields
356
+ expect(sentKeys).toEqual(expectedKeys);
357
+
358
+ // Read-only fields must NOT be in request
353
359
  expect(putBody.id).toBeUndefined();
354
360
  expect(putBody.createdAt).toBeUndefined();
355
361
  expect(putBody.updatedAt).toBeUndefined();
356
362
  expect(putBody.active).toBeUndefined();
363
+ });
364
+
365
+ it('filters out any unknown properties using schema-driven approach', async () => {
366
+ // Real n8n API returns many properties not in our type definition
367
+ // Schema-driven filtering ensures only N8N_WORKFLOW_WRITABLE_FIELDS are sent
368
+ const realN8nWorkflow = {
369
+ id: '123',
370
+ name: 'test_workflow',
371
+ active: true,
372
+ nodes: [{ id: 'n1', name: 'node1', type: 'test', typeVersion: 1, position: [0, 0] as [number, number], parameters: {} }],
373
+ connections: {},
374
+ settings: { timezone: 'UTC' },
375
+ staticData: { lastId: 5 },
376
+ tags: [{ id: 't1', name: 'production' }],
377
+ createdAt: '2024-01-01T00:00:00.000Z',
378
+ updatedAt: '2024-01-02T00:00:00.000Z',
379
+ versionId: 'v1',
380
+ // Properties that real n8n returns but aren't in writable fields:
381
+ homeProject: { id: 'proj1', type: 'personal', name: 'My Project' },
382
+ sharedWithProjects: [],
383
+ usedCredentials: [{ id: 'cred1', name: 'My API Key', type: 'apiKey' }],
384
+ meta: { instanceId: 'abc123' },
385
+ pinData: {},
386
+ triggerCount: 5,
387
+ unknownFutureField: 'whatever',
388
+ };
389
+
390
+ mockFetch.mockResolvedValueOnce({
391
+ ok: true,
392
+ text: async () => JSON.stringify(realN8nWorkflow),
393
+ });
394
+
395
+ await client.updateWorkflow('123', realN8nWorkflow as any);
396
+
397
+ const putCall = mockFetch.mock.calls[0];
398
+ const putBody = JSON.parse(putCall[1].body);
357
399
 
358
- // Only allowed properties should be sent
359
- expect(Object.keys(putBody).sort()).toEqual(['connections', 'name', 'nodes']);
400
+ // Request should ONLY contain fields from N8N_WORKFLOW_WRITABLE_FIELDS
401
+ const sentKeys = Object.keys(putBody).sort();
402
+ const allowedKeys = [...N8N_WORKFLOW_WRITABLE_FIELDS].sort();
403
+
404
+ // Every sent key must be in the allowed list
405
+ for (const key of sentKeys) {
406
+ expect(allowedKeys).toContain(key);
407
+ }
408
+
409
+ // Verify exact expected keys (all writable fields that had values)
410
+ expect(sentKeys).toEqual(['connections', 'name', 'nodes', 'settings', 'staticData', 'tags']);
360
411
  });
361
412
  });
362
413
  });
414
+
415
+ // ─────────────────────────────────────────────────────────────
416
+ // Schema utilities (types.ts)
417
+ // ─────────────────────────────────────────────────────────────
418
+
419
+ describe('pickFields utility', () => {
420
+ it('picks only specified fields', () => {
421
+ const obj = { a: 1, b: 2, c: 3, d: 4 };
422
+ const result = pickFields(obj, ['a', 'c'] as const);
423
+
424
+ expect(result).toEqual({ a: 1, c: 3 });
425
+ expect(Object.keys(result)).toEqual(['a', 'c']);
426
+ });
427
+
428
+ it('ignores undefined values', () => {
429
+ const obj = { a: 1, b: undefined, c: 3 };
430
+ const result = pickFields(obj, ['a', 'b', 'c'] as const);
431
+
432
+ expect(result).toEqual({ a: 1, c: 3 });
433
+ expect('b' in result).toBe(false);
434
+ });
435
+
436
+ it('ignores fields not in object', () => {
437
+ const obj = { a: 1 };
438
+ const result = pickFields(obj as any, ['a', 'missing'] as const);
439
+
440
+ expect(result).toEqual({ a: 1 });
441
+ });
442
+
443
+ it('returns empty object for empty fields array', () => {
444
+ const obj = { a: 1, b: 2 };
445
+ const result = pickFields(obj, [] as const);
446
+
447
+ expect(result).toEqual({});
448
+ });
449
+ });
450
+
451
+ describe('N8N_WORKFLOW_WRITABLE_FIELDS schema', () => {
452
+ it('contains expected writable fields', () => {
453
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('name');
454
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('nodes');
455
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('connections');
456
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('settings');
457
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('staticData');
458
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).toContain('tags');
459
+ });
460
+
461
+ it('does NOT contain read-only fields', () => {
462
+ const readOnlyFields = ['id', 'active', 'createdAt', 'updatedAt', 'versionId'];
463
+ for (const field of readOnlyFields) {
464
+ expect(N8N_WORKFLOW_WRITABLE_FIELDS).not.toContain(field);
465
+ }
466
+ });
467
+ });
package/src/n8n-client.ts CHANGED
@@ -3,15 +3,17 @@
3
3
  * Clean, minimal implementation with built-in safety checks
4
4
  */
5
5
 
6
- import type {
7
- N8nWorkflow,
8
- N8nWorkflowListItem,
9
- N8nExecution,
10
- N8nExecutionListItem,
11
- N8nListResponse,
12
- N8nNode,
13
- N8nNodeType,
14
- PatchOperation,
6
+ import {
7
+ N8N_WORKFLOW_WRITABLE_FIELDS,
8
+ pickFields,
9
+ type N8nWorkflow,
10
+ type N8nWorkflowListItem,
11
+ type N8nExecution,
12
+ type N8nExecutionListItem,
13
+ type N8nListResponse,
14
+ type N8nNode,
15
+ type N8nNodeType,
16
+ type PatchOperation,
15
17
  } from './types.js';
16
18
 
17
19
  export interface N8nClientConfig {
@@ -98,8 +100,8 @@ export class N8nClient {
98
100
  id: string,
99
101
  workflow: Partial<N8nWorkflow>
100
102
  ): Promise<N8nWorkflow> {
101
- // Strip properties that n8n API doesn't accept on PUT
102
- const { id: _id, createdAt, updatedAt, active, versionId, ...allowed } = workflow as any;
103
+ // Schema-driven: only send fields n8n accepts (defined in types.ts)
104
+ const allowed = pickFields(workflow, N8N_WORKFLOW_WRITABLE_FIELDS);
103
105
  return this.request('PUT', `/api/v1/workflows/${id}`, allowed);
104
106
  }
105
107
 
package/src/types.ts CHANGED
@@ -106,6 +106,49 @@ export interface N8nListResponse<T> {
106
106
  nextCursor?: string;
107
107
  }
108
108
 
109
+ // ─────────────────────────────────────────────────────────────
110
+ // Schema-driven field definitions
111
+ // Source of truth: n8n OpenAPI spec at /api/v1/openapi.yml
112
+ // ─────────────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Fields that n8n API accepts on PUT /workflows/:id
116
+ * All other fields (id, createdAt, homeProject, etc.) are read-only
117
+ *
118
+ * Derived from: n8n OpenAPI spec (GET {n8n-url}/api/v1/openapi.yml)
119
+ * Path: /workflows/{id} → put → requestBody → content → application/json → schema
120
+ *
121
+ * If n8n adds new writable fields, check the OpenAPI spec and update this array.
122
+ */
123
+ export const N8N_WORKFLOW_WRITABLE_FIELDS = [
124
+ 'name',
125
+ 'nodes',
126
+ 'connections',
127
+ 'settings',
128
+ 'staticData',
129
+ 'tags',
130
+ ] as const;
131
+
132
+ export type N8nWorkflowWritableField = (typeof N8N_WORKFLOW_WRITABLE_FIELDS)[number];
133
+ export type N8nWorkflowUpdate = Pick<N8nWorkflow, N8nWorkflowWritableField>;
134
+
135
+ /**
136
+ * Pick only specified fields from an object (strips everything else)
137
+ * Generic utility for schema-driven field filtering
138
+ */
139
+ export function pickFields<T, K extends keyof T>(
140
+ obj: T,
141
+ fields: readonly K[]
142
+ ): Pick<T, K> {
143
+ const result = {} as Pick<T, K>;
144
+ for (const field of fields) {
145
+ if (field in (obj as object) && obj[field] !== undefined) {
146
+ result[field] = obj[field];
147
+ }
148
+ }
149
+ return result;
150
+ }
151
+
109
152
  // Node type information from n8n API (GET /api/v1/nodes)
110
153
  export interface N8nNodeType {
111
154
  name: string; // e.g., "n8n-nodes-base.webhook"