@pagelines/n8n-mcp 0.1.0 → 0.2.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 +63 -0
- package/README.md +44 -119
- package/dist/autofix.d.ts +28 -0
- package/dist/autofix.js +222 -0
- package/dist/expressions.d.ts +25 -0
- package/dist/expressions.js +209 -0
- package/dist/index.js +124 -1
- package/dist/tools.js +147 -4
- package/dist/validators.js +67 -0
- package/dist/validators.test.js +83 -0
- package/dist/versions.d.ts +71 -0
- package/dist/versions.js +239 -0
- package/docs/best-practices.md +160 -0
- package/docs/node-config.md +203 -0
- package/package.json +1 -1
- package/plans/ai-guidelines.md +233 -0
- package/plans/architecture.md +177 -0
- package/server.json +10 -2
- package/src/autofix.ts +275 -0
- package/src/expressions.ts +254 -0
- package/src/index.ts +169 -1
- package/src/tools.ts +155 -4
- package/src/validators.test.ts +97 -0
- package/src/validators.ts +77 -0
- package/src/versions.ts +320 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* n8n expression validation
|
|
3
|
+
* Parses and validates expressions in workflow parameters
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Validate all expressions in a workflow
|
|
7
|
+
*/
|
|
8
|
+
export function validateExpressions(workflow) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
const nodeNames = new Set(workflow.nodes.map((n) => n.name));
|
|
11
|
+
for (const node of workflow.nodes) {
|
|
12
|
+
const nodeIssues = validateNodeExpressions(node, nodeNames);
|
|
13
|
+
issues.push(...nodeIssues);
|
|
14
|
+
}
|
|
15
|
+
return issues;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Validate expressions in a single node
|
|
19
|
+
*/
|
|
20
|
+
function validateNodeExpressions(node, nodeNames) {
|
|
21
|
+
const issues = [];
|
|
22
|
+
const expressions = extractExpressions(node.parameters);
|
|
23
|
+
for (const { path, expression } of expressions) {
|
|
24
|
+
const exprIssues = validateExpression(expression, nodeNames);
|
|
25
|
+
for (const issue of exprIssues) {
|
|
26
|
+
issues.push({
|
|
27
|
+
node: node.name,
|
|
28
|
+
parameter: path,
|
|
29
|
+
expression,
|
|
30
|
+
...issue,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return issues;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Extract all expressions from parameters
|
|
38
|
+
*/
|
|
39
|
+
function extractExpressions(params, basePath = '') {
|
|
40
|
+
const results = [];
|
|
41
|
+
for (const [key, value] of Object.entries(params)) {
|
|
42
|
+
const path = basePath ? `${basePath}.${key}` : key;
|
|
43
|
+
if (typeof value === 'string') {
|
|
44
|
+
// Check for expression patterns
|
|
45
|
+
const exprMatches = value.match(/\{\{.*?\}\}/g) || [];
|
|
46
|
+
for (const match of exprMatches) {
|
|
47
|
+
results.push({ path, expression: match });
|
|
48
|
+
}
|
|
49
|
+
// Also check for ={{ prefix (common in n8n)
|
|
50
|
+
if (value.startsWith('={{')) {
|
|
51
|
+
results.push({ path, expression: value });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if (typeof value === 'object' && value !== null) {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
for (let i = 0; i < value.length; i++) {
|
|
57
|
+
if (typeof value[i] === 'object' && value[i] !== null) {
|
|
58
|
+
results.push(...extractExpressions(value[i], `${path}[${i}]`));
|
|
59
|
+
}
|
|
60
|
+
else if (typeof value[i] === 'string') {
|
|
61
|
+
const strVal = value[i];
|
|
62
|
+
const exprMatches = strVal.match(/\{\{.*?\}\}/g) || [];
|
|
63
|
+
for (const match of exprMatches) {
|
|
64
|
+
results.push({ path: `${path}[${i}]`, expression: match });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
results.push(...extractExpressions(value, path));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Validate a single expression
|
|
78
|
+
*/
|
|
79
|
+
function validateExpression(expression, nodeNames) {
|
|
80
|
+
const issues = [];
|
|
81
|
+
// Strip wrapper
|
|
82
|
+
let inner = expression;
|
|
83
|
+
if (inner.startsWith('={{')) {
|
|
84
|
+
inner = inner.slice(3).trim();
|
|
85
|
+
}
|
|
86
|
+
else if (inner.startsWith('{{') && inner.endsWith('}}')) {
|
|
87
|
+
inner = inner.slice(2, -2).trim();
|
|
88
|
+
}
|
|
89
|
+
// Check for $json usage (should use explicit reference)
|
|
90
|
+
if (/\$json\./.test(inner) && !/\$\(['"]/.test(inner)) {
|
|
91
|
+
issues.push({
|
|
92
|
+
issue: 'Uses $json instead of explicit node reference',
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
suggestion: "Use $('node_name').item.json.field instead",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// Check for $input usage (acceptable but check context)
|
|
98
|
+
if (/\$input\./.test(inner)) {
|
|
99
|
+
issues.push({
|
|
100
|
+
issue: '$input reference found - ensure this is intentional',
|
|
101
|
+
severity: 'info',
|
|
102
|
+
suggestion: "Consider explicit $('node_name') for clarity",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// Check for node references that don't exist
|
|
106
|
+
const nodeRefMatches = inner.matchAll(/\$\(['"]([^'"]+)['"]\)/g);
|
|
107
|
+
for (const match of nodeRefMatches) {
|
|
108
|
+
const refNodeName = match[1];
|
|
109
|
+
if (!nodeNames.has(refNodeName)) {
|
|
110
|
+
issues.push({
|
|
111
|
+
issue: `References non-existent node "${refNodeName}"`,
|
|
112
|
+
severity: 'error',
|
|
113
|
+
suggestion: `Check if the node exists or was renamed`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Check for common syntax errors
|
|
118
|
+
if (expression.includes('{{') && !expression.includes('}}')) {
|
|
119
|
+
issues.push({
|
|
120
|
+
issue: 'Missing closing }}',
|
|
121
|
+
severity: 'error',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Check for unmatched parentheses
|
|
125
|
+
const openParens = (inner.match(/\(/g) || []).length;
|
|
126
|
+
const closeParens = (inner.match(/\)/g) || []).length;
|
|
127
|
+
if (openParens !== closeParens) {
|
|
128
|
+
issues.push({
|
|
129
|
+
issue: 'Unmatched parentheses',
|
|
130
|
+
severity: 'error',
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// Check for unmatched brackets
|
|
134
|
+
const openBrackets = (inner.match(/\[/g) || []).length;
|
|
135
|
+
const closeBrackets = (inner.match(/\]/g) || []).length;
|
|
136
|
+
if (openBrackets !== closeBrackets) {
|
|
137
|
+
issues.push({
|
|
138
|
+
issue: 'Unmatched brackets',
|
|
139
|
+
severity: 'error',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// Check for deprecated patterns
|
|
143
|
+
if (/\$node\./.test(inner)) {
|
|
144
|
+
issues.push({
|
|
145
|
+
issue: '$node is deprecated',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
suggestion: "Use $('node_name') instead",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Check for potential undefined access
|
|
151
|
+
if (/\.json\.[a-zA-Z_]+\.[a-zA-Z_]+/.test(inner) && !/\?\./g.test(inner)) {
|
|
152
|
+
issues.push({
|
|
153
|
+
issue: 'Deep property access without optional chaining',
|
|
154
|
+
severity: 'info',
|
|
155
|
+
suggestion: 'Consider using ?. for safer access: obj?.nested?.field',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Get all referenced nodes from expressions
|
|
162
|
+
*/
|
|
163
|
+
export function getReferencedNodes(workflow) {
|
|
164
|
+
const references = new Map();
|
|
165
|
+
for (const node of workflow.nodes) {
|
|
166
|
+
const expressions = extractExpressions(node.parameters);
|
|
167
|
+
const refsForNode = [];
|
|
168
|
+
for (const { expression } of expressions) {
|
|
169
|
+
const matches = expression.matchAll(/\$\(['"]([^'"]+)['"]\)/g);
|
|
170
|
+
for (const match of matches) {
|
|
171
|
+
const refName = match[1];
|
|
172
|
+
if (!refsForNode.includes(refName)) {
|
|
173
|
+
refsForNode.push(refName);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (refsForNode.length > 0) {
|
|
178
|
+
references.set(node.name, refsForNode);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return references;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Check for circular references in expressions
|
|
185
|
+
*/
|
|
186
|
+
export function checkCircularReferences(workflow) {
|
|
187
|
+
const references = getReferencedNodes(workflow);
|
|
188
|
+
const cycles = [];
|
|
189
|
+
function findCycle(start, path = []) {
|
|
190
|
+
if (path.includes(start)) {
|
|
191
|
+
const cycleStart = path.indexOf(start);
|
|
192
|
+
cycles.push([...path.slice(cycleStart), start]);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const refs = references.get(start) || [];
|
|
196
|
+
for (const ref of refs) {
|
|
197
|
+
findCycle(ref, [...path, start]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (const nodeName of references.keys()) {
|
|
201
|
+
findCycle(nodeName);
|
|
202
|
+
}
|
|
203
|
+
// Deduplicate cycles
|
|
204
|
+
const uniqueCycles = cycles.filter((cycle, index) => {
|
|
205
|
+
const key = [...cycle].sort().join(',');
|
|
206
|
+
return cycles.findIndex((c) => [...c].sort().join(',') === key) === index;
|
|
207
|
+
});
|
|
208
|
+
return uniqueCycles;
|
|
209
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,9 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextpro
|
|
|
9
9
|
import { N8nClient } from './n8n-client.js';
|
|
10
10
|
import { tools } from './tools.js';
|
|
11
11
|
import { validateWorkflow } from './validators.js';
|
|
12
|
+
import { validateExpressions, checkCircularReferences } from './expressions.js';
|
|
13
|
+
import { autofixWorkflow, formatWorkflow } from './autofix.js';
|
|
14
|
+
import { initVersionControl, saveVersion, listVersions, getVersion, diffWorkflows, getVersionStats, } from './versions.js';
|
|
12
15
|
// ─────────────────────────────────────────────────────────────
|
|
13
16
|
// Configuration
|
|
14
17
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -23,6 +26,11 @@ const client = new N8nClient({
|
|
|
23
26
|
apiUrl: N8N_API_URL,
|
|
24
27
|
apiKey: N8N_API_KEY,
|
|
25
28
|
});
|
|
29
|
+
// Initialize version control
|
|
30
|
+
initVersionControl({
|
|
31
|
+
enabled: process.env.N8N_MCP_VERSIONS !== 'false',
|
|
32
|
+
maxVersions: parseInt(process.env.N8N_MCP_MAX_VERSIONS || '20', 10),
|
|
33
|
+
});
|
|
26
34
|
// ─────────────────────────────────────────────────────────────
|
|
27
35
|
// MCP Server
|
|
28
36
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -114,6 +122,9 @@ async function handleTool(name, args) {
|
|
|
114
122
|
};
|
|
115
123
|
}
|
|
116
124
|
case 'workflow_update': {
|
|
125
|
+
// Save version before updating
|
|
126
|
+
const currentWorkflow = await client.getWorkflow(args.id);
|
|
127
|
+
const versionSaved = await saveVersion(currentWorkflow, 'before_update');
|
|
117
128
|
const operations = args.operations;
|
|
118
129
|
const { workflow, warnings } = await client.patchWorkflow(args.id, operations);
|
|
119
130
|
// Also run validation
|
|
@@ -122,6 +133,7 @@ async function handleTool(name, args) {
|
|
|
122
133
|
workflow,
|
|
123
134
|
patchWarnings: warnings,
|
|
124
135
|
validation,
|
|
136
|
+
versionSaved: versionSaved ? versionSaved.id : null,
|
|
125
137
|
};
|
|
126
138
|
}
|
|
127
139
|
case 'workflow_delete': {
|
|
@@ -164,16 +176,127 @@ async function handleTool(name, args) {
|
|
|
164
176
|
const execution = await client.getExecution(args.id);
|
|
165
177
|
return execution;
|
|
166
178
|
}
|
|
167
|
-
// Validation
|
|
179
|
+
// Validation & Quality
|
|
168
180
|
case 'workflow_validate': {
|
|
169
181
|
const workflow = await client.getWorkflow(args.id);
|
|
170
182
|
const validation = validateWorkflow(workflow);
|
|
183
|
+
const expressionIssues = validateExpressions(workflow);
|
|
184
|
+
const circularRefs = checkCircularReferences(workflow);
|
|
171
185
|
return {
|
|
172
186
|
workflowId: workflow.id,
|
|
173
187
|
workflowName: workflow.name,
|
|
174
188
|
...validation,
|
|
189
|
+
expressionIssues,
|
|
190
|
+
circularReferences: circularRefs.length > 0 ? circularRefs : null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
case 'workflow_autofix': {
|
|
194
|
+
const workflow = await client.getWorkflow(args.id);
|
|
195
|
+
const validation = validateWorkflow(workflow);
|
|
196
|
+
const result = autofixWorkflow(workflow, validation.warnings);
|
|
197
|
+
if (args.apply && result.fixes.length > 0) {
|
|
198
|
+
// Save version before applying fixes
|
|
199
|
+
await saveVersion(workflow, 'before_autofix');
|
|
200
|
+
// Apply the fixed workflow
|
|
201
|
+
await client.updateWorkflow(args.id, result.workflow);
|
|
202
|
+
return {
|
|
203
|
+
applied: true,
|
|
204
|
+
fixes: result.fixes,
|
|
205
|
+
unfixable: result.unfixable,
|
|
206
|
+
workflow: result.workflow,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
applied: false,
|
|
211
|
+
fixes: result.fixes,
|
|
212
|
+
unfixable: result.unfixable,
|
|
213
|
+
previewWorkflow: result.workflow,
|
|
175
214
|
};
|
|
176
215
|
}
|
|
216
|
+
case 'workflow_format': {
|
|
217
|
+
const workflow = await client.getWorkflow(args.id);
|
|
218
|
+
const formatted = formatWorkflow(workflow);
|
|
219
|
+
if (args.apply) {
|
|
220
|
+
await saveVersion(workflow, 'before_format');
|
|
221
|
+
await client.updateWorkflow(args.id, formatted);
|
|
222
|
+
return {
|
|
223
|
+
applied: true,
|
|
224
|
+
workflow: formatted,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
applied: false,
|
|
229
|
+
previewWorkflow: formatted,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// Version Control
|
|
233
|
+
case 'version_list': {
|
|
234
|
+
const versions = await listVersions(args.workflowId);
|
|
235
|
+
return {
|
|
236
|
+
workflowId: args.workflowId,
|
|
237
|
+
versions,
|
|
238
|
+
total: versions.length,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
case 'version_get': {
|
|
242
|
+
const version = await getVersion(args.workflowId, args.versionId);
|
|
243
|
+
if (!version) {
|
|
244
|
+
throw new Error(`Version ${args.versionId} not found`);
|
|
245
|
+
}
|
|
246
|
+
return version;
|
|
247
|
+
}
|
|
248
|
+
case 'version_save': {
|
|
249
|
+
const workflow = await client.getWorkflow(args.workflowId);
|
|
250
|
+
const version = await saveVersion(workflow, args.reason || 'manual');
|
|
251
|
+
if (!version) {
|
|
252
|
+
return { saved: false, message: 'No changes detected since last version' };
|
|
253
|
+
}
|
|
254
|
+
return { saved: true, version };
|
|
255
|
+
}
|
|
256
|
+
case 'version_rollback': {
|
|
257
|
+
const version = await getVersion(args.workflowId, args.versionId);
|
|
258
|
+
if (!version) {
|
|
259
|
+
throw new Error(`Version ${args.versionId} not found`);
|
|
260
|
+
}
|
|
261
|
+
// Save current state before rollback
|
|
262
|
+
const currentWorkflow = await client.getWorkflow(args.workflowId);
|
|
263
|
+
await saveVersion(currentWorkflow, 'before_rollback');
|
|
264
|
+
// Apply the old version
|
|
265
|
+
await client.updateWorkflow(args.workflowId, version.workflow);
|
|
266
|
+
return {
|
|
267
|
+
success: true,
|
|
268
|
+
restoredVersion: version.meta,
|
|
269
|
+
workflow: version.workflow,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
case 'version_diff': {
|
|
273
|
+
const toVersion = await getVersion(args.workflowId, args.toVersionId);
|
|
274
|
+
if (!toVersion) {
|
|
275
|
+
throw new Error(`Version ${args.toVersionId} not found`);
|
|
276
|
+
}
|
|
277
|
+
let fromWorkflow;
|
|
278
|
+
if (args.fromVersionId) {
|
|
279
|
+
const fromVersion = await getVersion(args.workflowId, args.fromVersionId);
|
|
280
|
+
if (!fromVersion) {
|
|
281
|
+
throw new Error(`Version ${args.fromVersionId} not found`);
|
|
282
|
+
}
|
|
283
|
+
fromWorkflow = fromVersion.workflow;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// Compare against current workflow state
|
|
287
|
+
fromWorkflow = await client.getWorkflow(args.workflowId);
|
|
288
|
+
}
|
|
289
|
+
const diff = diffWorkflows(fromWorkflow, toVersion.workflow);
|
|
290
|
+
return {
|
|
291
|
+
from: args.fromVersionId || 'current',
|
|
292
|
+
to: args.toVersionId,
|
|
293
|
+
diff,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
case 'version_stats': {
|
|
297
|
+
const stats = await getVersionStats();
|
|
298
|
+
return stats;
|
|
299
|
+
}
|
|
177
300
|
default:
|
|
178
301
|
throw new Error(`Unknown tool: ${name}`);
|
|
179
302
|
}
|
package/dist/tools.js
CHANGED
|
@@ -236,16 +236,17 @@ Example: { "operations": [{ "type": "updateNode", "nodeName": "my_node", "proper
|
|
|
236
236
|
},
|
|
237
237
|
},
|
|
238
238
|
// ─────────────────────────────────────────────────────────────
|
|
239
|
-
// Validation
|
|
239
|
+
// Validation & Quality
|
|
240
240
|
// ─────────────────────────────────────────────────────────────
|
|
241
241
|
{
|
|
242
242
|
name: 'workflow_validate',
|
|
243
243
|
description: `Validate a workflow against best practices:
|
|
244
244
|
- snake_case naming
|
|
245
245
|
- Explicit node references (no $json)
|
|
246
|
-
- No hardcoded IDs
|
|
247
|
-
- No
|
|
248
|
-
-
|
|
246
|
+
- No hardcoded IDs or secrets
|
|
247
|
+
- No orphan nodes
|
|
248
|
+
- AI node structured output
|
|
249
|
+
- Expression syntax validation`,
|
|
249
250
|
inputSchema: {
|
|
250
251
|
type: 'object',
|
|
251
252
|
properties: {
|
|
@@ -257,4 +258,146 @@ Example: { "operations": [{ "type": "updateNode", "nodeName": "my_node", "proper
|
|
|
257
258
|
required: ['id'],
|
|
258
259
|
},
|
|
259
260
|
},
|
|
261
|
+
{
|
|
262
|
+
name: 'workflow_autofix',
|
|
263
|
+
description: `Auto-fix common validation issues:
|
|
264
|
+
- Convert names to snake_case
|
|
265
|
+
- Replace $json with explicit node references
|
|
266
|
+
- Add AI structured output settings
|
|
267
|
+
|
|
268
|
+
Returns the fixed workflow and list of changes made.`,
|
|
269
|
+
inputSchema: {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
id: {
|
|
273
|
+
type: 'string',
|
|
274
|
+
description: 'Workflow ID to fix',
|
|
275
|
+
},
|
|
276
|
+
apply: {
|
|
277
|
+
type: 'boolean',
|
|
278
|
+
description: 'Apply fixes to n8n (default: false, dry-run)',
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
required: ['id'],
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'workflow_format',
|
|
286
|
+
description: 'Format a workflow: sort nodes by position, clean up null values.',
|
|
287
|
+
inputSchema: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
id: {
|
|
291
|
+
type: 'string',
|
|
292
|
+
description: 'Workflow ID to format',
|
|
293
|
+
},
|
|
294
|
+
apply: {
|
|
295
|
+
type: 'boolean',
|
|
296
|
+
description: 'Apply formatting to n8n (default: false)',
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
required: ['id'],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
// ─────────────────────────────────────────────────────────────
|
|
303
|
+
// Version Control
|
|
304
|
+
// ─────────────────────────────────────────────────────────────
|
|
305
|
+
{
|
|
306
|
+
name: 'version_list',
|
|
307
|
+
description: 'List saved versions of a workflow (local snapshots).',
|
|
308
|
+
inputSchema: {
|
|
309
|
+
type: 'object',
|
|
310
|
+
properties: {
|
|
311
|
+
workflowId: {
|
|
312
|
+
type: 'string',
|
|
313
|
+
description: 'Workflow ID',
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
required: ['workflowId'],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'version_get',
|
|
321
|
+
description: 'Get a specific saved version of a workflow.',
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: 'object',
|
|
324
|
+
properties: {
|
|
325
|
+
workflowId: {
|
|
326
|
+
type: 'string',
|
|
327
|
+
description: 'Workflow ID',
|
|
328
|
+
},
|
|
329
|
+
versionId: {
|
|
330
|
+
type: 'string',
|
|
331
|
+
description: 'Version ID (from version_list)',
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
required: ['workflowId', 'versionId'],
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: 'version_save',
|
|
339
|
+
description: 'Manually save a version snapshot of a workflow.',
|
|
340
|
+
inputSchema: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
properties: {
|
|
343
|
+
workflowId: {
|
|
344
|
+
type: 'string',
|
|
345
|
+
description: 'Workflow ID',
|
|
346
|
+
},
|
|
347
|
+
reason: {
|
|
348
|
+
type: 'string',
|
|
349
|
+
description: 'Reason for saving (default: "manual")',
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
required: ['workflowId'],
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: 'version_rollback',
|
|
357
|
+
description: 'Restore a workflow to a previous version.',
|
|
358
|
+
inputSchema: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {
|
|
361
|
+
workflowId: {
|
|
362
|
+
type: 'string',
|
|
363
|
+
description: 'Workflow ID',
|
|
364
|
+
},
|
|
365
|
+
versionId: {
|
|
366
|
+
type: 'string',
|
|
367
|
+
description: 'Version ID to restore',
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
required: ['workflowId', 'versionId'],
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
name: 'version_diff',
|
|
375
|
+
description: 'Compare two versions of a workflow or current state vs a version.',
|
|
376
|
+
inputSchema: {
|
|
377
|
+
type: 'object',
|
|
378
|
+
properties: {
|
|
379
|
+
workflowId: {
|
|
380
|
+
type: 'string',
|
|
381
|
+
description: 'Workflow ID',
|
|
382
|
+
},
|
|
383
|
+
fromVersionId: {
|
|
384
|
+
type: 'string',
|
|
385
|
+
description: 'First version ID (omit for current workflow state)',
|
|
386
|
+
},
|
|
387
|
+
toVersionId: {
|
|
388
|
+
type: 'string',
|
|
389
|
+
description: 'Second version ID',
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
required: ['workflowId', 'toVersionId'],
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
name: 'version_stats',
|
|
397
|
+
description: 'Get version control statistics.',
|
|
398
|
+
inputSchema: {
|
|
399
|
+
type: 'object',
|
|
400
|
+
properties: {},
|
|
401
|
+
},
|
|
402
|
+
},
|
|
260
403
|
];
|
package/dist/validators.js
CHANGED
|
@@ -26,6 +26,12 @@ function validateNode(node, warnings) {
|
|
|
26
26
|
checkForHardcodedIds(node, warnings);
|
|
27
27
|
// Check for hardcoded secrets
|
|
28
28
|
checkForHardcodedSecrets(node, warnings);
|
|
29
|
+
// Check for code node usage (should be last resort)
|
|
30
|
+
checkForCodeNodeUsage(node, warnings);
|
|
31
|
+
// Check for AI node structured output settings
|
|
32
|
+
checkForAIStructuredOutput(node, warnings);
|
|
33
|
+
// Check for in-memory storage (non-persistent)
|
|
34
|
+
checkForInMemoryStorage(node, warnings);
|
|
29
35
|
}
|
|
30
36
|
function validateSnakeCase(name, context, warnings) {
|
|
31
37
|
// Allow spaces for readability, but check the underlying format
|
|
@@ -107,6 +113,67 @@ function checkForHardcodedSecrets(node, warnings) {
|
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
}
|
|
116
|
+
function checkForCodeNodeUsage(node, warnings) {
|
|
117
|
+
// Detect code nodes - they should be last resort
|
|
118
|
+
const codeNodeTypes = [
|
|
119
|
+
'n8n-nodes-base.code',
|
|
120
|
+
'n8n-nodes-base.function',
|
|
121
|
+
'n8n-nodes-base.functionItem',
|
|
122
|
+
];
|
|
123
|
+
if (codeNodeTypes.some((t) => node.type.includes(t))) {
|
|
124
|
+
warnings.push({
|
|
125
|
+
node: node.name,
|
|
126
|
+
rule: 'code_node_usage',
|
|
127
|
+
message: `Node "${node.name}" is a code node - ensure built-in nodes can't achieve this`,
|
|
128
|
+
severity: 'info',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function checkForAIStructuredOutput(node, warnings) {
|
|
133
|
+
// Check AI/LLM nodes for structured output settings
|
|
134
|
+
const aiNodeTypes = [
|
|
135
|
+
'langchain.agent',
|
|
136
|
+
'langchain.chainLlm',
|
|
137
|
+
'langchain.lmChatOpenAi',
|
|
138
|
+
'langchain.lmChatAnthropic',
|
|
139
|
+
'langchain.lmChatGoogleGemini',
|
|
140
|
+
];
|
|
141
|
+
const isAINode = aiNodeTypes.some((t) => node.type.toLowerCase().includes(t.toLowerCase()));
|
|
142
|
+
if (!isAINode)
|
|
143
|
+
return;
|
|
144
|
+
// Check for structured output settings
|
|
145
|
+
const params = node.parameters;
|
|
146
|
+
const hasPromptType = params.promptType === 'define';
|
|
147
|
+
const hasOutputParser = params.hasOutputParser === true;
|
|
148
|
+
// Only warn if it looks like they want structured output but missed settings
|
|
149
|
+
if (params.outputParser || params.schemaType) {
|
|
150
|
+
if (!hasPromptType || !hasOutputParser) {
|
|
151
|
+
warnings.push({
|
|
152
|
+
node: node.name,
|
|
153
|
+
rule: 'ai_structured_output',
|
|
154
|
+
message: `Node "${node.name}" may need promptType: "define" and hasOutputParser: true for reliable structured output`,
|
|
155
|
+
severity: 'warning',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function checkForInMemoryStorage(node, warnings) {
|
|
161
|
+
// Detect in-memory storage nodes that don't persist across restarts
|
|
162
|
+
const inMemoryTypes = [
|
|
163
|
+
'memoryBufferWindow',
|
|
164
|
+
'memoryVectorStore',
|
|
165
|
+
'vectorStoreInMemory',
|
|
166
|
+
];
|
|
167
|
+
const isInMemory = inMemoryTypes.some((t) => node.type.toLowerCase().includes(t.toLowerCase()));
|
|
168
|
+
if (isInMemory) {
|
|
169
|
+
warnings.push({
|
|
170
|
+
node: node.name,
|
|
171
|
+
rule: 'in_memory_storage',
|
|
172
|
+
message: `Node "${node.name}" uses in-memory storage - consider Postgres for production persistence`,
|
|
173
|
+
severity: 'warning',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
110
177
|
function validateConnections(workflow, warnings) {
|
|
111
178
|
const connectedNodes = new Set();
|
|
112
179
|
// Collect all nodes that have connections (as source or target)
|