@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/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
|
-
|
|
1
|
+
<img src="logo.png" width="64" height="64" alt="PageLines">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# n8n MCP Server
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Opinionated** workflow automation for n8n. Enforces best practices, auto-fixes issues, and prevents mistakes.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Why This MCP?
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
npx @pagelines/n8n-mcp
|
|
23
|
-
```
|
|
29
|
+
## Setup
|
|
24
30
|
|
|
25
|
-
Add to
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
+
const format = args.format || 'compact';
|
|
102
|
+
return formatWorkflowResponse(workflow, format);
|
|
100
103
|
}
|
|
101
104
|
case 'workflow_create': {
|
|
102
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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': {
|
package/dist/n8n-client.d.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
}
|
package/dist/n8n-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/n8n-client.test.js
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
|
// 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
|
});
|