@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.
package/src/index.ts CHANGED
@@ -14,6 +14,16 @@ import {
14
14
  import { N8nClient } from './n8n-client.js';
15
15
  import { tools } from './tools.js';
16
16
  import { validateWorkflow } from './validators.js';
17
+ import { validateExpressions, checkCircularReferences } from './expressions.js';
18
+ import { autofixWorkflow, formatWorkflow } from './autofix.js';
19
+ import {
20
+ initVersionControl,
21
+ saveVersion,
22
+ listVersions,
23
+ getVersion,
24
+ diffWorkflows,
25
+ getVersionStats,
26
+ } from './versions.js';
17
27
  import type { PatchOperation, N8nConnections } from './types.js';
18
28
 
19
29
  // ─────────────────────────────────────────────────────────────
@@ -34,6 +44,12 @@ const client = new N8nClient({
34
44
  apiKey: N8N_API_KEY,
35
45
  });
36
46
 
47
+ // Initialize version control
48
+ initVersionControl({
49
+ enabled: process.env.N8N_MCP_VERSIONS !== 'false',
50
+ maxVersions: parseInt(process.env.N8N_MCP_MAX_VERSIONS || '20', 10),
51
+ });
52
+
37
53
  // ─────────────────────────────────────────────────────────────
38
54
  // MCP Server
39
55
  // ─────────────────────────────────────────────────────────────
@@ -146,6 +162,10 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
146
162
  }
147
163
 
148
164
  case 'workflow_update': {
165
+ // Save version before updating
166
+ const currentWorkflow = await client.getWorkflow(args.id as string);
167
+ const versionSaved = await saveVersion(currentWorkflow, 'before_update');
168
+
149
169
  const operations = args.operations as PatchOperation[];
150
170
  const { workflow, warnings } = await client.patchWorkflow(
151
171
  args.id as string,
@@ -159,6 +179,7 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
159
179
  workflow,
160
180
  patchWarnings: warnings,
161
181
  validation,
182
+ versionSaved: versionSaved ? versionSaved.id : null,
162
183
  };
163
184
  }
164
185
 
@@ -211,17 +232,164 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
211
232
  return execution;
212
233
  }
213
234
 
214
- // Validation
235
+ // Validation & Quality
215
236
  case 'workflow_validate': {
216
237
  const workflow = await client.getWorkflow(args.id as string);
217
238
  const validation = validateWorkflow(workflow);
239
+ const expressionIssues = validateExpressions(workflow);
240
+ const circularRefs = checkCircularReferences(workflow);
241
+
218
242
  return {
219
243
  workflowId: workflow.id,
220
244
  workflowName: workflow.name,
221
245
  ...validation,
246
+ expressionIssues,
247
+ circularReferences: circularRefs.length > 0 ? circularRefs : null,
248
+ };
249
+ }
250
+
251
+ case 'workflow_autofix': {
252
+ const workflow = await client.getWorkflow(args.id as string);
253
+ const validation = validateWorkflow(workflow);
254
+ const result = autofixWorkflow(workflow, validation.warnings);
255
+
256
+ if (args.apply && result.fixes.length > 0) {
257
+ // Save version before applying fixes
258
+ await saveVersion(workflow, 'before_autofix');
259
+
260
+ // Apply the fixed workflow
261
+ await client.updateWorkflow(args.id as string, result.workflow);
262
+
263
+ return {
264
+ applied: true,
265
+ fixes: result.fixes,
266
+ unfixable: result.unfixable,
267
+ workflow: result.workflow,
268
+ };
269
+ }
270
+
271
+ return {
272
+ applied: false,
273
+ fixes: result.fixes,
274
+ unfixable: result.unfixable,
275
+ previewWorkflow: result.workflow,
276
+ };
277
+ }
278
+
279
+ case 'workflow_format': {
280
+ const workflow = await client.getWorkflow(args.id as string);
281
+ const formatted = formatWorkflow(workflow);
282
+
283
+ if (args.apply) {
284
+ await saveVersion(workflow, 'before_format');
285
+ await client.updateWorkflow(args.id as string, formatted);
286
+
287
+ return {
288
+ applied: true,
289
+ workflow: formatted,
290
+ };
291
+ }
292
+
293
+ return {
294
+ applied: false,
295
+ previewWorkflow: formatted,
296
+ };
297
+ }
298
+
299
+ // Version Control
300
+ case 'version_list': {
301
+ const versions = await listVersions(args.workflowId as string);
302
+ return {
303
+ workflowId: args.workflowId,
304
+ versions,
305
+ total: versions.length,
222
306
  };
223
307
  }
224
308
 
309
+ case 'version_get': {
310
+ const version = await getVersion(
311
+ args.workflowId as string,
312
+ args.versionId as string
313
+ );
314
+ if (!version) {
315
+ throw new Error(`Version ${args.versionId} not found`);
316
+ }
317
+ return version;
318
+ }
319
+
320
+ case 'version_save': {
321
+ const workflow = await client.getWorkflow(args.workflowId as string);
322
+ const version = await saveVersion(
323
+ workflow,
324
+ (args.reason as string) || 'manual'
325
+ );
326
+ if (!version) {
327
+ return { saved: false, message: 'No changes detected since last version' };
328
+ }
329
+ return { saved: true, version };
330
+ }
331
+
332
+ case 'version_rollback': {
333
+ const version = await getVersion(
334
+ args.workflowId as string,
335
+ args.versionId as string
336
+ );
337
+ if (!version) {
338
+ throw new Error(`Version ${args.versionId} not found`);
339
+ }
340
+
341
+ // Save current state before rollback
342
+ const currentWorkflow = await client.getWorkflow(args.workflowId as string);
343
+ await saveVersion(currentWorkflow, 'before_rollback');
344
+
345
+ // Apply the old version
346
+ await client.updateWorkflow(args.workflowId as string, version.workflow);
347
+
348
+ return {
349
+ success: true,
350
+ restoredVersion: version.meta,
351
+ workflow: version.workflow,
352
+ };
353
+ }
354
+
355
+ case 'version_diff': {
356
+ const toVersion = await getVersion(
357
+ args.workflowId as string,
358
+ args.toVersionId as string
359
+ );
360
+ if (!toVersion) {
361
+ throw new Error(`Version ${args.toVersionId} not found`);
362
+ }
363
+
364
+ let fromWorkflow;
365
+ if (args.fromVersionId) {
366
+ const fromVersion = await getVersion(
367
+ args.workflowId as string,
368
+ args.fromVersionId as string
369
+ );
370
+ if (!fromVersion) {
371
+ throw new Error(`Version ${args.fromVersionId} not found`);
372
+ }
373
+ fromWorkflow = fromVersion.workflow;
374
+ } else {
375
+ // Compare against current workflow state
376
+ fromWorkflow = await client.getWorkflow(args.workflowId as string);
377
+ }
378
+
379
+ const diff = diffWorkflows(fromWorkflow, toVersion.workflow);
380
+
381
+ return {
382
+ from: args.fromVersionId || 'current',
383
+ to: args.toVersionId,
384
+ diff,
385
+ };
386
+ }
387
+
388
+ case 'version_stats': {
389
+ const stats = await getVersionStats();
390
+ return stats;
391
+ }
392
+
225
393
  default:
226
394
  throw new Error(`Unknown tool: ${name}`);
227
395
  }
package/src/tools.ts CHANGED
@@ -249,16 +249,17 @@ Example: { "operations": [{ "type": "updateNode", "nodeName": "my_node", "proper
249
249
  },
250
250
 
251
251
  // ─────────────────────────────────────────────────────────────
252
- // Validation
252
+ // Validation & Quality
253
253
  // ─────────────────────────────────────────────────────────────
254
254
  {
255
255
  name: 'workflow_validate',
256
256
  description: `Validate a workflow against best practices:
257
257
  - snake_case naming
258
258
  - Explicit node references (no $json)
259
- - No hardcoded IDs
260
- - No hardcoded secrets
261
- - No orphan nodes`,
259
+ - No hardcoded IDs or secrets
260
+ - No orphan nodes
261
+ - AI node structured output
262
+ - Expression syntax validation`,
262
263
  inputSchema: {
263
264
  type: 'object',
264
265
  properties: {
@@ -270,4 +271,154 @@ Example: { "operations": [{ "type": "updateNode", "nodeName": "my_node", "proper
270
271
  required: ['id'],
271
272
  },
272
273
  },
274
+
275
+ {
276
+ name: 'workflow_autofix',
277
+ description: `Auto-fix common validation issues:
278
+ - Convert names to snake_case
279
+ - Replace $json with explicit node references
280
+ - Add AI structured output settings
281
+
282
+ Returns the fixed workflow and list of changes made.`,
283
+ inputSchema: {
284
+ type: 'object',
285
+ properties: {
286
+ id: {
287
+ type: 'string',
288
+ description: 'Workflow ID to fix',
289
+ },
290
+ apply: {
291
+ type: 'boolean',
292
+ description: 'Apply fixes to n8n (default: false, dry-run)',
293
+ },
294
+ },
295
+ required: ['id'],
296
+ },
297
+ },
298
+
299
+ {
300
+ name: 'workflow_format',
301
+ description: 'Format a workflow: sort nodes by position, clean up null values.',
302
+ inputSchema: {
303
+ type: 'object',
304
+ properties: {
305
+ id: {
306
+ type: 'string',
307
+ description: 'Workflow ID to format',
308
+ },
309
+ apply: {
310
+ type: 'boolean',
311
+ description: 'Apply formatting to n8n (default: false)',
312
+ },
313
+ },
314
+ required: ['id'],
315
+ },
316
+ },
317
+
318
+ // ─────────────────────────────────────────────────────────────
319
+ // Version Control
320
+ // ─────────────────────────────────────────────────────────────
321
+ {
322
+ name: 'version_list',
323
+ description: 'List saved versions of a workflow (local snapshots).',
324
+ inputSchema: {
325
+ type: 'object',
326
+ properties: {
327
+ workflowId: {
328
+ type: 'string',
329
+ description: 'Workflow ID',
330
+ },
331
+ },
332
+ required: ['workflowId'],
333
+ },
334
+ },
335
+
336
+ {
337
+ name: 'version_get',
338
+ description: 'Get a specific saved version of a workflow.',
339
+ inputSchema: {
340
+ type: 'object',
341
+ properties: {
342
+ workflowId: {
343
+ type: 'string',
344
+ description: 'Workflow ID',
345
+ },
346
+ versionId: {
347
+ type: 'string',
348
+ description: 'Version ID (from version_list)',
349
+ },
350
+ },
351
+ required: ['workflowId', 'versionId'],
352
+ },
353
+ },
354
+
355
+ {
356
+ name: 'version_save',
357
+ description: 'Manually save a version snapshot of a workflow.',
358
+ inputSchema: {
359
+ type: 'object',
360
+ properties: {
361
+ workflowId: {
362
+ type: 'string',
363
+ description: 'Workflow ID',
364
+ },
365
+ reason: {
366
+ type: 'string',
367
+ description: 'Reason for saving (default: "manual")',
368
+ },
369
+ },
370
+ required: ['workflowId'],
371
+ },
372
+ },
373
+
374
+ {
375
+ name: 'version_rollback',
376
+ description: 'Restore a workflow to a previous version.',
377
+ inputSchema: {
378
+ type: 'object',
379
+ properties: {
380
+ workflowId: {
381
+ type: 'string',
382
+ description: 'Workflow ID',
383
+ },
384
+ versionId: {
385
+ type: 'string',
386
+ description: 'Version ID to restore',
387
+ },
388
+ },
389
+ required: ['workflowId', 'versionId'],
390
+ },
391
+ },
392
+
393
+ {
394
+ name: 'version_diff',
395
+ description: 'Compare two versions of a workflow or current state vs a version.',
396
+ inputSchema: {
397
+ type: 'object',
398
+ properties: {
399
+ workflowId: {
400
+ type: 'string',
401
+ description: 'Workflow ID',
402
+ },
403
+ fromVersionId: {
404
+ type: 'string',
405
+ description: 'First version ID (omit for current workflow state)',
406
+ },
407
+ toVersionId: {
408
+ type: 'string',
409
+ description: 'Second version ID',
410
+ },
411
+ },
412
+ required: ['workflowId', 'toVersionId'],
413
+ },
414
+ },
415
+
416
+ {
417
+ name: 'version_stats',
418
+ description: 'Get version control statistics.',
419
+ inputSchema: {
420
+ type: 'object',
421
+ properties: {},
422
+ },
423
+ },
273
424
  ];
@@ -115,6 +115,103 @@ describe('validateWorkflow', () => {
115
115
  })
116
116
  );
117
117
  });
118
+
119
+ it('info on code node usage', () => {
120
+ const workflow = createWorkflow({
121
+ nodes: [
122
+ {
123
+ id: '1',
124
+ name: 'my_code',
125
+ type: 'n8n-nodes-base.code',
126
+ typeVersion: 1,
127
+ position: [0, 0],
128
+ parameters: { jsCode: 'return items;' },
129
+ },
130
+ ],
131
+ });
132
+
133
+ const result = validateWorkflow(workflow);
134
+ expect(result.warnings).toContainEqual(
135
+ expect.objectContaining({
136
+ rule: 'code_node_usage',
137
+ severity: 'info',
138
+ })
139
+ );
140
+ });
141
+
142
+ it('warns on AI node without structured output settings', () => {
143
+ const workflow = createWorkflow({
144
+ nodes: [
145
+ {
146
+ id: '1',
147
+ name: 'ai_agent',
148
+ type: '@n8n/n8n-nodes-langchain.agent',
149
+ typeVersion: 1,
150
+ position: [0, 0],
151
+ parameters: {
152
+ outputParser: true,
153
+ schemaType: 'manual',
154
+ // Missing promptType: 'define' and hasOutputParser: true
155
+ },
156
+ },
157
+ ],
158
+ });
159
+
160
+ const result = validateWorkflow(workflow);
161
+ expect(result.warnings).toContainEqual(
162
+ expect.objectContaining({
163
+ rule: 'ai_structured_output',
164
+ severity: 'warning',
165
+ })
166
+ );
167
+ });
168
+
169
+ it('passes AI node with correct structured output settings', () => {
170
+ const workflow = createWorkflow({
171
+ nodes: [
172
+ {
173
+ id: '1',
174
+ name: 'ai_agent',
175
+ type: '@n8n/n8n-nodes-langchain.agent',
176
+ typeVersion: 1,
177
+ position: [0, 0],
178
+ parameters: {
179
+ outputParser: true,
180
+ schemaType: 'manual',
181
+ promptType: 'define',
182
+ hasOutputParser: true,
183
+ },
184
+ },
185
+ ],
186
+ });
187
+
188
+ const result = validateWorkflow(workflow);
189
+ const aiWarnings = result.warnings.filter((w) => w.rule === 'ai_structured_output');
190
+ expect(aiWarnings).toHaveLength(0);
191
+ });
192
+
193
+ it('warns on in-memory storage nodes', () => {
194
+ const workflow = createWorkflow({
195
+ nodes: [
196
+ {
197
+ id: '1',
198
+ name: 'memory_buffer',
199
+ type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
200
+ typeVersion: 1,
201
+ position: [0, 0],
202
+ parameters: {},
203
+ },
204
+ ],
205
+ });
206
+
207
+ const result = validateWorkflow(workflow);
208
+ expect(result.warnings).toContainEqual(
209
+ expect.objectContaining({
210
+ rule: 'in_memory_storage',
211
+ severity: 'warning',
212
+ })
213
+ );
214
+ });
118
215
  });
119
216
 
120
217
  describe('validatePartialUpdate', () => {
package/src/validators.ts CHANGED
@@ -37,6 +37,15 @@ function validateNode(node: N8nNode, warnings: ValidationWarning[]): void {
37
37
 
38
38
  // Check for hardcoded secrets
39
39
  checkForHardcodedSecrets(node, warnings);
40
+
41
+ // Check for code node usage (should be last resort)
42
+ checkForCodeNodeUsage(node, warnings);
43
+
44
+ // Check for AI node structured output settings
45
+ checkForAIStructuredOutput(node, warnings);
46
+
47
+ // Check for in-memory storage (non-persistent)
48
+ checkForInMemoryStorage(node, warnings);
40
49
  }
41
50
 
42
51
  function validateSnakeCase(name: string, context: string, warnings: ValidationWarning[]): void {
@@ -132,6 +141,74 @@ function checkForHardcodedSecrets(node: N8nNode, warnings: ValidationWarning[]):
132
141
  }
133
142
  }
134
143
 
144
+ function checkForCodeNodeUsage(node: N8nNode, warnings: ValidationWarning[]): void {
145
+ // Detect code nodes - they should be last resort
146
+ const codeNodeTypes = [
147
+ 'n8n-nodes-base.code',
148
+ 'n8n-nodes-base.function',
149
+ 'n8n-nodes-base.functionItem',
150
+ ];
151
+
152
+ if (codeNodeTypes.some((t) => node.type.includes(t))) {
153
+ warnings.push({
154
+ node: node.name,
155
+ rule: 'code_node_usage',
156
+ message: `Node "${node.name}" is a code node - ensure built-in nodes can't achieve this`,
157
+ severity: 'info',
158
+ });
159
+ }
160
+ }
161
+
162
+ function checkForAIStructuredOutput(node: N8nNode, warnings: ValidationWarning[]): void {
163
+ // Check AI/LLM nodes for structured output settings
164
+ const aiNodeTypes = [
165
+ 'langchain.agent',
166
+ 'langchain.chainLlm',
167
+ 'langchain.lmChatOpenAi',
168
+ 'langchain.lmChatAnthropic',
169
+ 'langchain.lmChatGoogleGemini',
170
+ ];
171
+
172
+ const isAINode = aiNodeTypes.some((t) => node.type.toLowerCase().includes(t.toLowerCase()));
173
+ if (!isAINode) return;
174
+
175
+ // Check for structured output settings
176
+ const params = node.parameters as Record<string, unknown>;
177
+ const hasPromptType = params.promptType === 'define';
178
+ const hasOutputParser = params.hasOutputParser === true;
179
+
180
+ // Only warn if it looks like they want structured output but missed settings
181
+ if (params.outputParser || params.schemaType) {
182
+ if (!hasPromptType || !hasOutputParser) {
183
+ warnings.push({
184
+ node: node.name,
185
+ rule: 'ai_structured_output',
186
+ message: `Node "${node.name}" may need promptType: "define" and hasOutputParser: true for reliable structured output`,
187
+ severity: 'warning',
188
+ });
189
+ }
190
+ }
191
+ }
192
+
193
+ function checkForInMemoryStorage(node: N8nNode, warnings: ValidationWarning[]): void {
194
+ // Detect in-memory storage nodes that don't persist across restarts
195
+ const inMemoryTypes = [
196
+ 'memoryBufferWindow',
197
+ 'memoryVectorStore',
198
+ 'vectorStoreInMemory',
199
+ ];
200
+
201
+ const isInMemory = inMemoryTypes.some((t) => node.type.toLowerCase().includes(t.toLowerCase()));
202
+ if (isInMemory) {
203
+ warnings.push({
204
+ node: node.name,
205
+ rule: 'in_memory_storage',
206
+ message: `Node "${node.name}" uses in-memory storage - consider Postgres for production persistence`,
207
+ severity: 'warning',
208
+ });
209
+ }
210
+ }
211
+
135
212
  function validateConnections(workflow: N8nWorkflow, warnings: ValidationWarning[]): void {
136
213
  const connectedNodes = new Set<string>();
137
214