@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 +38 -0
- package/README.md +40 -24
- package/dist/index.js +122 -18
- package/dist/n8n-client.d.ts +3 -2
- package/dist/n8n-client.js +10 -1
- package/dist/n8n-client.test.js +198 -0
- package/dist/response-format.d.ts +84 -0
- package/dist/response-format.js +183 -0
- package/dist/response-format.test.d.ts +1 -0
- package/dist/response-format.test.js +291 -0
- package/dist/tools.js +67 -3
- package/dist/types.d.ts +44 -0
- package/dist/types.js +34 -1
- package/dist/validators.d.ts +9 -1
- package/dist/validators.js +87 -2
- package/dist/validators.test.js +83 -4
- package/docs/best-practices.md +15 -10
- package/docs/node-config.md +3 -1
- package/logo.png +0 -0
- package/package.json +1 -1
- package/plans/architecture.md +69 -26
- package/src/index.ts +159 -20
- package/src/n8n-client.test.ts +240 -0
- package/src/n8n-client.ts +23 -10
- package/src/response-format.test.ts +355 -0
- package/src/response-format.ts +278 -0
- package/src/tools.ts +68 -3
- package/src/types.ts +76 -0
- package/src/validators.test.ts +101 -4
- package/src/validators.ts +112 -3
package/plans/architecture.md
CHANGED
|
@@ -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,
|
|
19
|
+
| Lines of code | ~10k+ | ~1,500 |
|
|
15
20
|
|
|
16
21
|
## Modules
|
|
17
22
|
|
|
18
23
|
```
|
|
19
24
|
src/
|
|
20
|
-
├── index.ts
|
|
21
|
-
├── types.ts
|
|
22
|
-
├── tools.ts
|
|
23
|
-
├── n8n-client.ts
|
|
24
|
-
├── validators.ts
|
|
25
|
-
├── expressions.ts
|
|
26
|
-
├── autofix.ts
|
|
27
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
|
102
|
-
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
const format = (args.format as ResponseFormat) || 'compact';
|
|
136
|
+
return formatWorkflowResponse(workflow, format);
|
|
128
137
|
}
|
|
129
138
|
|
|
130
139
|
case 'workflow_create': {
|
|
131
|
-
const
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/n8n-client.test.ts
CHANGED
|
@@ -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
|
});
|