n8n-nodes-browser-smart-automation 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.
Files changed (71) hide show
  1. package/dist/McpClient/McpClient.node.js +333 -0
  2. package/dist/McpClient/McpClient.node.js.map +1 -0
  3. package/dist/McpClient/listSearch.js +58 -0
  4. package/dist/McpClient/listSearch.js.map +1 -0
  5. package/dist/McpClient/resourceMapping.js +61 -0
  6. package/dist/McpClient/resourceMapping.js.map +1 -0
  7. package/dist/McpClient/utils.js +248 -0
  8. package/dist/McpClient/utils.js.map +1 -0
  9. package/dist/McpClientTool/McpClientTool.node.js +417 -0
  10. package/dist/McpClientTool/McpClientTool.node.js.map +1 -0
  11. package/dist/McpClientTool/loadOptions.js +61 -0
  12. package/dist/McpClientTool/loadOptions.js.map +1 -0
  13. package/dist/McpClientTool/types.js +17 -0
  14. package/dist/McpClientTool/types.js.map +1 -0
  15. package/dist/McpClientTool/utils.js +120 -0
  16. package/dist/McpClientTool/utils.js.map +1 -0
  17. package/dist/McpTrigger/FlushingTransport.js +61 -0
  18. package/dist/McpTrigger/FlushingTransport.js.map +1 -0
  19. package/dist/McpTrigger/McpServer.js +246 -0
  20. package/dist/McpTrigger/McpServer.js.map +1 -0
  21. package/dist/McpTrigger/McpTrigger.node.js +196 -0
  22. package/dist/McpTrigger/McpTrigger.node.js.map +1 -0
  23. package/dist/shared/descriptions.js +89 -0
  24. package/dist/shared/descriptions.js.map +1 -0
  25. package/dist/shared/helpers.js +47 -0
  26. package/dist/shared/helpers.js.map +1 -0
  27. package/dist/shared/httpProxyAgent.js +31 -0
  28. package/dist/shared/httpProxyAgent.js.map +1 -0
  29. package/dist/shared/logWrapper.js +31 -0
  30. package/dist/shared/logWrapper.js.map +1 -0
  31. package/dist/shared/schemaParsing.js +32 -0
  32. package/dist/shared/schemaParsing.js.map +1 -0
  33. package/dist/shared/sharedFields.js +41 -0
  34. package/dist/shared/sharedFields.js.map +1 -0
  35. package/dist/shared/types.js +17 -0
  36. package/dist/shared/types.js.map +1 -0
  37. package/dist/shared/utils.js +231 -0
  38. package/dist/shared/utils.js.map +1 -0
  39. package/jest.config.js +24 -0
  40. package/nodes/McpClient/McpClient.node.ts +327 -0
  41. package/nodes/McpClient/__test__/McpClient.node.test.ts +221 -0
  42. package/nodes/McpClient/__test__/utils.test.ts +302 -0
  43. package/nodes/McpClient/listSearch.ts +48 -0
  44. package/nodes/McpClient/resourceMapping.ts +48 -0
  45. package/nodes/McpClient/utils.ts +281 -0
  46. package/nodes/McpClientTool/McpClientTool.node.ts +468 -0
  47. package/nodes/McpClientTool/__test__/McpClientTool.node.test.ts +730 -0
  48. package/nodes/McpClientTool/loadOptions.ts +45 -0
  49. package/nodes/McpClientTool/types.ts +1 -0
  50. package/nodes/McpClientTool/utils.ts +116 -0
  51. package/nodes/McpTrigger/FlushingTransport.ts +61 -0
  52. package/nodes/McpTrigger/McpServer.ts +317 -0
  53. package/nodes/McpTrigger/McpTrigger.node.ts +204 -0
  54. package/nodes/McpTrigger/__test__/FlushingTransport.test.ts +102 -0
  55. package/nodes/McpTrigger/__test__/McpServer.test.ts +532 -0
  56. package/nodes/McpTrigger/__test__/McpTrigger.node.test.ts +171 -0
  57. package/nodes/mcp.dark.svg +7 -0
  58. package/nodes/mcp.svg +7 -0
  59. package/nodes/shared/__test__/utils.test.ts +318 -0
  60. package/nodes/shared/descriptions.ts +65 -0
  61. package/nodes/shared/helpers.ts +31 -0
  62. package/nodes/shared/httpProxyAgent.ts +11 -0
  63. package/nodes/shared/logWrapper.ts +13 -0
  64. package/nodes/shared/schemaParsing.ts +9 -0
  65. package/nodes/shared/sharedFields.ts +20 -0
  66. package/nodes/shared/types.ts +12 -0
  67. package/nodes/shared/utils.ts +296 -0
  68. package/officail/package.json +255 -0
  69. package/package.json +46 -0
  70. package/tsconfig.json +32 -0
  71. package/tsup.config.ts +11 -0
@@ -0,0 +1,730 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
3
+ import { McpError, ErrorCode, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import { mock, mockDeep } from 'jest-mock-extended';
5
+ import {
6
+ type IExecuteFunctions,
7
+ NodeConnectionTypes,
8
+ NodeOperationError,
9
+ type ILoadOptionsFunctions,
10
+ type INode,
11
+ type ISupplyDataFunctions,
12
+ } from 'n8n-workflow';
13
+
14
+ import { getTools } from '../loadOptions';
15
+ import { McpClientTool } from '../McpClientTool.node';
16
+ import { McpToolkit } from '../utils';
17
+
18
+ jest.mock('@modelcontextprotocol/sdk/client/sse.js');
19
+ jest.mock('@modelcontextprotocol/sdk/client/index.js');
20
+
21
+ describe('McpClientTool', () => {
22
+ describe('loadOptions: getTools', () => {
23
+ it('should return a list of tools', async () => {
24
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
25
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
26
+ tools: [
27
+ {
28
+ name: 'MyTool',
29
+ description: 'MyTool does something',
30
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
31
+ },
32
+ ],
33
+ });
34
+
35
+ const result = await getTools.call(
36
+ mock<ILoadOptionsFunctions>({ getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })) }),
37
+ );
38
+
39
+ expect(result).toEqual([
40
+ {
41
+ description: 'MyTool does something',
42
+ name: 'MyTool',
43
+ value: 'MyTool',
44
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
45
+ },
46
+ ]);
47
+ });
48
+
49
+ it('should handle errors', async () => {
50
+ jest.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Fail!'));
51
+
52
+ const node = mock<INode>({ typeVersion: 1 });
53
+ await expect(
54
+ getTools.call(mock<ILoadOptionsFunctions>({ getNode: jest.fn(() => node) })),
55
+ ).rejects.toEqual(new NodeOperationError(node, 'Could not connect to your MCP server'));
56
+ });
57
+ });
58
+
59
+ describe('supplyData', () => {
60
+ beforeEach(() => {
61
+ jest.resetAllMocks();
62
+ });
63
+
64
+ it('should return a valid toolkit with usable tools (that returns a string)', async () => {
65
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
66
+ jest
67
+ .spyOn(Client.prototype, 'callTool')
68
+ .mockResolvedValue({ content: [{ type: 'text', text: 'result from tool' }] });
69
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
70
+ tools: [
71
+ {
72
+ name: 'MyTool1',
73
+ description: 'MyTool1 does something',
74
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
75
+ },
76
+ {
77
+ name: 'MyTool2',
78
+ description: 'MyTool2 does something',
79
+ inputSchema: { type: 'object', properties: { input2: { type: 'string' } } },
80
+ },
81
+ ],
82
+ });
83
+
84
+ const supplyDataResult = await new McpClientTool().supplyData.call(
85
+ mock<ISupplyDataFunctions>({
86
+ getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
87
+ logger: { debug: jest.fn(), error: jest.fn() },
88
+ addInputData: jest.fn(() => ({ index: 0 })),
89
+ }),
90
+ 0,
91
+ );
92
+
93
+ expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
94
+ expect(supplyDataResult.response).toBeInstanceOf(McpToolkit);
95
+
96
+ const tools = (supplyDataResult.response as McpToolkit).getTools();
97
+ expect(tools).toHaveLength(2);
98
+
99
+ const toolCallResult = await tools[0].invoke({ input: 'foo' });
100
+ expect(toolCallResult).toEqual(JSON.stringify([{ type: 'text', text: 'result from tool' }]));
101
+ });
102
+
103
+ it('should support selecting tools to expose', async () => {
104
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
105
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
106
+ tools: [
107
+ {
108
+ name: 'MyTool1',
109
+ description: 'MyTool1 does something',
110
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
111
+ },
112
+ {
113
+ name: 'MyTool2',
114
+ description: 'MyTool2 does something',
115
+ inputSchema: { type: 'object', properties: { input2: { type: 'string' } } },
116
+ },
117
+ ],
118
+ });
119
+
120
+ const supplyDataResult = await new McpClientTool().supplyData.call(
121
+ mock<ISupplyDataFunctions>({
122
+ getNode: jest.fn(() =>
123
+ mock<INode>({
124
+ typeVersion: 1,
125
+ }),
126
+ ),
127
+ getNodeParameter: jest.fn((key, _index) => {
128
+ const parameters: Record<string, any> = {
129
+ include: 'selected',
130
+ includeTools: ['MyTool2'],
131
+ };
132
+ return parameters[key];
133
+ }),
134
+ logger: { debug: jest.fn(), error: jest.fn() },
135
+ addInputData: jest.fn(() => ({ index: 0 })),
136
+ }),
137
+ 0,
138
+ );
139
+
140
+ expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
141
+ expect(supplyDataResult.response).toBeInstanceOf(McpToolkit);
142
+
143
+ const tools = (supplyDataResult.response as McpToolkit).getTools();
144
+ expect(tools).toHaveLength(1);
145
+ expect(tools[0].name).toBe('MyTool2');
146
+ });
147
+
148
+ it('should support selecting tools to exclude', async () => {
149
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
150
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
151
+ tools: [
152
+ {
153
+ name: 'MyTool1',
154
+ description: 'MyTool1 does something',
155
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
156
+ },
157
+ {
158
+ name: 'MyTool2',
159
+ description: 'MyTool2 does something',
160
+ inputSchema: { type: 'object', properties: { input2: { type: 'string' } } },
161
+ },
162
+ ],
163
+ });
164
+
165
+ const supplyDataResult = await new McpClientTool().supplyData.call(
166
+ mock<ISupplyDataFunctions>({
167
+ getNode: jest.fn(() =>
168
+ mock<INode>({
169
+ typeVersion: 1,
170
+ }),
171
+ ),
172
+ getNodeParameter: jest.fn((key, _index) => {
173
+ const parameters: Record<string, any> = {
174
+ include: 'except',
175
+ excludeTools: ['MyTool2'],
176
+ };
177
+ return parameters[key];
178
+ }),
179
+ logger: { debug: jest.fn(), error: jest.fn() },
180
+ addInputData: jest.fn(() => ({ index: 0 })),
181
+ }),
182
+ 0,
183
+ );
184
+
185
+ expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
186
+ expect(supplyDataResult.response).toBeInstanceOf(McpToolkit);
187
+
188
+ const tools = (supplyDataResult.response as McpToolkit).getTools();
189
+ expect(tools).toHaveLength(1);
190
+ expect(tools[0].name).toBe('MyTool1');
191
+ });
192
+
193
+ it('should support header auth', async () => {
194
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
195
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
196
+ tools: [
197
+ {
198
+ name: 'MyTool1',
199
+ description: 'MyTool1 does something',
200
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
201
+ },
202
+ ],
203
+ });
204
+
205
+ const supplyDataResult = await new McpClientTool().supplyData.call(
206
+ mock<ISupplyDataFunctions>({
207
+ getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
208
+ getNodeParameter: jest.fn((key, _index) => {
209
+ const parameters: Record<string, any> = {
210
+ include: 'except',
211
+ excludeTools: ['MyTool2'],
212
+ authentication: 'headerAuth',
213
+ sseEndpoint: 'https://my-mcp-endpoint.ai/sse',
214
+ };
215
+ return parameters[key];
216
+ }),
217
+ logger: { debug: jest.fn(), error: jest.fn() },
218
+ addInputData: jest.fn(() => ({ index: 0 })),
219
+ getCredentials: jest.fn().mockResolvedValue({ name: 'my-header', value: 'header-value' }),
220
+ }),
221
+ 0,
222
+ );
223
+
224
+ expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
225
+ expect(supplyDataResult.response).toBeInstanceOf(McpToolkit);
226
+
227
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(mock());
228
+ const url = new URL('https://my-mcp-endpoint.ai/sse');
229
+ expect(SSEClientTransport).toHaveBeenCalledTimes(1);
230
+ expect(SSEClientTransport).toHaveBeenCalledWith(url, {
231
+ eventSourceInit: { fetch: expect.any(Function) },
232
+ fetch: expect.any(Function),
233
+ requestInit: { headers: { 'my-header': 'header-value' } },
234
+ });
235
+
236
+ const customFetch = jest.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
237
+ await customFetch?.(url, {} as any);
238
+ expect(fetchSpy).toHaveBeenCalledWith(url, {
239
+ headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
240
+ });
241
+ });
242
+
243
+ it('should support bearer auth', async () => {
244
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
245
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
246
+ tools: [
247
+ {
248
+ name: 'MyTool1',
249
+ description: 'MyTool1 does something',
250
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
251
+ },
252
+ ],
253
+ });
254
+
255
+ const supplyDataResult = await new McpClientTool().supplyData.call(
256
+ mock<ISupplyDataFunctions>({
257
+ getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
258
+ getNodeParameter: jest.fn((key, _index) => {
259
+ const parameters: Record<string, any> = {
260
+ include: 'except',
261
+ excludeTools: ['MyTool2'],
262
+ authentication: 'bearerAuth',
263
+ sseEndpoint: 'https://my-mcp-endpoint.ai/sse',
264
+ };
265
+ return parameters[key];
266
+ }),
267
+ logger: { debug: jest.fn(), error: jest.fn() },
268
+ addInputData: jest.fn(() => ({ index: 0 })),
269
+ getCredentials: jest.fn().mockResolvedValue({ token: 'my-token' }),
270
+ }),
271
+ 0,
272
+ );
273
+
274
+ expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
275
+ expect(supplyDataResult.response).toBeInstanceOf(McpToolkit);
276
+
277
+ const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(mock());
278
+ const url = new URL('https://my-mcp-endpoint.ai/sse');
279
+ expect(SSEClientTransport).toHaveBeenCalledTimes(1);
280
+ expect(SSEClientTransport).toHaveBeenCalledWith(url, {
281
+ eventSourceInit: { fetch: expect.any(Function) },
282
+ fetch: expect.any(Function),
283
+ requestInit: { headers: { Authorization: 'Bearer my-token' } },
284
+ });
285
+
286
+ const customFetch = jest.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
287
+ await customFetch?.(url, {} as any);
288
+ expect(fetchSpy).toHaveBeenCalledWith(url, {
289
+ headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
290
+ });
291
+ });
292
+
293
+ it('should successfully execute a tool', async () => {
294
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
295
+ jest
296
+ .spyOn(Client.prototype, 'callTool')
297
+ .mockResolvedValue({ toolResult: 'Sunny', content: [] });
298
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
299
+ tools: [
300
+ {
301
+ name: 'Weather Tool',
302
+ description: 'Gets the current weather',
303
+ inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
304
+ },
305
+ ],
306
+ });
307
+
308
+ const supplyDataResult = await new McpClientTool().supplyData.call(
309
+ mock<ISupplyDataFunctions>({
310
+ getNode: jest.fn(() =>
311
+ mock<INode>({
312
+ typeVersion: 1,
313
+ }),
314
+ ),
315
+ logger: { debug: jest.fn(), error: jest.fn() },
316
+ addInputData: jest.fn(() => ({ index: 0 })),
317
+ }),
318
+ 0,
319
+ );
320
+
321
+ expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
322
+ expect(supplyDataResult.response).toBeInstanceOf(McpToolkit);
323
+
324
+ const tools = (supplyDataResult.response as McpToolkit).getTools();
325
+ const toolResult = await tools[0].invoke({ location: 'Berlin' });
326
+ expect(toolResult).toEqual('Sunny');
327
+ });
328
+
329
+ it('should handle tool errors', async () => {
330
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
331
+ jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
332
+ isError: true,
333
+ toolResult: 'Weather unknown at location',
334
+ content: [{ text: 'Weather unknown at location' }],
335
+ });
336
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
337
+ tools: [
338
+ {
339
+ name: 'Weather Tool',
340
+ description: 'Gets the current weather',
341
+ inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
342
+ },
343
+ ],
344
+ });
345
+
346
+ const supplyDataFunctions = mock<ISupplyDataFunctions>({
347
+ getNode: jest.fn(() =>
348
+ mock<INode>({
349
+ typeVersion: 1,
350
+ }),
351
+ ),
352
+ logger: { debug: jest.fn(), error: jest.fn() },
353
+ addInputData: jest.fn(() => ({ index: 0 })),
354
+ });
355
+ const supplyDataResult = await new McpClientTool().supplyData.call(supplyDataFunctions, 0);
356
+
357
+ expect(supplyDataResult.closeFunction).toBeInstanceOf(Function);
358
+ expect(supplyDataResult.response).toBeInstanceOf(McpToolkit);
359
+
360
+ const tools = (supplyDataResult.response as McpToolkit).getTools();
361
+ const toolResult = await tools[0].invoke({ location: 'Berlin' });
362
+ expect(toolResult).toEqual('Weather unknown at location');
363
+ expect(supplyDataFunctions.addOutputData).toHaveBeenCalledWith(
364
+ NodeConnectionTypes.AiTool,
365
+ 0,
366
+ new NodeOperationError(supplyDataFunctions.getNode(), 'Weather unknown at location'),
367
+ );
368
+ });
369
+
370
+ it('should support setting a timeout', async () => {
371
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
372
+ const callToolSpy = jest
373
+ .spyOn(Client.prototype, 'callTool')
374
+ .mockRejectedValue(
375
+ new McpError(ErrorCode.RequestTimeout, 'Request timed out', { timeout: 200 }),
376
+ );
377
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
378
+ tools: [
379
+ {
380
+ name: 'SlowTool',
381
+ description: 'SlowTool throws a timeout',
382
+ inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
383
+ },
384
+ ],
385
+ });
386
+
387
+ const mockNode = mock<INode>({ typeVersion: 1 });
388
+ const supplyDataResult = await new McpClientTool().supplyData.call(
389
+ mock<ISupplyDataFunctions>({
390
+ getNode: jest.fn(() => mockNode),
391
+ getNodeParameter: jest.fn((key, _index) => {
392
+ const parameters: Record<string, any> = {
393
+ 'options.timeout': 200,
394
+ };
395
+ return parameters[key];
396
+ }),
397
+ logger: { debug: jest.fn(), error: jest.fn() },
398
+ addInputData: jest.fn(() => ({ index: 0 })),
399
+ }),
400
+ 0,
401
+ );
402
+
403
+ const tools = (supplyDataResult.response as McpToolkit).getTools();
404
+
405
+ await expect(tools[0].invoke({ input: 'foo' })).resolves.toEqual(
406
+ 'MCP error -32001: Request timed out',
407
+ );
408
+ expect(callToolSpy).toHaveBeenCalledWith(
409
+ expect.any(Object), // params
410
+ expect.any(Object), // schema
411
+ expect.objectContaining({ timeout: 200 }),
412
+ ); // options
413
+ });
414
+ });
415
+
416
+ describe('execute', () => {
417
+ beforeEach(() => {
418
+ jest.resetAllMocks();
419
+ });
420
+
421
+ it('should execute tool when tool name is in item.json.tool (from agent)', async () => {
422
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
423
+ jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
424
+ content: [{ type: 'text', text: 'Weather is sunny' }],
425
+ });
426
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
427
+ tools: [
428
+ {
429
+ name: 'get_weather',
430
+ description: 'Gets the weather',
431
+ inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
432
+ },
433
+ ],
434
+ });
435
+
436
+ const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
437
+ const mockExecuteFunctions = mock<any>({
438
+ getNode: jest.fn(() => mockNode),
439
+ getInputData: jest.fn(() => [
440
+ {
441
+ json: {
442
+ tool: 'get_weather',
443
+ location: 'Berlin',
444
+ },
445
+ },
446
+ ]),
447
+ getNodeParameter: jest.fn((key) => {
448
+ const params: Record<string, any> = {
449
+ include: 'all',
450
+ includeTools: [],
451
+ excludeTools: [],
452
+ authentication: 'none',
453
+ sseEndpoint: 'https://test.com/sse',
454
+ 'options.timeout': 60000,
455
+ };
456
+ return params[key];
457
+ }),
458
+ });
459
+
460
+ const result = await new McpClientTool().execute.call(mockExecuteFunctions);
461
+
462
+ expect(result).toEqual([
463
+ [
464
+ {
465
+ json: {
466
+ response: [{ type: 'text', text: 'Weather is sunny' }],
467
+ },
468
+ pairedItem: { item: 0 },
469
+ },
470
+ ],
471
+ ]);
472
+
473
+ expect(Client.prototype.callTool).toHaveBeenCalledWith(
474
+ {
475
+ name: 'get_weather',
476
+ arguments: { location: 'Berlin' },
477
+ },
478
+ expect.anything(),
479
+ expect.anything(),
480
+ );
481
+ });
482
+
483
+ it('should not execute if tool name does not match', async () => {
484
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
485
+ jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
486
+ content: [{ type: 'text', text: 'Should not be called' }],
487
+ });
488
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
489
+ tools: [
490
+ {
491
+ name: 'get_weather',
492
+ description: 'Gets the weather',
493
+ inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
494
+ },
495
+ ],
496
+ });
497
+
498
+ const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
499
+ const mockExecuteFunctions = mock<any>({
500
+ getNode: jest.fn(() => mockNode),
501
+ getInputData: jest.fn(() => [
502
+ {
503
+ json: {
504
+ tool: 'different_tool',
505
+ location: 'Berlin',
506
+ },
507
+ },
508
+ ]),
509
+ getNodeParameter: jest.fn((key) => {
510
+ const params: Record<string, any> = {
511
+ include: 'all',
512
+ includeTools: [],
513
+ excludeTools: [],
514
+ authentication: 'none',
515
+ sseEndpoint: 'https://test.com/sse',
516
+ 'options.timeout': 60000,
517
+ };
518
+ return params[key];
519
+ }),
520
+ });
521
+
522
+ const result = await new McpClientTool().execute.call(mockExecuteFunctions);
523
+
524
+ expect(result).toEqual([[]]);
525
+ expect(Client.prototype.callTool).not.toHaveBeenCalled();
526
+ });
527
+
528
+ it('should throw error when MCP server connection fails', async () => {
529
+ jest.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Connection failed'));
530
+
531
+ const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
532
+ const mockExecuteFunctions = mock<any>({
533
+ getNode: jest.fn(() => mockNode),
534
+ getInputData: jest.fn(() => [
535
+ {
536
+ json: {
537
+ tool: 'get_weather',
538
+ location: 'Berlin',
539
+ },
540
+ },
541
+ ]),
542
+ getNodeParameter: jest.fn((key) => {
543
+ const params: Record<string, any> = {
544
+ include: 'all',
545
+ includeTools: [],
546
+ excludeTools: [],
547
+ authentication: 'none',
548
+ sseEndpoint: 'https://test.com/sse',
549
+ 'options.timeout': 60000,
550
+ };
551
+ return params[key];
552
+ }),
553
+ });
554
+
555
+ await expect(new McpClientTool().execute.call(mockExecuteFunctions)).rejects.toThrow(
556
+ NodeOperationError,
557
+ );
558
+ });
559
+
560
+ it('should handle multiple items', async () => {
561
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
562
+ jest
563
+ .spyOn(Client.prototype, 'callTool')
564
+ .mockResolvedValueOnce({
565
+ content: [{ type: 'text', text: 'Weather in Berlin is sunny' }],
566
+ })
567
+ .mockResolvedValueOnce({
568
+ content: [{ type: 'text', text: 'Weather in London is rainy' }],
569
+ });
570
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
571
+ tools: [
572
+ {
573
+ name: 'get_weather',
574
+ description: 'Gets the weather',
575
+ inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
576
+ },
577
+ ],
578
+ });
579
+
580
+ const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
581
+ const mockExecuteFunctions = mock<any>({
582
+ getNode: jest.fn(() => mockNode),
583
+ getInputData: jest.fn(() => [
584
+ {
585
+ json: {
586
+ tool: 'get_weather',
587
+ location: 'Berlin',
588
+ },
589
+ },
590
+ {
591
+ json: {
592
+ tool: 'get_weather',
593
+ location: 'London',
594
+ },
595
+ },
596
+ ]),
597
+ getNodeParameter: jest.fn((key) => {
598
+ const params: Record<string, any> = {
599
+ include: 'all',
600
+ includeTools: [],
601
+ excludeTools: [],
602
+ authentication: 'none',
603
+ sseEndpoint: 'https://test.com/sse',
604
+ 'options.timeout': 60000,
605
+ };
606
+ return params[key];
607
+ }),
608
+ });
609
+
610
+ const result = await new McpClientTool().execute.call(mockExecuteFunctions);
611
+
612
+ expect(result).toEqual([
613
+ [
614
+ {
615
+ json: {
616
+ response: [{ type: 'text', text: 'Weather in Berlin is sunny' }],
617
+ },
618
+ pairedItem: { item: 0 },
619
+ },
620
+ {
621
+ json: {
622
+ response: [{ type: 'text', text: 'Weather in London is rainy' }],
623
+ },
624
+ pairedItem: { item: 1 },
625
+ },
626
+ ],
627
+ ]);
628
+
629
+ expect(Client.prototype.callTool).toHaveBeenCalledTimes(2);
630
+ });
631
+
632
+ it('should respect tool filtering (selected tools)', async () => {
633
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
634
+ jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
635
+ content: [{ type: 'text', text: 'Weather is sunny' }],
636
+ });
637
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
638
+ tools: [
639
+ {
640
+ name: 'get_weather',
641
+ description: 'Gets the weather',
642
+ inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
643
+ },
644
+ {
645
+ name: 'get_time',
646
+ description: 'Gets the time',
647
+ inputSchema: { type: 'object', properties: {} },
648
+ },
649
+ ],
650
+ });
651
+
652
+ const mockNode = mock<INode>({ typeVersion: 1, type: 'mcpClientTool' });
653
+ const mockExecuteFunctions = mock<any>({
654
+ getNode: jest.fn(() => mockNode),
655
+ getInputData: jest.fn(() => [
656
+ {
657
+ json: {
658
+ tool: 'get_weather',
659
+ location: 'Berlin',
660
+ },
661
+ },
662
+ ]),
663
+ getNodeParameter: jest.fn((key) => {
664
+ const params: Record<string, any> = {
665
+ include: 'selected',
666
+ includeTools: ['get_weather'],
667
+ excludeTools: [],
668
+ authentication: 'none',
669
+ sseEndpoint: 'https://test.com/sse',
670
+ 'options.timeout': 60000,
671
+ };
672
+ return params[key];
673
+ }),
674
+ });
675
+
676
+ const result = await new McpClientTool().execute.call(mockExecuteFunctions);
677
+
678
+ expect(result[0]).toHaveLength(1);
679
+ expect(result[0][0].json.response).toEqual([{ type: 'text', text: 'Weather is sunny' }]);
680
+ });
681
+
682
+ it('should execute tool with timeout', async () => {
683
+ jest.spyOn(Client.prototype, 'connect').mockResolvedValue();
684
+ jest.spyOn(Client.prototype, 'callTool').mockResolvedValue({
685
+ content: [{ type: 'text', text: 'Weather is sunny' }],
686
+ });
687
+ jest.spyOn(Client.prototype, 'listTools').mockResolvedValue({
688
+ tools: [
689
+ {
690
+ name: 'get_weather',
691
+ description: 'Gets the weather',
692
+ inputSchema: { type: 'object', properties: { location: { type: 'string' } } },
693
+ },
694
+ ],
695
+ });
696
+ const mockNode = mock<INode>({ typeVersion: 1.2, type: 'mcpClientTool' });
697
+ const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
698
+ mockExecuteFunctions.getNode.mockReturnValue(mockNode);
699
+ mockExecuteFunctions.getInputData.mockReturnValue([
700
+ {
701
+ json: {
702
+ tool: 'get_weather',
703
+ location: 'Berlin',
704
+ },
705
+ },
706
+ ]);
707
+ mockExecuteFunctions.getNodeParameter.mockImplementation((key, _idx, defaultValue) => {
708
+ const params = {
709
+ include: 'all',
710
+ authentication: 'none',
711
+ serverTransport: 'httpStreamable',
712
+ endpointUrl: 'https://test.com/mcp',
713
+ 'options.timeout': 12345,
714
+ };
715
+ return params[key as keyof typeof params] ?? defaultValue;
716
+ });
717
+
718
+ await new McpClientTool().execute.call(mockExecuteFunctions);
719
+
720
+ expect(Client.prototype.callTool).toHaveBeenCalledWith(
721
+ {
722
+ name: 'get_weather',
723
+ arguments: { location: 'Berlin' },
724
+ },
725
+ CallToolResultSchema,
726
+ { timeout: 12345 },
727
+ );
728
+ });
729
+ });
730
+ });