@pagelines/n8n-mcp 0.2.1 → 0.3.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
@@ -13,7 +13,7 @@ import {
13
13
 
14
14
  import { N8nClient } from './n8n-client.js';
15
15
  import { tools } from './tools.js';
16
- import { validateWorkflow } from './validators.js';
16
+ import { validateWorkflow, validateNodeTypes } from './validators.js';
17
17
  import { validateExpressions, checkCircularReferences } from './expressions.js';
18
18
  import { autofixWorkflow, formatWorkflow } from './autofix.js';
19
19
  import {
@@ -24,7 +24,14 @@ import {
24
24
  diffWorkflows,
25
25
  getVersionStats,
26
26
  } from './versions.js';
27
- import type { PatchOperation, N8nConnections } from './types.js';
27
+ import {
28
+ formatWorkflowResponse,
29
+ formatExecutionResponse,
30
+ formatExecutionListResponse,
31
+ stringifyResponse,
32
+ type ResponseFormat,
33
+ } from './response-format.js';
34
+ import type { PatchOperation, N8nConnections, N8nNodeTypeSummary } from './types.js';
28
35
 
29
36
  // ─────────────────────────────────────────────────────────────
30
37
  // Configuration
@@ -57,7 +64,7 @@ initVersionControl({
57
64
  const server = new Server(
58
65
  {
59
66
  name: '@pagelines/n8n-mcp',
60
- version: '0.1.0',
67
+ version: '0.3.0',
61
68
  },
62
69
  {
63
70
  capabilities: {
@@ -81,7 +88,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
81
88
  content: [
82
89
  {
83
90
  type: 'text',
84
- text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
91
+ // Use minified JSON to reduce token usage
92
+ text: typeof result === 'string' ? result : stringifyResponse(result),
85
93
  },
86
94
  ],
87
95
  };
@@ -124,18 +132,40 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
124
132
 
125
133
  case 'workflow_get': {
126
134
  const workflow = await client.getWorkflow(args.id as string);
127
- return workflow;
135
+ const format = (args.format as ResponseFormat) || 'compact';
136
+ return formatWorkflowResponse(workflow, format);
128
137
  }
129
138
 
130
139
  case 'workflow_create': {
131
- const nodes = (args.nodes as Array<{
140
+ const inputNodes = args.nodes as Array<{
132
141
  name: string;
133
142
  type: string;
134
143
  typeVersion: number;
135
144
  position: [number, number];
136
145
  parameters: Record<string, unknown>;
137
146
  credentials?: Record<string, { id: string; name: string }>;
138
- }>).map((n, i) => ({
147
+ }>;
148
+
149
+ // Validate node types BEFORE creating workflow
150
+ const availableTypes = await client.listNodeTypes();
151
+ const validTypeSet = new Set(availableTypes.map((nt) => nt.name));
152
+ const typeErrors = validateNodeTypes(inputNodes, validTypeSet);
153
+
154
+ if (typeErrors.length > 0) {
155
+ const errorMessages = typeErrors.map((e) => {
156
+ let msg = e.message;
157
+ if (e.suggestions && e.suggestions.length > 0) {
158
+ msg += `. Did you mean: ${e.suggestions.join(', ')}?`;
159
+ }
160
+ return msg;
161
+ });
162
+ throw new Error(
163
+ `Invalid node types detected:\n${errorMessages.join('\n')}\n\n` +
164
+ `Use node_types_list to discover available node types.`
165
+ );
166
+ }
167
+
168
+ const nodes = inputNodes.map((n, i) => ({
139
169
  id: crypto.randomUUID(),
140
170
  name: n.name,
141
171
  type: n.type,
@@ -145,40 +175,100 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
145
175
  ...(n.credentials && { credentials: n.credentials }),
146
176
  }));
147
177
 
148
- const workflow = await client.createWorkflow({
178
+ let workflow = await client.createWorkflow({
149
179
  name: args.name as string,
150
180
  nodes,
151
181
  connections: (args.connections as N8nConnections) || {},
152
182
  settings: args.settings as Record<string, unknown>,
153
183
  });
154
184
 
155
- // Validate the new workflow
185
+ // Validate and auto-cleanup
156
186
  const validation = validateWorkflow(workflow);
187
+ const autofix = autofixWorkflow(workflow, validation.warnings);
188
+ let formatted = formatWorkflow(autofix.workflow);
189
+
190
+ // Apply cleanup if there were fixes or formatting changes
191
+ if (autofix.fixes.length > 0 || JSON.stringify(workflow) !== JSON.stringify(formatted)) {
192
+ workflow = await client.updateWorkflow(workflow.id, formatted);
193
+ formatted = workflow;
194
+ }
195
+
196
+ const format = (args.format as ResponseFormat) || 'compact';
157
197
 
158
198
  return {
159
- workflow,
160
- validation,
199
+ workflow: formatWorkflowResponse(formatted, format),
200
+ validation: {
201
+ ...validation,
202
+ warnings: autofix.unfixable, // Only show unfixable warnings
203
+ },
204
+ autoFixed: autofix.fixes.length > 0 ? autofix.fixes : undefined,
161
205
  };
162
206
  }
163
207
 
164
208
  case 'workflow_update': {
209
+ const operations = args.operations as PatchOperation[];
210
+
211
+ // Extract addNode operations that need validation
212
+ const addNodeOps = operations.filter(
213
+ (op): op is Extract<PatchOperation, { type: 'addNode' }> =>
214
+ op.type === 'addNode'
215
+ );
216
+
217
+ if (addNodeOps.length > 0) {
218
+ // Fetch available types and validate
219
+ const availableTypes = await client.listNodeTypes();
220
+ const validTypeSet = new Set(availableTypes.map((nt) => nt.name));
221
+ const nodesToValidate = addNodeOps.map((op) => ({
222
+ name: op.node.name,
223
+ type: op.node.type,
224
+ }));
225
+ const typeErrors = validateNodeTypes(nodesToValidate, validTypeSet);
226
+
227
+ if (typeErrors.length > 0) {
228
+ const errorMessages = typeErrors.map((e) => {
229
+ let msg = e.message;
230
+ if (e.suggestions && e.suggestions.length > 0) {
231
+ msg += `. Did you mean: ${e.suggestions.join(', ')}?`;
232
+ }
233
+ return msg;
234
+ });
235
+ throw new Error(
236
+ `Invalid node types in addNode operations:\n${errorMessages.join('\n')}\n\n` +
237
+ `Use node_types_list to discover available node types.`
238
+ );
239
+ }
240
+ }
241
+
165
242
  // Save version before updating
166
243
  const currentWorkflow = await client.getWorkflow(args.id as string);
167
244
  const versionSaved = await saveVersion(currentWorkflow, 'before_update');
168
245
 
169
- const operations = args.operations as PatchOperation[];
170
- const { workflow, warnings } = await client.patchWorkflow(
246
+ let { workflow, warnings } = await client.patchWorkflow(
171
247
  args.id as string,
172
248
  operations
173
249
  );
174
250
 
175
- // Also run validation
251
+ // Validate and auto-cleanup
176
252
  const validation = validateWorkflow(workflow);
253
+ const autofix = autofixWorkflow(workflow, validation.warnings);
254
+ let formatted = formatWorkflow(autofix.workflow);
255
+
256
+ // Apply cleanup if there were fixes or formatting changes
257
+ if (autofix.fixes.length > 0 || JSON.stringify(workflow) !== JSON.stringify(formatted)) {
258
+ workflow = await client.updateWorkflow(args.id as string, formatted);
259
+ formatted = workflow;
260
+ }
261
+
262
+ const format = (args.format as ResponseFormat) || 'compact';
177
263
 
178
264
  return {
179
- workflow,
265
+ workflow: formatWorkflowResponse(formatted, format),
180
266
  patchWarnings: warnings,
181
- validation,
267
+ validation: {
268
+ ...validation,
269
+ warnings: autofix.unfixable, // Only show unfixable warnings
270
+ },
271
+ autoFixed: autofix.fixes.length > 0 ? autofix.fixes : undefined,
182
272
  versionSaved: versionSaved ? versionSaved.id : null,
183
273
  };
184
274
  }
@@ -221,15 +311,17 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
221
311
  status: args.status as 'success' | 'error' | 'waiting' | undefined,
222
312
  limit: (args.limit as number) || 20,
223
313
  });
314
+ const format = (args.format as ResponseFormat) || 'compact';
224
315
  return {
225
- executions: response.data,
316
+ executions: formatExecutionListResponse(response.data, format),
226
317
  total: response.data.length,
227
318
  };
228
319
  }
229
320
 
230
321
  case 'execution_get': {
231
322
  const execution = await client.getExecution(args.id as string);
232
- return execution;
323
+ const format = (args.format as ResponseFormat) || 'compact';
324
+ return formatExecutionResponse(execution, format);
233
325
  }
234
326
 
235
327
  // Validation & Quality
@@ -296,6 +388,48 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
296
388
  };
297
389
  }
298
390
 
391
+ // Node Discovery
392
+ case 'node_types_list': {
393
+ const nodeTypes = await client.listNodeTypes();
394
+ const search = (args.search as string)?.toLowerCase();
395
+ const category = args.category as string;
396
+ const limit = (args.limit as number) || 50;
397
+
398
+ let results: N8nNodeTypeSummary[] = nodeTypes.map((nt) => ({
399
+ type: nt.name,
400
+ name: nt.displayName,
401
+ description: nt.description,
402
+ category: nt.codex?.categories?.[0] || nt.group?.[0] || 'Other',
403
+ version: nt.version,
404
+ }));
405
+
406
+ // Apply search filter
407
+ if (search) {
408
+ results = results.filter(
409
+ (nt) =>
410
+ nt.type.toLowerCase().includes(search) ||
411
+ nt.name.toLowerCase().includes(search) ||
412
+ nt.description.toLowerCase().includes(search)
413
+ );
414
+ }
415
+
416
+ // Apply category filter
417
+ if (category) {
418
+ results = results.filter((nt) =>
419
+ nt.category.toLowerCase().includes(category.toLowerCase())
420
+ );
421
+ }
422
+
423
+ // Apply limit
424
+ results = results.slice(0, limit);
425
+
426
+ return {
427
+ nodeTypes: results,
428
+ total: results.length,
429
+ hint: 'Use the "type" field value when creating nodes (e.g., "n8n-nodes-base.webhook")',
430
+ };
431
+ }
432
+
299
433
  // Version Control
300
434
  case 'version_list': {
301
435
  const versions = await listVersions(args.workflowId as string);
@@ -314,7 +448,11 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
314
448
  if (!version) {
315
449
  throw new Error(`Version ${args.versionId} not found`);
316
450
  }
317
- return version;
451
+ const format = (args.format as ResponseFormat) || 'compact';
452
+ return {
453
+ meta: version.meta,
454
+ workflow: formatWorkflowResponse(version.workflow, format),
455
+ };
318
456
  }
319
457
 
320
458
  case 'version_save': {
@@ -344,11 +482,12 @@ async function handleTool(name: string, args: Record<string, unknown>): Promise<
344
482
 
345
483
  // Apply the old version
346
484
  await client.updateWorkflow(args.workflowId as string, version.workflow);
485
+ const format = (args.format as ResponseFormat) || 'compact';
347
486
 
348
487
  return {
349
488
  success: true,
350
489
  restoredVersion: version.meta,
351
- workflow: version.workflow,
490
+ workflow: formatWorkflowResponse(version.workflow, format),
352
491
  };
353
492
  }
354
493
 
@@ -224,4 +224,139 @@ describe('N8nClient', () => {
224
224
  await expect(client.getWorkflow('999')).rejects.toThrow('n8n API error (404)');
225
225
  });
226
226
  });
227
+
228
+ describe('listNodeTypes', () => {
229
+ it('calls correct endpoint', async () => {
230
+ const mockNodeTypes = [
231
+ {
232
+ name: 'n8n-nodes-base.webhook',
233
+ displayName: 'Webhook',
234
+ description: 'Starts workflow on webhook call',
235
+ group: ['trigger'],
236
+ version: 2,
237
+ },
238
+ {
239
+ name: 'n8n-nodes-base.set',
240
+ displayName: 'Set',
241
+ description: 'Set values',
242
+ group: ['transform'],
243
+ version: 3,
244
+ },
245
+ ];
246
+
247
+ mockFetch.mockResolvedValueOnce({
248
+ ok: true,
249
+ text: async () => JSON.stringify(mockNodeTypes),
250
+ });
251
+
252
+ const result = await client.listNodeTypes();
253
+
254
+ expect(mockFetch).toHaveBeenCalledWith(
255
+ 'https://n8n.example.com/api/v1/nodes',
256
+ expect.objectContaining({
257
+ method: 'GET',
258
+ headers: expect.objectContaining({
259
+ 'X-N8N-API-KEY': 'test-api-key',
260
+ }),
261
+ })
262
+ );
263
+
264
+ expect(result).toHaveLength(2);
265
+ expect(result[0].name).toBe('n8n-nodes-base.webhook');
266
+ expect(result[1].name).toBe('n8n-nodes-base.set');
267
+ });
268
+ });
269
+
270
+ describe('updateWorkflow', () => {
271
+ it('strips disallowed properties before sending to API', async () => {
272
+ const fullWorkflow = {
273
+ id: '123',
274
+ name: 'test_workflow',
275
+ active: true,
276
+ nodes: [{ id: 'n1', name: 'node1', type: 'test', typeVersion: 1, position: [0, 0] as [number, number], parameters: {} }],
277
+ connections: {},
278
+ settings: { timezone: 'UTC' },
279
+ createdAt: '2024-01-01T00:00:00.000Z',
280
+ updatedAt: '2024-01-02T00:00:00.000Z',
281
+ versionId: 'v1',
282
+ staticData: undefined,
283
+ tags: [{ id: 't1', name: 'tag1' }],
284
+ };
285
+
286
+ mockFetch.mockResolvedValueOnce({
287
+ ok: true,
288
+ text: async () => JSON.stringify(fullWorkflow),
289
+ });
290
+
291
+ await client.updateWorkflow('123', fullWorkflow);
292
+
293
+ // Verify the request body does NOT contain disallowed properties
294
+ const putCall = mockFetch.mock.calls[0];
295
+ const putBody = JSON.parse(putCall[1].body);
296
+
297
+ // These should be stripped
298
+ expect(putBody.id).toBeUndefined();
299
+ expect(putBody.createdAt).toBeUndefined();
300
+ expect(putBody.updatedAt).toBeUndefined();
301
+ expect(putBody.active).toBeUndefined();
302
+ expect(putBody.versionId).toBeUndefined();
303
+
304
+ // These should be preserved
305
+ expect(putBody.name).toBe('test_workflow');
306
+ expect(putBody.nodes).toHaveLength(1);
307
+ expect(putBody.connections).toEqual({});
308
+ expect(putBody.settings).toEqual({ timezone: 'UTC' });
309
+ expect(putBody.staticData).toBeUndefined();
310
+ expect(putBody.tags).toEqual([{ id: 't1', name: 'tag1' }]);
311
+ });
312
+
313
+ it('works with partial workflow (only some fields)', async () => {
314
+ mockFetch.mockResolvedValueOnce({
315
+ ok: true,
316
+ text: async () => JSON.stringify({ id: '123', name: 'updated' }),
317
+ });
318
+
319
+ await client.updateWorkflow('123', { name: 'updated', nodes: [] });
320
+
321
+ const putCall = mockFetch.mock.calls[0];
322
+ const putBody = JSON.parse(putCall[1].body);
323
+
324
+ expect(putBody.name).toBe('updated');
325
+ expect(putBody.nodes).toEqual([]);
326
+ });
327
+
328
+ it('handles workflow from formatWorkflow (simulating workflow_format apply)', async () => {
329
+ // This simulates the exact scenario that caused the bug:
330
+ // workflow_format returns a full N8nWorkflow object with id, createdAt, etc.
331
+ const formattedWorkflow = {
332
+ id: 'zbB1fCxWgZXgpjB1',
333
+ name: 'my_workflow',
334
+ active: false,
335
+ nodes: [],
336
+ connections: {},
337
+ createdAt: '2024-01-01T00:00:00.000Z',
338
+ updatedAt: '2024-01-02T00:00:00.000Z',
339
+ };
340
+
341
+ mockFetch.mockResolvedValueOnce({
342
+ ok: true,
343
+ text: async () => JSON.stringify(formattedWorkflow),
344
+ });
345
+
346
+ // This should NOT throw "must NOT have additional properties"
347
+ await client.updateWorkflow('zbB1fCxWgZXgpjB1', formattedWorkflow);
348
+
349
+ const putCall = mockFetch.mock.calls[0];
350
+ const putBody = JSON.parse(putCall[1].body);
351
+
352
+ // Critical: these must NOT be in the request body
353
+ expect(putBody.id).toBeUndefined();
354
+ expect(putBody.createdAt).toBeUndefined();
355
+ expect(putBody.updatedAt).toBeUndefined();
356
+ expect(putBody.active).toBeUndefined();
357
+
358
+ // Only allowed properties should be sent
359
+ expect(Object.keys(putBody).sort()).toEqual(['connections', 'name', 'nodes']);
360
+ });
361
+ });
227
362
  });
package/src/n8n-client.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  N8nExecutionListItem,
11
11
  N8nListResponse,
12
12
  N8nNode,
13
+ N8nNodeType,
13
14
  PatchOperation,
14
15
  } from './types.js';
15
16
 
@@ -95,9 +96,11 @@ export class N8nClient {
95
96
 
96
97
  async updateWorkflow(
97
98
  id: string,
98
- workflow: Partial<Omit<N8nWorkflow, 'id' | 'createdAt' | 'updatedAt'>>
99
+ workflow: Partial<N8nWorkflow>
99
100
  ): Promise<N8nWorkflow> {
100
- return this.request('PUT', `/api/v1/workflows/${id}`, workflow);
101
+ // Strip properties that n8n API doesn't accept on PUT
102
+ const { id: _id, createdAt, updatedAt, active, versionId, ...allowed } = workflow as any;
103
+ return this.request('PUT', `/api/v1/workflows/${id}`, allowed);
101
104
  }
102
105
 
103
106
  async deleteWorkflow(id: string): Promise<void> {
@@ -358,4 +361,12 @@ export class N8nClient {
358
361
  };
359
362
  }
360
363
  }
364
+
365
+ // ─────────────────────────────────────────────────────────────
366
+ // Node Types
367
+ // ─────────────────────────────────────────────────────────────
368
+
369
+ async listNodeTypes(): Promise<N8nNodeType[]> {
370
+ return this.request<N8nNodeType[]>('GET', '/api/v1/nodes');
371
+ }
361
372
  }