@pagelines/n8n-mcp 0.1.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,227 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { N8nClient } from './n8n-client.js';
3
+
4
+ // Mock fetch globally
5
+ const mockFetch = vi.fn();
6
+ global.fetch = mockFetch;
7
+
8
+ describe('N8nClient', () => {
9
+ let client: N8nClient;
10
+
11
+ beforeEach(() => {
12
+ client = new N8nClient({
13
+ apiUrl: 'https://n8n.example.com',
14
+ apiKey: 'test-api-key',
15
+ });
16
+ mockFetch.mockReset();
17
+ });
18
+
19
+ describe('constructor', () => {
20
+ it('normalizes URL by removing trailing slash', () => {
21
+ const clientWithSlash = new N8nClient({
22
+ apiUrl: 'https://n8n.example.com/',
23
+ apiKey: 'key',
24
+ });
25
+ // Access private property for testing
26
+ expect((clientWithSlash as any).baseUrl).toBe('https://n8n.example.com');
27
+ });
28
+ });
29
+
30
+ describe('listWorkflows', () => {
31
+ it('calls correct endpoint', async () => {
32
+ mockFetch.mockResolvedValueOnce({
33
+ ok: true,
34
+ text: async () => JSON.stringify({ data: [] }),
35
+ });
36
+
37
+ await client.listWorkflows();
38
+
39
+ expect(mockFetch).toHaveBeenCalledWith(
40
+ 'https://n8n.example.com/api/v1/workflows',
41
+ expect.objectContaining({
42
+ method: 'GET',
43
+ headers: expect.objectContaining({
44
+ 'X-N8N-API-KEY': 'test-api-key',
45
+ }),
46
+ })
47
+ );
48
+ });
49
+
50
+ it('includes query params when provided', async () => {
51
+ mockFetch.mockResolvedValueOnce({
52
+ ok: true,
53
+ text: async () => JSON.stringify({ data: [] }),
54
+ });
55
+
56
+ await client.listWorkflows({ active: true, limit: 10 });
57
+
58
+ expect(mockFetch).toHaveBeenCalledWith(
59
+ expect.stringContaining('active=true'),
60
+ expect.any(Object)
61
+ );
62
+ expect(mockFetch).toHaveBeenCalledWith(
63
+ expect.stringContaining('limit=10'),
64
+ expect.any(Object)
65
+ );
66
+ });
67
+ });
68
+
69
+ describe('patchWorkflow', () => {
70
+ const mockWorkflow = {
71
+ id: '1',
72
+ name: 'test_workflow',
73
+ active: false,
74
+ nodes: [
75
+ {
76
+ id: 'node1',
77
+ name: 'existing_node',
78
+ type: 'n8n-nodes-base.set',
79
+ typeVersion: 1,
80
+ position: [0, 0] as [number, number],
81
+ parameters: { param1: 'value1', param2: 'value2' },
82
+ },
83
+ ],
84
+ connections: {},
85
+ createdAt: '2024-01-01',
86
+ updatedAt: '2024-01-01',
87
+ };
88
+
89
+ beforeEach(() => {
90
+ // Mock GET workflow
91
+ mockFetch.mockResolvedValueOnce({
92
+ ok: true,
93
+ text: async () => JSON.stringify(mockWorkflow),
94
+ });
95
+ // Mock PUT workflow
96
+ mockFetch.mockResolvedValueOnce({
97
+ ok: true,
98
+ text: async () => JSON.stringify(mockWorkflow),
99
+ });
100
+ });
101
+
102
+ it('warns when updateNode would remove parameters', async () => {
103
+ const { warnings } = await client.patchWorkflow('1', [
104
+ {
105
+ type: 'updateNode',
106
+ nodeName: 'existing_node',
107
+ properties: {
108
+ parameters: { newParam: 'newValue' }, // Missing param1, param2
109
+ },
110
+ },
111
+ ]);
112
+
113
+ expect(warnings).toContainEqual(
114
+ expect.stringContaining('remove parameters')
115
+ );
116
+ expect(warnings).toContainEqual(
117
+ expect.stringContaining('param1')
118
+ );
119
+ });
120
+
121
+ it('warns when removing non-existent node', async () => {
122
+ const { warnings } = await client.patchWorkflow('1', [
123
+ {
124
+ type: 'removeNode',
125
+ nodeName: 'nonexistent_node',
126
+ },
127
+ ]);
128
+
129
+ expect(warnings).toContainEqual(
130
+ expect.stringContaining('not found')
131
+ );
132
+ });
133
+
134
+ it('adds node correctly', async () => {
135
+ mockFetch.mockReset();
136
+ mockFetch.mockResolvedValueOnce({
137
+ ok: true,
138
+ text: async () => JSON.stringify(mockWorkflow),
139
+ });
140
+
141
+ const updatedWorkflow = {
142
+ ...mockWorkflow,
143
+ nodes: [
144
+ ...mockWorkflow.nodes,
145
+ {
146
+ id: 'new-id',
147
+ name: 'new_node',
148
+ type: 'n8n-nodes-base.code',
149
+ typeVersion: 1,
150
+ position: [100, 100],
151
+ parameters: {},
152
+ },
153
+ ],
154
+ };
155
+
156
+ mockFetch.mockResolvedValueOnce({
157
+ ok: true,
158
+ text: async () => JSON.stringify(updatedWorkflow),
159
+ });
160
+
161
+ const { workflow } = await client.patchWorkflow('1', [
162
+ {
163
+ type: 'addNode',
164
+ node: {
165
+ name: 'new_node',
166
+ type: 'n8n-nodes-base.code',
167
+ typeVersion: 1,
168
+ position: [100, 100],
169
+ parameters: {},
170
+ },
171
+ },
172
+ ]);
173
+
174
+ // Verify PUT was called with the new node
175
+ const putCall = mockFetch.mock.calls[1];
176
+ const putBody = JSON.parse(putCall[1].body);
177
+ expect(putBody.nodes).toHaveLength(2);
178
+ expect(putBody.nodes[1].name).toBe('new_node');
179
+ });
180
+
181
+ it('adds connection correctly', async () => {
182
+ mockFetch.mockReset();
183
+ mockFetch.mockResolvedValueOnce({
184
+ ok: true,
185
+ text: async () => JSON.stringify(mockWorkflow),
186
+ });
187
+
188
+ const updatedWorkflow = {
189
+ ...mockWorkflow,
190
+ connections: {
191
+ existing_node: {
192
+ main: [[{ node: 'target_node', type: 'main', index: 0 }]],
193
+ },
194
+ },
195
+ };
196
+
197
+ mockFetch.mockResolvedValueOnce({
198
+ ok: true,
199
+ text: async () => JSON.stringify(updatedWorkflow),
200
+ });
201
+
202
+ await client.patchWorkflow('1', [
203
+ {
204
+ type: 'addConnection',
205
+ from: 'existing_node',
206
+ to: 'target_node',
207
+ },
208
+ ]);
209
+
210
+ const putCall = mockFetch.mock.calls[1];
211
+ const putBody = JSON.parse(putCall[1].body);
212
+ expect(putBody.connections.existing_node.main[0][0].node).toBe('target_node');
213
+ });
214
+ });
215
+
216
+ describe('error handling', () => {
217
+ it('throws on non-ok response', async () => {
218
+ mockFetch.mockResolvedValueOnce({
219
+ ok: false,
220
+ status: 404,
221
+ text: async () => 'Not found',
222
+ });
223
+
224
+ await expect(client.getWorkflow('999')).rejects.toThrow('n8n API error (404)');
225
+ });
226
+ });
227
+ });
@@ -0,0 +1,361 @@
1
+ /**
2
+ * n8n REST API Client
3
+ * Clean, minimal implementation with built-in safety checks
4
+ */
5
+
6
+ import type {
7
+ N8nWorkflow,
8
+ N8nWorkflowListItem,
9
+ N8nExecution,
10
+ N8nExecutionListItem,
11
+ N8nListResponse,
12
+ N8nNode,
13
+ PatchOperation,
14
+ } from './types.js';
15
+
16
+ export interface N8nClientConfig {
17
+ apiUrl: string;
18
+ apiKey: string;
19
+ }
20
+
21
+ export class N8nClient {
22
+ private baseUrl: string;
23
+ private headers: Record<string, string>;
24
+
25
+ constructor(config: N8nClientConfig) {
26
+ // Normalize URL (remove trailing slash)
27
+ this.baseUrl = config.apiUrl.replace(/\/$/, '');
28
+ this.headers = {
29
+ 'X-N8N-API-KEY': config.apiKey,
30
+ 'Content-Type': 'application/json',
31
+ };
32
+ }
33
+
34
+ // ─────────────────────────────────────────────────────────────
35
+ // HTTP helpers
36
+ // ─────────────────────────────────────────────────────────────
37
+
38
+ private async request<T>(
39
+ method: string,
40
+ path: string,
41
+ body?: unknown
42
+ ): Promise<T> {
43
+ const url = `${this.baseUrl}${path}`;
44
+
45
+ const response = await fetch(url, {
46
+ method,
47
+ headers: this.headers,
48
+ body: body ? JSON.stringify(body) : undefined,
49
+ });
50
+
51
+ if (!response.ok) {
52
+ const text = await response.text();
53
+ throw new Error(`n8n API error (${response.status}): ${text}`);
54
+ }
55
+
56
+ // Handle empty responses
57
+ const text = await response.text();
58
+ if (!text) return {} as T;
59
+
60
+ return JSON.parse(text) as T;
61
+ }
62
+
63
+ // ─────────────────────────────────────────────────────────────
64
+ // Workflow operations
65
+ // ─────────────────────────────────────────────────────────────
66
+
67
+ async listWorkflows(options?: {
68
+ limit?: number;
69
+ cursor?: string;
70
+ active?: boolean;
71
+ tags?: string[];
72
+ }): Promise<N8nListResponse<N8nWorkflowListItem>> {
73
+ const params = new URLSearchParams();
74
+ if (options?.limit) params.set('limit', String(options.limit));
75
+ if (options?.cursor) params.set('cursor', options.cursor);
76
+ if (options?.active !== undefined) params.set('active', String(options.active));
77
+ if (options?.tags?.length) params.set('tags', options.tags.join(','));
78
+
79
+ const query = params.toString();
80
+ return this.request('GET', `/api/v1/workflows${query ? `?${query}` : ''}`);
81
+ }
82
+
83
+ async getWorkflow(id: string): Promise<N8nWorkflow> {
84
+ return this.request('GET', `/api/v1/workflows/${id}`);
85
+ }
86
+
87
+ async createWorkflow(workflow: {
88
+ name: string;
89
+ nodes: N8nNode[];
90
+ connections: N8nWorkflow['connections'];
91
+ settings?: Record<string, unknown>;
92
+ }): Promise<N8nWorkflow> {
93
+ return this.request('POST', '/api/v1/workflows', workflow);
94
+ }
95
+
96
+ async updateWorkflow(
97
+ id: string,
98
+ workflow: Partial<Omit<N8nWorkflow, 'id' | 'createdAt' | 'updatedAt'>>
99
+ ): Promise<N8nWorkflow> {
100
+ return this.request('PUT', `/api/v1/workflows/${id}`, workflow);
101
+ }
102
+
103
+ async deleteWorkflow(id: string): Promise<void> {
104
+ await this.request('DELETE', `/api/v1/workflows/${id}`);
105
+ }
106
+
107
+ async activateWorkflow(id: string): Promise<N8nWorkflow> {
108
+ return this.request('POST', `/api/v1/workflows/${id}/activate`);
109
+ }
110
+
111
+ async deactivateWorkflow(id: string): Promise<N8nWorkflow> {
112
+ return this.request('POST', `/api/v1/workflows/${id}/deactivate`);
113
+ }
114
+
115
+ // ─────────────────────────────────────────────────────────────
116
+ // Partial update with safety checks
117
+ // ─────────────────────────────────────────────────────────────
118
+
119
+ async patchWorkflow(
120
+ id: string,
121
+ operations: PatchOperation[]
122
+ ): Promise<{ workflow: N8nWorkflow; warnings: string[] }> {
123
+ const warnings: string[] = [];
124
+
125
+ // Fetch current state
126
+ const current = await this.getWorkflow(id);
127
+
128
+ // Apply operations
129
+ const updated = this.applyOperations(current, operations, warnings);
130
+
131
+ // Save
132
+ const result = await this.updateWorkflow(id, {
133
+ name: updated.name,
134
+ nodes: updated.nodes,
135
+ connections: updated.connections,
136
+ settings: updated.settings,
137
+ });
138
+
139
+ return { workflow: result, warnings };
140
+ }
141
+
142
+ private applyOperations(
143
+ workflow: N8nWorkflow,
144
+ operations: PatchOperation[],
145
+ warnings: string[]
146
+ ): N8nWorkflow {
147
+ // Deep clone
148
+ const result: N8nWorkflow = JSON.parse(JSON.stringify(workflow));
149
+
150
+ for (const op of operations) {
151
+ switch (op.type) {
152
+ case 'addNode': {
153
+ const node: N8nNode = {
154
+ id: op.node.id || crypto.randomUUID(),
155
+ name: op.node.name,
156
+ type: op.node.type,
157
+ typeVersion: op.node.typeVersion,
158
+ position: op.node.position,
159
+ parameters: op.node.parameters,
160
+ ...(op.node.credentials && { credentials: op.node.credentials }),
161
+ ...(op.node.disabled && { disabled: op.node.disabled }),
162
+ };
163
+ result.nodes.push(node);
164
+ break;
165
+ }
166
+
167
+ case 'removeNode': {
168
+ const idx = result.nodes.findIndex((n) => n.name === op.nodeName);
169
+ if (idx === -1) {
170
+ warnings.push(`Node not found: ${op.nodeName}`);
171
+ } else {
172
+ result.nodes.splice(idx, 1);
173
+ // Clean up connections
174
+ delete result.connections[op.nodeName];
175
+ for (const [, outputs] of Object.entries(result.connections)) {
176
+ for (const [, connections] of Object.entries(outputs)) {
177
+ for (const connArray of connections) {
178
+ const toRemove = connArray.filter((c) => c.node === op.nodeName);
179
+ for (const conn of toRemove) {
180
+ const i = connArray.indexOf(conn);
181
+ if (i !== -1) connArray.splice(i, 1);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ break;
188
+ }
189
+
190
+ case 'updateNode': {
191
+ const node = result.nodes.find((n) => n.name === op.nodeName);
192
+ if (!node) {
193
+ warnings.push(`Node not found: ${op.nodeName}`);
194
+ } else {
195
+ // CRITICAL: Warn about parameter replacement
196
+ if (op.properties.parameters) {
197
+ const currentKeys = Object.keys(node.parameters);
198
+ const newKeys = Object.keys(op.properties.parameters);
199
+ const missingKeys = currentKeys.filter((k) => !newKeys.includes(k));
200
+ if (missingKeys.length > 0) {
201
+ warnings.push(
202
+ `WARNING: Updating "${op.nodeName}" will remove parameters: ${missingKeys.join(', ')}. ` +
203
+ `Include all existing parameters to preserve them.`
204
+ );
205
+ }
206
+ }
207
+ Object.assign(node, op.properties);
208
+ }
209
+ break;
210
+ }
211
+
212
+ case 'addConnection': {
213
+ const outputType = op.outputType || 'main';
214
+ const fromOutput = op.fromOutput || 0;
215
+ const toInput = op.toInput || 0;
216
+ const inputType = op.inputType || 'main';
217
+
218
+ if (!result.connections[op.from]) {
219
+ result.connections[op.from] = {};
220
+ }
221
+ if (!result.connections[op.from][outputType]) {
222
+ result.connections[op.from][outputType] = [];
223
+ }
224
+ while (result.connections[op.from][outputType].length <= fromOutput) {
225
+ result.connections[op.from][outputType].push([]);
226
+ }
227
+ result.connections[op.from][outputType][fromOutput].push({
228
+ node: op.to,
229
+ type: inputType,
230
+ index: toInput,
231
+ });
232
+ break;
233
+ }
234
+
235
+ case 'removeConnection': {
236
+ const outputType = op.outputType || 'main';
237
+ const fromOutput = op.fromOutput || 0;
238
+ const conns = result.connections[op.from]?.[outputType]?.[fromOutput];
239
+ if (conns) {
240
+ const idx = conns.findIndex((c) => c.node === op.to);
241
+ if (idx !== -1) conns.splice(idx, 1);
242
+ }
243
+ break;
244
+ }
245
+
246
+ case 'updateSettings': {
247
+ result.settings = { ...result.settings, ...op.settings };
248
+ break;
249
+ }
250
+
251
+ case 'updateName': {
252
+ result.name = op.name;
253
+ break;
254
+ }
255
+
256
+ case 'activate': {
257
+ result.active = true;
258
+ break;
259
+ }
260
+
261
+ case 'deactivate': {
262
+ result.active = false;
263
+ break;
264
+ }
265
+ }
266
+ }
267
+
268
+ return result;
269
+ }
270
+
271
+ // ─────────────────────────────────────────────────────────────
272
+ // Execution operations
273
+ // ─────────────────────────────────────────────────────────────
274
+
275
+ async listExecutions(options?: {
276
+ workflowId?: string;
277
+ status?: 'success' | 'error' | 'waiting';
278
+ limit?: number;
279
+ cursor?: string;
280
+ }): Promise<N8nListResponse<N8nExecutionListItem>> {
281
+ const params = new URLSearchParams();
282
+ if (options?.workflowId) params.set('workflowId', options.workflowId);
283
+ if (options?.status) params.set('status', options.status);
284
+ if (options?.limit) params.set('limit', String(options.limit));
285
+ if (options?.cursor) params.set('cursor', options.cursor);
286
+
287
+ const query = params.toString();
288
+ return this.request('GET', `/api/v1/executions${query ? `?${query}` : ''}`);
289
+ }
290
+
291
+ async getExecution(id: string): Promise<N8nExecution> {
292
+ return this.request('GET', `/api/v1/executions/${id}`);
293
+ }
294
+
295
+ async deleteExecution(id: string): Promise<void> {
296
+ await this.request('DELETE', `/api/v1/executions/${id}`);
297
+ }
298
+
299
+ // ─────────────────────────────────────────────────────────────
300
+ // Workflow execution (webhook trigger)
301
+ // ─────────────────────────────────────────────────────────────
302
+
303
+ async executeWorkflow(
304
+ id: string,
305
+ data?: Record<string, unknown>
306
+ ): Promise<{ executionId?: string; data?: unknown }> {
307
+ // Get workflow to find webhook path
308
+ const workflow = await this.getWorkflow(id);
309
+
310
+ // Find webhook trigger node
311
+ const webhookNode = workflow.nodes.find(
312
+ (n) => n.type === 'n8n-nodes-base.webhook'
313
+ );
314
+
315
+ if (!webhookNode) {
316
+ throw new Error('Workflow has no webhook trigger. Cannot execute via API.');
317
+ }
318
+
319
+ const path = webhookNode.parameters.path as string;
320
+ if (!path) {
321
+ throw new Error('Webhook node has no path configured.');
322
+ }
323
+
324
+ // Execute via webhook
325
+ const webhookUrl = `${this.baseUrl}/webhook/${path}`;
326
+ const response = await fetch(webhookUrl, {
327
+ method: 'POST',
328
+ headers: { 'Content-Type': 'application/json' },
329
+ body: data ? JSON.stringify(data) : undefined,
330
+ });
331
+
332
+ if (!response.ok) {
333
+ const text = await response.text();
334
+ throw new Error(`Webhook execution failed (${response.status}): ${text}`);
335
+ }
336
+
337
+ const result = await response.text();
338
+ try {
339
+ return JSON.parse(result);
340
+ } catch {
341
+ return { data: result };
342
+ }
343
+ }
344
+
345
+ // ─────────────────────────────────────────────────────────────
346
+ // Health check
347
+ // ─────────────────────────────────────────────────────────────
348
+
349
+ async healthCheck(): Promise<{ healthy: boolean; version?: string; error?: string }> {
350
+ try {
351
+ // Try to list workflows with limit 1
352
+ await this.listWorkflows({ limit: 1 });
353
+ return { healthy: true };
354
+ } catch (error) {
355
+ return {
356
+ healthy: false,
357
+ error: error instanceof Error ? error.message : String(error),
358
+ };
359
+ }
360
+ }
361
+ }