@pagelines/n8n-mcp 0.1.0 → 0.2.0

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.
@@ -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 hardcoded secrets
248
- - No orphan nodes`,
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
  ];
@@ -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)