@pagelines/n8n-mcp 0.2.1 → 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.
@@ -1,30 +1,36 @@
1
1
  # Architecture
2
2
 
3
+ **Opinionated** n8n workflow automation. Enforces conventions, blocks mistakes, auto-fixes issues.
4
+
3
5
  ## vs czlonkowski/n8n-mcp
4
6
 
5
7
  | Concern | czlonkowski | @pagelines |
6
8
  |---------|-------------|------------|
9
+ | Philosophy | Permissive | Opinionated |
7
10
  | Node docs | 70MB SQLite, 1084 nodes | None (use Google) |
8
11
  | Templates | 2,709 indexed | None (use n8n.io) |
9
12
  | Update mode | Full replace | Patch (preserves params) |
13
+ | After mutations | Nothing | Auto-validate, auto-fix, format |
14
+ | Invalid node types | API error | Blocked with suggestions |
10
15
  | Version control | Limited | Auto-snapshot, diff, rollback |
11
16
  | Validation | Basic | Rules + expressions + circular refs |
12
- | Auto-fix | No | Yes |
17
+ | Auto-fix | No | Yes (automatic) |
13
18
  | Dependencies | SQLite, heavy | Zero runtime |
14
- | Lines of code | ~10k+ | ~1,200 |
19
+ | Lines of code | ~10k+ | ~1,500 |
15
20
 
16
21
  ## Modules
17
22
 
18
23
  ```
19
24
  src/
20
- ├── index.ts # MCP server, tool dispatch
21
- ├── types.ts # Type definitions
22
- ├── tools.ts # Tool schemas (JSON Schema)
23
- ├── n8n-client.ts # n8n REST API client
24
- ├── validators.ts # Validation rules
25
- ├── expressions.ts # Expression parsing ({{ }})
26
- ├── autofix.ts # Auto-fix transforms
27
- └── versions.ts # Version control (local fs)
25
+ ├── index.ts # MCP server, tool dispatch
26
+ ├── types.ts # Type definitions
27
+ ├── tools.ts # Tool schemas (JSON Schema)
28
+ ├── n8n-client.ts # n8n REST API client
29
+ ├── validators.ts # Validation rules + node type validation
30
+ ├── expressions.ts # Expression parsing ({{ }})
31
+ ├── autofix.ts # Auto-fix transforms
32
+ ├── versions.ts # Version control (local fs)
33
+ └── response-format.ts # Token-efficient response formatting
28
34
  ```
29
35
 
30
36
  ## Data Flow
@@ -39,21 +45,35 @@ Tool Handler
39
45
  ┌─────────────────────────────────┐
40
46
  │ n8n-client validators │
41
47
  │ expressions autofix │
42
- │ versions
48
+ │ versions response-format
43
49
  └─────────────────────────────────┘
44
50
 
45
51
  JSON Response → Claude
46
52
  ```
47
53
 
48
- ## Tools (19 total)
54
+ ## Auto-Cleanup Pipeline
55
+
56
+ Every `workflow_create` and `workflow_update` runs this automatically:
57
+
58
+ ```
59
+ 1. Validate node types → Block if invalid (with suggestions)
60
+ 2. Execute operation
61
+ 3. Validate workflow → Get warnings
62
+ 4. Auto-fix fixable issues → snake_case, $json refs, AI settings
63
+ 5. Format workflow → Sort nodes, remove nulls
64
+ 6. Update if changes → Apply cleanup to n8n
65
+ 7. Return result → Only unfixable warnings shown
66
+ ```
67
+
68
+ ## Tools (20 total)
49
69
 
50
70
  ### Workflow (8)
51
71
  | Tool | Description |
52
72
  |------|-------------|
53
73
  | `workflow_list` | List workflows, filter by active |
54
74
  | `workflow_get` | Get full workflow |
55
- | `workflow_create` | Create with nodes/connections |
56
- | `workflow_update` | Patch operations |
75
+ | `workflow_create` | Create with nodes/connections (auto-validates, auto-fixes) |
76
+ | `workflow_update` | Patch operations (auto-validates, auto-fixes) |
57
77
  | `workflow_delete` | Delete workflow |
58
78
  | `workflow_activate` | Enable triggers |
59
79
  | `workflow_deactivate` | Disable triggers |
@@ -72,6 +92,11 @@ JSON Response → Claude
72
92
  | `workflow_autofix` | Fix auto-fixable issues (dry-run default) |
73
93
  | `workflow_format` | Sort nodes, clean nulls |
74
94
 
95
+ ### Discovery (1)
96
+ | Tool | Description |
97
+ |------|-------------|
98
+ | `node_types_list` | Search available node types by name/category |
99
+
75
100
  ### Version Control (6)
76
101
  | Tool | Description |
77
102
  |------|-------------|
@@ -94,20 +119,38 @@ updateSettings, updateName
94
119
 
95
120
  Key: Preserves unmodified parameters.
96
121
 
122
+ ## Node Type Validation
123
+
124
+ Before `workflow_create` or `workflow_update` with `addNode`:
125
+
126
+ 1. Fetch available types from n8n API
127
+ 2. Validate all node types exist
128
+ 3. **Block** if invalid with suggestions (fuzzy matching)
129
+
130
+ ```
131
+ Error: Invalid node types detected:
132
+ Invalid node type "n8n-nodes-base.webhok" for node "trigger".
133
+ Did you mean: n8n-nodes-base.webhook?
134
+
135
+ Use node_types_list to discover available node types.
136
+ ```
137
+
97
138
  ## Validation Rules
98
139
 
99
- | Rule | Severity | Description |
100
- |------|----------|-------------|
101
- | `snake_case` | warning | Names should be snake_case |
102
- | `explicit_reference` | warning | Use `$('node')` not `$json` |
103
- | `no_hardcoded_ids` | info | Avoid hardcoded IDs |
104
- | `no_hardcoded_secrets` | error | Never hardcode secrets |
105
- | `code_node_usage` | info | Code node detected |
106
- | `ai_structured_output` | warning | AI node missing structured output |
107
- | `in_memory_storage` | warning | Non-persistent storage |
108
- | `orphan_node` | warning | Node has no connections |
109
- | `node_exists` | error | Node doesn't exist (for updates) |
110
- | `parameter_preservation` | error | Update would lose parameters |
140
+ All rules are checked automatically on every `workflow_create` and `workflow_update`:
141
+
142
+ | Rule | Severity | Auto-fix | Description |
143
+ |------|----------|----------|-------------|
144
+ | `snake_case` | warning | Yes | Names should be snake_case |
145
+ | `explicit_reference` | warning | Yes | Use `$('node')` not `$json` |
146
+ | `ai_structured_output` | warning | Yes | AI node missing structured output |
147
+ | `no_hardcoded_ids` | info | No | Avoid hardcoded IDs |
148
+ | `no_hardcoded_secrets` | info | No | Consider using $env vars |
149
+ | `code_node_usage` | info | No | Code node detected |
150
+ | `in_memory_storage` | warning | No | Non-persistent storage |
151
+ | `orphan_node` | warning | No | Node has no connections |
152
+ | `node_exists` | error | No | Node doesn't exist (for updates) |
153
+ | `parameter_preservation` | error | No | Update would lose parameters |
111
154
 
112
155
  ## Expression Validation
113
156
 
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
 
14
14
  import { N8nClient } from './n8n-client.js';
15
15
  import { tools } from './tools.js';
16
- import { validateWorkflow } from './validators.js';
16
+ import { validateWorkflow, validateNodeTypes } from './validators.js';
17
17
  import { validateExpressions, checkCircularReferences } from './expressions.js';
18
18
  import { autofixWorkflow, formatWorkflow } from './autofix.js';
19
19
  import {
@@ -24,7 +24,14 @@ import {
24
24
  diffWorkflows,
25
25
  getVersionStats,
26
26
  } from './versions.js';
27
- import type { PatchOperation, N8nConnections } from './types.js';
27
+ import {
28
+ formatWorkflowResponse,
29
+ formatExecutionResponse,
30
+ formatExecutionListResponse,
31
+ stringifyResponse,
32
+ type ResponseFormat,
33
+ } from './response-format.js';
34
+ import type { PatchOperation, N8nConnections, N8nNodeTypeSummary } from './types.js';
28
35
 
29
36
  // ─────────────────────────────────────────────────────────────
30
37
  // Configuration
@@ -57,7 +64,7 @@ initVersionControl({
57
64
  const server = new Server(
58
65
  {
59
66
  name: '@pagelines/n8n-mcp',
60
- version: '0.1.0',
67
+ version: '0.3.1',
61
68
  },
62
69
  {
63
70
  capabilities: {
@@ -81,7 +88,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
81
88
  content: [
82
89
  {
83
90
  type: 'text',
84
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
91
+ // Use minified JSON to reduce token usage
92
+ text: typeof result === 'string' ? result : stringifyResponse(result),
85
93
  },
86
94
  ],
87
95
  };
@@ -124,18 +132,40 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
124
132
 
125
133
  case 'workflow_get': {
126
134
  const workflow = await client.getWorkflow(args.id as string);
127
- return workflow;
135
+ const format = (args.format as ResponseFormat) || 'compact';
136
+ return formatWorkflowResponse(workflow, format);
128
137
  }
129
138
 
130
139
  case 'workflow_create': {
131
- const nodes = (args.nodes as Array<{
140
+ const inputNodes = args.nodes as Array<{
132
141
  name: string;
133
142
  type: string;
134
143
  typeVersion: number;
135
144
  position: [number, number];
136
145
  parameters: Record<string, unknown>;
137
146
  credentials?: Record<string, { id: string; name: string }>;
138
- }>).map((n, i) => ({
147
+ }>;
148
+
149
+ // Validate node types BEFORE creating workflow
150
+ const availableTypes = await client.listNodeTypes();
151
+ const validTypeSet = new Set(availableTypes.map((nt) => nt.name));
152
+ const typeErrors = validateNodeTypes(inputNodes, validTypeSet);
153
+
154
+ if (typeErrors.length > 0) {
155
+ const errorMessages = typeErrors.map((e) => {
156
+ let msg = e.message;
157
+ if (e.suggestions && e.suggestions.length > 0) {
158
+ msg += `. Did you mean: ${e.suggestions.join(', ')}?`;
159
+ }
160
+ return msg;
161
+ });
162
+ throw new Error(
163
+ `Invalid node types detected:\n${errorMessages.join('\n')}\n\n` +
164
+ `Use node_types_list to discover available node types.`
165
+ );
166
+ }
167
+
168
+ const nodes = inputNodes.map((n, i) => ({
139
169
  id: crypto.randomUUID(),
140
170
  name: n.name,
141
171
  type: n.type,
@@ -145,40 +175,100 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
145
175
  ...(n.credentials && { credentials: n.credentials }),
146
176
  }));
147
177
 
148
- const workflow = await client.createWorkflow({
178
+ let workflow = await client.createWorkflow({
149
179
  name: args.name as string,
150
180
  nodes,
151
181
  connections: (args.connections as N8nConnections) || {},
152
182
  settings: args.settings as Record<string, unknown>,
153
183
  });
154
184
 
155
- // Validate the new workflow
185
+ // Validate and auto-cleanup
156
186
  const validation = validateWorkflow(workflow);
187
+ const autofix = autofixWorkflow(workflow, validation.warnings);
188
+ let formatted = formatWorkflow(autofix.workflow);
189
+
190
+ // Apply cleanup if there were fixes or formatting changes
191
+ if (autofix.fixes.length > 0 || JSON.stringify(workflow) !== JSON.stringify(formatted)) {
192
+ workflow = await client.updateWorkflow(workflow.id, formatted);
193
+ formatted = workflow;
194
+ }
195
+
196
+ const format = (args.format as ResponseFormat) || 'compact';
157
197
 
158
198
  return {
159
- workflow,
160
- validation,
199
+ workflow: formatWorkflowResponse(formatted, format),
200
+ validation: {
201
+ ...validation,
202
+ warnings: autofix.unfixable, // Only show unfixable warnings
203
+ },
204
+ autoFixed: autofix.fixes.length > 0 ? autofix.fixes : undefined,
161
205
  };
162
206
  }
163
207
 
164
208
  case 'workflow_update': {
209
+ const operations = args.operations as PatchOperation[];
210
+
211
+ // Extract addNode operations that need validation
212
+ const addNodeOps = operations.filter(
213
+ (op): op is Extract<PatchOperation, { type: 'addNode' }> =>
214
+ op.type === 'addNode'
215
+ );
216
+
217
+ if (addNodeOps.length > 0) {
218
+ // Fetch available types and validate
219
+ const availableTypes = await client.listNodeTypes();
220
+ const validTypeSet = new Set(availableTypes.map((nt) => nt.name));
221
+ const nodesToValidate = addNodeOps.map((op) => ({
222
+ name: op.node.name,
223
+ type: op.node.type,
224
+ }));
225
+ const typeErrors = validateNodeTypes(nodesToValidate, validTypeSet);
226
+
227
+ if (typeErrors.length > 0) {
228
+ const errorMessages = typeErrors.map((e) => {
229
+ let msg = e.message;
230
+ if (e.suggestions && e.suggestions.length > 0) {
231
+ msg += `. Did you mean: ${e.suggestions.join(', ')}?`;
232
+ }
233
+ return msg;
234
+ });
235
+ throw new Error(
236
+ `Invalid node types in addNode operations:\n${errorMessages.join('\n')}\n\n` +
237
+ `Use node_types_list to discover available node types.`
238
+ );
239
+ }
240
+ }
241
+
165
242
  // Save version before updating
166
243
  const currentWorkflow = await client.getWorkflow(args.id as string);
167
244
  const versionSaved = await saveVersion(currentWorkflow, 'before_update');
168
245
 
169
- const operations = args.operations as PatchOperation[];
170
- const { workflow, warnings } = await client.patchWorkflow(
246
+ let { workflow, warnings } = await client.patchWorkflow(
171
247
  args.id as string,
172
248
  operations
173
249
  );
174
250
 
175
- // Also run validation
251
+ // Validate and auto-cleanup
176
252
  const validation = validateWorkflow(workflow);
253
+ const autofix = autofixWorkflow(workflow, validation.warnings);
254
+ let formatted = formatWorkflow(autofix.workflow);
255
+
256
+ // Apply cleanup if there were fixes or formatting changes
257
+ if (autofix.fixes.length > 0 || JSON.stringify(workflow) !== JSON.stringify(formatted)) {
258
+ workflow = await client.updateWorkflow(args.id as string, formatted);
259
+ formatted = workflow;
260
+ }
261
+
262
+ const format = (args.format as ResponseFormat) || 'compact';
177
263
 
178
264
  return {
179
- workflow,
265
+ workflow: formatWorkflowResponse(formatted, format),
180
266
  patchWarnings: warnings,
181
- validation,
267
+ validation: {
268
+ ...validation,
269
+ warnings: autofix.unfixable, // Only show unfixable warnings
270
+ },
271
+ autoFixed: autofix.fixes.length > 0 ? autofix.fixes : undefined,
182
272
  versionSaved: versionSaved ? versionSaved.id : null,
183
273
  };
184
274
  }
@@ -221,15 +311,17 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
221
311
  status: args.status as 'success' | 'error' | 'waiting' | undefined,
222
312
  limit: (args.limit as number) || 20,
223
313
  });
314
+ const format = (args.format as ResponseFormat) || 'compact';
224
315
  return {
225
- executions: response.data,
316
+ executions: formatExecutionListResponse(response.data, format),
226
317
  total: response.data.length,
227
318
  };
228
319
  }
229
320
 
230
321
  case 'execution_get': {
231
322
  const execution = await client.getExecution(args.id as string);
232
- return execution;
323
+ const format = (args.format as ResponseFormat) || 'compact';
324
+ return formatExecutionResponse(execution, format);
233
325
  }
234
326
 
235
327
  // Validation & Quality
@@ -296,6 +388,48 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
296
388
  };
297
389
  }
298
390
 
391
+ // Node Discovery
392
+ case 'node_types_list': {
393
+ const nodeTypes = await client.listNodeTypes();
394
+ const search = (args.search as string)?.toLowerCase();
395
+ const category = args.category as string;
396
+ const limit = (args.limit as number) || 50;
397
+
398
+ let results: N8nNodeTypeSummary[] = nodeTypes.map((nt) => ({
399
+ type: nt.name,
400
+ name: nt.displayName,
401
+ description: nt.description,
402
+ category: nt.codex?.categories?.[0] || nt.group?.[0] || 'Other',
403
+ version: nt.version,
404
+ }));
405
+
406
+ // Apply search filter
407
+ if (search) {
408
+ results = results.filter(
409
+ (nt) =>
410
+ nt.type.toLowerCase().includes(search) ||
411
+ nt.name.toLowerCase().includes(search) ||
412
+ nt.description.toLowerCase().includes(search)
413
+ );
414
+ }
415
+
416
+ // Apply category filter
417
+ if (category) {
418
+ results = results.filter((nt) =>
419
+ nt.category.toLowerCase().includes(category.toLowerCase())
420
+ );
421
+ }
422
+
423
+ // Apply limit
424
+ results = results.slice(0, limit);
425
+
426
+ return {
427
+ nodeTypes: results,
428
+ total: results.length,
429
+ hint: 'Use the "type" field value when creating nodes (e.g., "n8n-nodes-base.webhook")',
430
+ };
431
+ }
432
+
299
433
  // Version Control
300
434
  case 'version_list': {
301
435
  const versions = await listVersions(args.workflowId as string);
@@ -314,7 +448,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
314
448
  if (!version) {
315
449
  throw new Error(`Version ${args.versionId} not found`);
316
450
  }
317
- return version;
451
+ const format = (args.format as ResponseFormat) || 'compact';
452
+ return {
453
+ meta: version.meta,
454
+ workflow: formatWorkflowResponse(version.workflow, format),
455
+ };
318
456
  }
319
457
 
320
458
  case 'version_save': {
@@ -344,11 +482,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
344
482
 
345
483
  // Apply the old version
346
484
  await client.updateWorkflow(args.workflowId as string, version.workflow);
485
+ const format = (args.format as ResponseFormat) || 'compact';
347
486
 
348
487
  return {
349
488
  success: true,
350
489
  restoredVersion: version.meta,
351
- workflow: version.workflow,
490
+ workflow: formatWorkflowResponse(version.workflow, format),
352
491
  };
353
492
  }
354
493
 
@@ -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();
@@ -224,4 +225,243 @@ describe('N8nClient', () => {
224
225
  await expect(client.getWorkflow('999')).rejects.toThrow('n8n API error (404)');
225
226
  });
226
227
  });
228
+
229
+ describe('listNodeTypes', () => {
230
+ it('calls correct endpoint', async () => {
231
+ const mockNodeTypes = [
232
+ {
233
+ name: 'n8n-nodes-base.webhook',
234
+ displayName: 'Webhook',
235
+ description: 'Starts workflow on webhook call',
236
+ group: ['trigger'],
237
+ version: 2,
238
+ },
239
+ {
240
+ name: 'n8n-nodes-base.set',
241
+ displayName: 'Set',
242
+ description: 'Set values',
243
+ group: ['transform'],
244
+ version: 3,
245
+ },
246
+ ];
247
+
248
+ mockFetch.mockResolvedValueOnce({
249
+ ok: true,
250
+ text: async () => JSON.stringify(mockNodeTypes),
251
+ });
252
+
253
+ const result = await client.listNodeTypes();
254
+
255
+ expect(mockFetch).toHaveBeenCalledWith(
256
+ 'https://n8n.example.com/api/v1/nodes',
257
+ expect.objectContaining({
258
+ method: 'GET',
259
+ headers: expect.objectContaining({
260
+ 'X-N8N-API-KEY': 'test-api-key',
261
+ }),
262
+ })
263
+ );
264
+
265
+ expect(result).toHaveLength(2);
266
+ expect(result[0].name).toBe('n8n-nodes-base.webhook');
267
+ expect(result[1].name).toBe('n8n-nodes-base.set');
268
+ });
269
+ });
270
+
271
+ describe('updateWorkflow', () => {
272
+ it('strips disallowed properties before sending to API', async () => {
273
+ const fullWorkflow = {
274
+ id: '123',
275
+ name: 'test_workflow',
276
+ active: true,
277
+ nodes: [{ id: 'n1', name: 'node1', type: 'test', typeVersion: 1, position: [0, 0] as [number, number], parameters: {} }],
278
+ connections: {},
279
+ settings: { timezone: 'UTC' },
280
+ createdAt: '2024-01-01T00:00:00.000Z',
281
+ updatedAt: '2024-01-02T00:00:00.000Z',
282
+ versionId: 'v1',
283
+ staticData: undefined,
284
+ tags: [{ id: 't1', name: 'tag1' }],
285
+ };
286
+
287
+ mockFetch.mockResolvedValueOnce({
288
+ ok: true,
289
+ text: async () => JSON.stringify(fullWorkflow),
290
+ });
291
+
292
+ await client.updateWorkflow('123', fullWorkflow);
293
+
294
+ // Verify the request body does NOT contain disallowed properties
295
+ const putCall = mockFetch.mock.calls[0];
296
+ const putBody = JSON.parse(putCall[1].body);
297
+
298
+ // These should be stripped
299
+ expect(putBody.id).toBeUndefined();
300
+ expect(putBody.createdAt).toBeUndefined();
301
+ expect(putBody.updatedAt).toBeUndefined();
302
+ expect(putBody.active).toBeUndefined();
303
+ expect(putBody.versionId).toBeUndefined();
304
+
305
+ // These should be preserved
306
+ expect(putBody.name).toBe('test_workflow');
307
+ expect(putBody.nodes).toHaveLength(1);
308
+ expect(putBody.connections).toEqual({});
309
+ expect(putBody.settings).toEqual({ timezone: 'UTC' });
310
+ expect(putBody.staticData).toBeUndefined();
311
+ expect(putBody.tags).toEqual([{ id: 't1', name: 'tag1' }]);
312
+ });
313
+
314
+ it('works with partial workflow (only some fields)', async () => {
315
+ mockFetch.mockResolvedValueOnce({
316
+ ok: true,
317
+ text: async () => JSON.stringify({ id: '123', name: 'updated' }),
318
+ });
319
+
320
+ await client.updateWorkflow('123', { name: 'updated', nodes: [] });
321
+
322
+ const putCall = mockFetch.mock.calls[0];
323
+ const putBody = JSON.parse(putCall[1].body);
324
+
325
+ expect(putBody.name).toBe('updated');
326
+ expect(putBody.nodes).toEqual([]);
327
+ });
328
+
329
+ it('handles workflow from formatWorkflow (simulating workflow_format apply)', async () => {
330
+ // This simulates the exact scenario that caused the bug:
331
+ // workflow_format returns a full N8nWorkflow object with id, createdAt, etc.
332
+ const formattedWorkflow = {
333
+ id: 'zbB1fCxWgZXgpjB1',
334
+ name: 'my_workflow',
335
+ active: false,
336
+ nodes: [],
337
+ connections: {},
338
+ createdAt: '2024-01-01T00:00:00.000Z',
339
+ updatedAt: '2024-01-02T00:00:00.000Z',
340
+ };
341
+
342
+ mockFetch.mockResolvedValueOnce({
343
+ ok: true,
344
+ text: async () => JSON.stringify(formattedWorkflow),
345
+ });
346
+
347
+ // This should NOT throw "must NOT have additional properties"
348
+ await client.updateWorkflow('zbB1fCxWgZXgpjB1', formattedWorkflow);
349
+
350
+ const putCall = mockFetch.mock.calls[0];
351
+ const putBody = JSON.parse(putCall[1].body);
352
+
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
359
+ expect(putBody.id).toBeUndefined();
360
+ expect(putBody.createdAt).toBeUndefined();
361
+ expect(putBody.updatedAt).toBeUndefined();
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);
399
+
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']);
411
+ });
412
+ });
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
+ });
227
467
  });