@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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
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
+
17
+ ## [0.3.0] - 2025-01-13
18
+
19
+ ### Added
20
+
21
+ #### Node Type Discovery & Validation
22
+ - `node_types_list` - Search available node types by name/category
23
+ - Pre-validation blocks invalid node types before `workflow_create` and `workflow_update`
24
+ - Fuzzy matching suggests correct types when invalid types detected
25
+
26
+ #### Auto-Cleanup Pipeline
27
+ - Every `workflow_create` and `workflow_update` now automatically:
28
+ - Validates node types (blocks if invalid)
29
+ - Runs validation rules
30
+ - Auto-fixes fixable issues (snake_case, $json refs, AI settings)
31
+ - Formats workflow (sorts nodes, removes nulls)
32
+ - Returns only unfixable warnings
33
+
34
+ #### Response Formatting
35
+ - New `format` parameter on workflow/execution tools: `compact` (default), `summary`, `full`
36
+ - Token-efficient responses (88% reduction with compact, 98% with summary)
37
+
38
+ ### Changed
39
+ - Hardcoded secrets: severity `error` → `info` (recommend env vars, don't block)
40
+ - Documentation updated with "opinionated" messaging throughout
41
+ - Added PageLines logo to README
42
+
5
43
  ## [0.2.1] - 2025-01-13
6
44
 
7
45
  ### Added
package/README.md CHANGED
@@ -1,28 +1,34 @@
1
- # n8n MCP Server
1
+ <img src="logo.png" width="64" height="64" alt="PageLines">
2
2
 
3
- Workflow validation, version control, and patch-based updates for n8n.
3
+ # n8n MCP Server
4
4
 
5
- ## The Problem
5
+ **Opinionated** workflow automation for n8n. Enforces best practices, auto-fixes issues, and prevents mistakes.
6
6
 
7
- Other n8n MCPs replace entire nodes on update—losing parameters you didn't touch. No rollback. 70MB of SQLite for node docs you can Google.
7
+ ## Why This MCP?
8
8
 
9
- ## This MCP
9
+ **The problem:** Other n8n MCPs replace entire nodes when you update one field. Change a message? Lose your channel ID, auth settings, everything else. No undo. And they bundle 70MB of node docs you can just Google.
10
10
 
11
- | Feature | This | Others |
12
- |---------|------|--------|
13
- | Update approach | Patch (preserves params) | Replace (loses params) |
14
- | Version control | Auto-snapshot before mutations | Manual/none |
15
- | Validation | Expression syntax, circular refs, secrets | Basic |
16
- | Auto-fix | snake_case, $json→$('node'), AI settings | None |
17
- | Size | ~1,200 LOC, zero deps | 10k+ LOC, 70MB SQLite |
11
+ **This MCP is opinionated:**
12
+ - **Patches, not replaces** - Update one field, keep everything else
13
+ - **Auto-cleanup** - Every create/update validates, auto-fixes, and formats automatically
14
+ - **Auto-snapshots** - Every mutation saves a version first. Always have rollback.
15
+ - **Node type validation** - Blocks invalid node types with suggestions before they hit n8n
16
+ - **Expression validation** - Catches `$json` refs that break on reorder, circular deps, missing nodes
17
+ - **Enforces conventions** - snake_case naming, explicit references, recommends env vars
18
+ - **Lightweight** - ~1,500 lines, zero runtime dependencies
18
19
 
19
- ## Install
20
+ | | This MCP | Others |
21
+ |--|----------|--------|
22
+ | Update a node | Preserves untouched params | Loses them |
23
+ | After create/update | Auto-validates, auto-fixes, formats | Manual cleanup |
24
+ | Invalid node types | Blocked with suggestions | API error |
25
+ | Before mutations | Auto-saves version | Hope you backed up |
26
+ | Expression validation | Syntax, refs, circular deps | Basic |
27
+ | Size | ~1,500 LOC | 10k+ LOC, 70MB SQLite |
20
28
 
21
- ```bash
22
- npx @pagelines/n8n-mcp
23
- ```
29
+ ## Setup
24
30
 
25
- Add to `~/.claude/mcp.json`:
31
+ Add to your MCP client config:
26
32
 
27
33
  ```json
28
34
  {
@@ -39,28 +45,38 @@ Add to `~/.claude/mcp.json`:
39
45
  }
40
46
  ```
41
47
 
42
- ## Tools
48
+ No install step needed - npx handles it.
49
+
50
+ ---
51
+
52
+ ## Reference
53
+
54
+ ### Tools
43
55
 
44
56
  | Category | Tools |
45
57
  |----------|-------|
46
58
  | Workflow | `list` `get` `create` `update` `delete` `activate` `deactivate` `execute` |
47
59
  | Execution | `list` `get` |
48
60
  | Validation | `validate` `autofix` `format` |
61
+ | Discovery | `node_types_list` |
49
62
  | Versions | `list` `get` `save` `rollback` `diff` `stats` |
50
63
 
51
- ## Validation
64
+ ### Opinions Enforced
65
+
66
+ These rules are checked and auto-fixed on every `workflow_create` and `workflow_update`:
52
67
 
53
68
  | Rule | Severity | Auto-fix |
54
69
  |------|----------|----------|
55
70
  | snake_case naming | warning | Yes |
56
71
  | Explicit refs (`$('node')` not `$json`) | warning | Yes |
57
- | AI structured output | warning | Yes |
58
- | Hardcoded secrets | error | No |
72
+ | AI structured output settings | warning | Yes |
73
+ | Invalid node types | error | Blocked |
74
+ | Hardcoded secrets | info | No |
59
75
  | Orphan nodes | warning | No |
60
76
  | Expression syntax | error | No |
61
77
  | Circular references | error | No |
62
78
 
63
- ## Config
79
+ ### Config
64
80
 
65
81
  | Variable | Default | Description |
66
82
  |----------|---------|-------------|
@@ -69,10 +85,10 @@ Add to `~/.claude/mcp.json`:
69
85
  | `N8N_MCP_VERSIONS` | `true` | Enable version control |
70
86
  | `N8N_MCP_MAX_VERSIONS` | `20` | Max snapshots per workflow |
71
87
 
72
- ## Docs
88
+ ### Docs
73
89
 
74
90
  - [Best Practices](docs/best-practices.md) - Expression patterns, config nodes, AI settings
75
- - [Node Config](docs/node-config.md) - Human-editable node settings (`__rl`, Set node, etc.)
91
+ - [Node Config](docs/node-config.md) - Human-editable node settings
76
92
  - [Architecture](plans/architecture.md) - Technical reference
77
93
 
78
94
  ## License
package/dist/index.js CHANGED
@@ -8,10 +8,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { N8nClient } from './n8n-client.js';
10
10
  import { tools } from './tools.js';
11
- import { validateWorkflow } from './validators.js';
11
+ import { validateWorkflow, validateNodeTypes } from './validators.js';
12
12
  import { validateExpressions, checkCircularReferences } from './expressions.js';
13
13
  import { autofixWorkflow, formatWorkflow } from './autofix.js';
14
14
  import { initVersionControl, saveVersion, listVersions, getVersion, diffWorkflows, getVersionStats, } from './versions.js';
15
+ import { formatWorkflowResponse, formatExecutionResponse, formatExecutionListResponse, stringifyResponse, } from './response-format.js';
15
16
  // ─────────────────────────────────────────────────────────────
16
17
  // Configuration
17
18
  // ─────────────────────────────────────────────────────────────
@@ -36,7 +37,7 @@ initVersionControl({
36
37
  // ─────────────────────────────────────────────────────────────
37
38
  const server = new Server({
38
39
  name: '@pagelines/n8n-mcp',
39
- version: '0.1.0',
40
+ version: '0.3.1',
40
41
  }, {
41
42
  capabilities: {
42
43
  tools: {},
@@ -55,7 +56,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
55
56
  content: [
56
57
  {
57
58
  type: 'text',
58
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
59
+ // Use minified JSON to reduce token usage
60
+ text: typeof result === 'string' ? result : stringifyResponse(result),
59
61
  },
60
62
  ],
61
63
  };
@@ -96,10 +98,27 @@ async function handleTool(name, args) {
96
98
  }
97
99
  case 'workflow_get': {
98
100
  const workflow = await client.getWorkflow(args.id);
99
- return workflow;
101
+ const format = args.format || 'compact';
102
+ return formatWorkflowResponse(workflow, format);
100
103
  }
101
104
  case 'workflow_create': {
102
- const nodes = args.nodes.map((n, i) => ({
105
+ const inputNodes = args.nodes;
106
+ // Validate node types BEFORE creating workflow
107
+ const availableTypes = await client.listNodeTypes();
108
+ const validTypeSet = new Set(availableTypes.map((nt) => nt.name));
109
+ const typeErrors = validateNodeTypes(inputNodes, validTypeSet);
110
+ if (typeErrors.length > 0) {
111
+ const errorMessages = typeErrors.map((e) => {
112
+ let msg = e.message;
113
+ if (e.suggestions && e.suggestions.length > 0) {
114
+ msg += `. Did you mean: ${e.suggestions.join(', ')}?`;
115
+ }
116
+ return msg;
117
+ });
118
+ throw new Error(`Invalid node types detected:\n${errorMessages.join('\n')}\n\n` +
119
+ `Use node_types_list to discover available node types.`);
120
+ }
121
+ const nodes = inputNodes.map((n, i) => ({
103
122
  id: crypto.randomUUID(),
104
123
  name: n.name,
105
124
  type: n.type,
@@ -108,31 +127,78 @@ async function handleTool(name, args) {
108
127
  parameters: n.parameters || {},
109
128
  ...(n.credentials && { credentials: n.credentials }),
110
129
  }));
111
- const workflow = await client.createWorkflow({
130
+ let workflow = await client.createWorkflow({
112
131
  name: args.name,
113
132
  nodes,
114
133
  connections: args.connections || {},
115
134
  settings: args.settings,
116
135
  });
117
- // Validate the new workflow
136
+ // Validate and auto-cleanup
118
137
  const validation = validateWorkflow(workflow);
138
+ const autofix = autofixWorkflow(workflow, validation.warnings);
139
+ let formatted = formatWorkflow(autofix.workflow);
140
+ // Apply cleanup if there were fixes or formatting changes
141
+ if (autofix.fixes.length > 0 || JSON.stringify(workflow) !== JSON.stringify(formatted)) {
142
+ workflow = await client.updateWorkflow(workflow.id, formatted);
143
+ formatted = workflow;
144
+ }
145
+ const format = args.format || 'compact';
119
146
  return {
120
- workflow,
121
- validation,
147
+ workflow: formatWorkflowResponse(formatted, format),
148
+ validation: {
149
+ ...validation,
150
+ warnings: autofix.unfixable, // Only show unfixable warnings
151
+ },
152
+ autoFixed: autofix.fixes.length > 0 ? autofix.fixes : undefined,
122
153
  };
123
154
  }
124
155
  case 'workflow_update': {
156
+ const operations = args.operations;
157
+ // Extract addNode operations that need validation
158
+ const addNodeOps = operations.filter((op) => op.type === 'addNode');
159
+ if (addNodeOps.length > 0) {
160
+ // Fetch available types and validate
161
+ const availableTypes = await client.listNodeTypes();
162
+ const validTypeSet = new Set(availableTypes.map((nt) => nt.name));
163
+ const nodesToValidate = addNodeOps.map((op) => ({
164
+ name: op.node.name,
165
+ type: op.node.type,
166
+ }));
167
+ const typeErrors = validateNodeTypes(nodesToValidate, validTypeSet);
168
+ if (typeErrors.length > 0) {
169
+ const errorMessages = typeErrors.map((e) => {
170
+ let msg = e.message;
171
+ if (e.suggestions && e.suggestions.length > 0) {
172
+ msg += `. Did you mean: ${e.suggestions.join(', ')}?`;
173
+ }
174
+ return msg;
175
+ });
176
+ throw new Error(`Invalid node types in addNode operations:\n${errorMessages.join('\n')}\n\n` +
177
+ `Use node_types_list to discover available node types.`);
178
+ }
179
+ }
125
180
  // Save version before updating
126
181
  const currentWorkflow = await client.getWorkflow(args.id);
127
182
  const versionSaved = await saveVersion(currentWorkflow, 'before_update');
128
- const operations = args.operations;
129
- const { workflow, warnings } = await client.patchWorkflow(args.id, operations);
130
- // Also run validation
183
+ let { workflow, warnings } = await client.patchWorkflow(args.id, operations);
184
+ // Validate and auto-cleanup
131
185
  const validation = validateWorkflow(workflow);
186
+ const autofix = autofixWorkflow(workflow, validation.warnings);
187
+ let formatted = formatWorkflow(autofix.workflow);
188
+ // Apply cleanup if there were fixes or formatting changes
189
+ if (autofix.fixes.length > 0 || JSON.stringify(workflow) !== JSON.stringify(formatted)) {
190
+ workflow = await client.updateWorkflow(args.id, formatted);
191
+ formatted = workflow;
192
+ }
193
+ const format = args.format || 'compact';
132
194
  return {
133
- workflow,
195
+ workflow: formatWorkflowResponse(formatted, format),
134
196
  patchWarnings: warnings,
135
- validation,
197
+ validation: {
198
+ ...validation,
199
+ warnings: autofix.unfixable, // Only show unfixable warnings
200
+ },
201
+ autoFixed: autofix.fixes.length > 0 ? autofix.fixes : undefined,
136
202
  versionSaved: versionSaved ? versionSaved.id : null,
137
203
  };
138
204
  }
@@ -167,14 +233,16 @@ async function handleTool(name, args) {
167
233
  status: args.status,
168
234
  limit: args.limit || 20,
169
235
  });
236
+ const format = args.format || 'compact';
170
237
  return {
171
- executions: response.data,
238
+ executions: formatExecutionListResponse(response.data, format),
172
239
  total: response.data.length,
173
240
  };
174
241
  }
175
242
  case 'execution_get': {
176
243
  const execution = await client.getExecution(args.id);
177
- return execution;
244
+ const format = args.format || 'compact';
245
+ return formatExecutionResponse(execution, format);
178
246
  }
179
247
  // Validation & Quality
180
248
  case 'workflow_validate': {
@@ -229,6 +297,37 @@ async function handleTool(name, args) {
229
297
  previewWorkflow: formatted,
230
298
  };
231
299
  }
300
+ // Node Discovery
301
+ case 'node_types_list': {
302
+ const nodeTypes = await client.listNodeTypes();
303
+ const search = args.search?.toLowerCase();
304
+ const category = args.category;
305
+ const limit = args.limit || 50;
306
+ let results = nodeTypes.map((nt) => ({
307
+ type: nt.name,
308
+ name: nt.displayName,
309
+ description: nt.description,
310
+ category: nt.codex?.categories?.[0] || nt.group?.[0] || 'Other',
311
+ version: nt.version,
312
+ }));
313
+ // Apply search filter
314
+ if (search) {
315
+ results = results.filter((nt) => nt.type.toLowerCase().includes(search) ||
316
+ nt.name.toLowerCase().includes(search) ||
317
+ nt.description.toLowerCase().includes(search));
318
+ }
319
+ // Apply category filter
320
+ if (category) {
321
+ results = results.filter((nt) => nt.category.toLowerCase().includes(category.toLowerCase()));
322
+ }
323
+ // Apply limit
324
+ results = results.slice(0, limit);
325
+ return {
326
+ nodeTypes: results,
327
+ total: results.length,
328
+ hint: 'Use the "type" field value when creating nodes (e.g., "n8n-nodes-base.webhook")',
329
+ };
330
+ }
232
331
  // Version Control
233
332
  case 'version_list': {
234
333
  const versions = await listVersions(args.workflowId);
@@ -243,7 +342,11 @@ async function handleTool(name, args) {
243
342
  if (!version) {
244
343
  throw new Error(`Version ${args.versionId} not found`);
245
344
  }
246
- return version;
345
+ const format = args.format || 'compact';
346
+ return {
347
+ meta: version.meta,
348
+ workflow: formatWorkflowResponse(version.workflow, format),
349
+ };
247
350
  }
248
351
  case 'version_save': {
249
352
  const workflow = await client.getWorkflow(args.workflowId);
@@ -263,10 +366,11 @@ async function handleTool(name, args) {
263
366
  await saveVersion(currentWorkflow, 'before_rollback');
264
367
  // Apply the old version
265
368
  await client.updateWorkflow(args.workflowId, version.workflow);
369
+ const format = args.format || 'compact';
266
370
  return {
267
371
  success: true,
268
372
  restoredVersion: version.meta,
269
- workflow: version.workflow,
373
+ workflow: formatWorkflowResponse(version.workflow, format),
270
374
  };
271
375
  }
272
376
  case 'version_diff': {
@@ -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, 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;
@@ -25,7 +25,7 @@ export declare class N8nClient {
25
25
  connections: N8nWorkflow['connections'];
26
26
  settings?: Record<string, unknown>;
27
27
  }): Promise<N8nWorkflow>;
28
- updateWorkflow(id: string, workflow: Partial<Omit<N8nWorkflow, 'id' | 'createdAt' | 'updatedAt'>>): Promise<N8nWorkflow>;
28
+ updateWorkflow(id: string, workflow: Partial<N8nWorkflow>): Promise<N8nWorkflow>;
29
29
  deleteWorkflow(id: string): Promise<void>;
30
30
  activateWorkflow(id: string): Promise<N8nWorkflow>;
31
31
  deactivateWorkflow(id: string): Promise<N8nWorkflow>;
@@ -51,4 +51,5 @@ export declare class N8nClient {
51
51
  version?: string;
52
52
  error?: string;
53
53
  }>;
54
+ listNodeTypes(): Promise<N8nNodeType[]>;
54
55
  }
@@ -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,7 +57,9 @@ export class N8nClient {
56
57
  return this.request('POST', '/api/v1/workflows', workflow);
57
58
  }
58
59
  async updateWorkflow(id, workflow) {
59
- return this.request('PUT', `/api/v1/workflows/${id}`, workflow);
60
+ // Schema-driven: only send fields n8n accepts (defined in types.ts)
61
+ const allowed = pickFields(workflow, N8N_WORKFLOW_WRITABLE_FIELDS);
62
+ return this.request('PUT', `/api/v1/workflows/${id}`, allowed);
60
63
  }
61
64
  async deleteWorkflow(id) {
62
65
  await this.request('DELETE', `/api/v1/workflows/${id}`);
@@ -272,4 +275,10 @@ export class N8nClient {
272
275
  };
273
276
  }
274
277
  }
278
+ // ─────────────────────────────────────────────────────────────
279
+ // Node Types
280
+ // ─────────────────────────────────────────────────────────────
281
+ async listNodeTypes() {
282
+ return this.request('GET', '/api/v1/nodes');
283
+ }
275
284
  }
@@ -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;
@@ -181,4 +182,201 @@ describe('N8nClient', () => {
181
182
  await expect(client.getWorkflow('999')).rejects.toThrow('n8n API error (404)');
182
183
  });
183
184
  });
185
+ describe('listNodeTypes', () => {
186
+ it('calls correct endpoint', async () => {
187
+ const mockNodeTypes = [
188
+ {
189
+ name: 'n8n-nodes-base.webhook',
190
+ displayName: 'Webhook',
191
+ description: 'Starts workflow on webhook call',
192
+ group: ['trigger'],
193
+ version: 2,
194
+ },
195
+ {
196
+ name: 'n8n-nodes-base.set',
197
+ displayName: 'Set',
198
+ description: 'Set values',
199
+ group: ['transform'],
200
+ version: 3,
201
+ },
202
+ ];
203
+ mockFetch.mockResolvedValueOnce({
204
+ ok: true,
205
+ text: async () => JSON.stringify(mockNodeTypes),
206
+ });
207
+ const result = await client.listNodeTypes();
208
+ expect(mockFetch).toHaveBeenCalledWith('https://n8n.example.com/api/v1/nodes', expect.objectContaining({
209
+ method: 'GET',
210
+ headers: expect.objectContaining({
211
+ 'X-N8N-API-KEY': 'test-api-key',
212
+ }),
213
+ }));
214
+ expect(result).toHaveLength(2);
215
+ expect(result[0].name).toBe('n8n-nodes-base.webhook');
216
+ expect(result[1].name).toBe('n8n-nodes-base.set');
217
+ });
218
+ });
219
+ describe('updateWorkflow', () => {
220
+ it('strips disallowed properties before sending to API', async () => {
221
+ const fullWorkflow = {
222
+ id: '123',
223
+ name: 'test_workflow',
224
+ active: true,
225
+ nodes: [{ id: 'n1', name: 'node1', type: 'test', typeVersion: 1, position: [0, 0], parameters: {} }],
226
+ connections: {},
227
+ settings: { timezone: 'UTC' },
228
+ createdAt: '2024-01-01T00:00:00.000Z',
229
+ updatedAt: '2024-01-02T00:00:00.000Z',
230
+ versionId: 'v1',
231
+ staticData: undefined,
232
+ tags: [{ id: 't1', name: 'tag1' }],
233
+ };
234
+ mockFetch.mockResolvedValueOnce({
235
+ ok: true,
236
+ text: async () => JSON.stringify(fullWorkflow),
237
+ });
238
+ await client.updateWorkflow('123', fullWorkflow);
239
+ // Verify the request body does NOT contain disallowed properties
240
+ const putCall = mockFetch.mock.calls[0];
241
+ const putBody = JSON.parse(putCall[1].body);
242
+ // These should be stripped
243
+ expect(putBody.id).toBeUndefined();
244
+ expect(putBody.createdAt).toBeUndefined();
245
+ expect(putBody.updatedAt).toBeUndefined();
246
+ expect(putBody.active).toBeUndefined();
247
+ expect(putBody.versionId).toBeUndefined();
248
+ // These should be preserved
249
+ expect(putBody.name).toBe('test_workflow');
250
+ expect(putBody.nodes).toHaveLength(1);
251
+ expect(putBody.connections).toEqual({});
252
+ expect(putBody.settings).toEqual({ timezone: 'UTC' });
253
+ expect(putBody.staticData).toBeUndefined();
254
+ expect(putBody.tags).toEqual([{ id: 't1', name: 'tag1' }]);
255
+ });
256
+ it('works with partial workflow (only some fields)', async () => {
257
+ mockFetch.mockResolvedValueOnce({
258
+ ok: true,
259
+ text: async () => JSON.stringify({ id: '123', name: 'updated' }),
260
+ });
261
+ await client.updateWorkflow('123', { name: 'updated', nodes: [] });
262
+ const putCall = mockFetch.mock.calls[0];
263
+ const putBody = JSON.parse(putCall[1].body);
264
+ expect(putBody.name).toBe('updated');
265
+ expect(putBody.nodes).toEqual([]);
266
+ });
267
+ it('handles workflow from formatWorkflow (simulating workflow_format apply)', async () => {
268
+ // This simulates the exact scenario that caused the bug:
269
+ // workflow_format returns a full N8nWorkflow object with id, createdAt, etc.
270
+ const formattedWorkflow = {
271
+ id: 'zbB1fCxWgZXgpjB1',
272
+ name: 'my_workflow',
273
+ active: false,
274
+ nodes: [],
275
+ connections: {},
276
+ createdAt: '2024-01-01T00:00:00.000Z',
277
+ updatedAt: '2024-01-02T00:00:00.000Z',
278
+ };
279
+ mockFetch.mockResolvedValueOnce({
280
+ ok: true,
281
+ text: async () => JSON.stringify(formattedWorkflow),
282
+ });
283
+ // This should NOT throw "must NOT have additional properties"
284
+ await client.updateWorkflow('zbB1fCxWgZXgpjB1', formattedWorkflow);
285
+ const putCall = mockFetch.mock.calls[0];
286
+ const putBody = JSON.parse(putCall[1].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
292
+ expect(putBody.id).toBeUndefined();
293
+ expect(putBody.createdAt).toBeUndefined();
294
+ expect(putBody.updatedAt).toBeUndefined();
295
+ expect(putBody.active).toBeUndefined();
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
+ }
381
+ });
184
382
  });