@mastra/mcp 0.11.3-alpha.0 → 0.11.3-alpha.2

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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.cjs +7 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.js +7 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/server/server.d.ts.map +1 -1
  7. package/package.json +19 -6
  8. package/.turbo/turbo-build.log +0 -4
  9. package/eslint.config.js +0 -11
  10. package/integration-tests/node_modules/.bin/tsc +0 -21
  11. package/integration-tests/node_modules/.bin/tsserver +0 -21
  12. package/integration-tests/node_modules/.bin/vitest +0 -21
  13. package/integration-tests/package.json +0 -29
  14. package/integration-tests/src/mastra/agents/weather.ts +0 -34
  15. package/integration-tests/src/mastra/index.ts +0 -15
  16. package/integration-tests/src/mastra/mcp/index.ts +0 -46
  17. package/integration-tests/src/mastra/tools/weather.ts +0 -13
  18. package/integration-tests/src/server.test.ts +0 -238
  19. package/integration-tests/tsconfig.json +0 -13
  20. package/integration-tests/vitest.config.ts +0 -14
  21. package/src/__fixtures__/fire-crawl-complex-schema.ts +0 -1013
  22. package/src/__fixtures__/server-weather.ts +0 -16
  23. package/src/__fixtures__/stock-price.ts +0 -128
  24. package/src/__fixtures__/tools.ts +0 -94
  25. package/src/__fixtures__/weather.ts +0 -269
  26. package/src/client/client.test.ts +0 -585
  27. package/src/client/client.ts +0 -628
  28. package/src/client/configuration.test.ts +0 -856
  29. package/src/client/configuration.ts +0 -468
  30. package/src/client/elicitationActions.ts +0 -26
  31. package/src/client/index.ts +0 -3
  32. package/src/client/promptActions.ts +0 -70
  33. package/src/client/resourceActions.ts +0 -119
  34. package/src/index.ts +0 -2
  35. package/src/server/index.ts +0 -2
  36. package/src/server/promptActions.ts +0 -48
  37. package/src/server/resourceActions.ts +0 -90
  38. package/src/server/server-logging.test.ts +0 -181
  39. package/src/server/server.test.ts +0 -2142
  40. package/src/server/server.ts +0 -1442
  41. package/src/server/types.ts +0 -59
  42. package/tsconfig.build.json +0 -9
  43. package/tsconfig.json +0 -5
  44. package/tsup.config.ts +0 -17
  45. package/vitest.config.ts +0 -8
@@ -1,856 +0,0 @@
1
- import { spawn } from 'child_process';
2
- import path from 'path';
3
- import { openai } from '@ai-sdk/openai';
4
- import { Agent } from '@mastra/core/agent';
5
- import { RuntimeContext } from '@mastra/core/di';
6
- import type { ResourceTemplate } from '@modelcontextprotocol/sdk/types.js';
7
- import { describe, it, expect, beforeEach, afterEach, afterAll, beforeAll, vi } from 'vitest';
8
- import { allTools, mcpServerName } from '../__fixtures__/fire-crawl-complex-schema';
9
- import type { LogHandler, LogMessage } from './client';
10
- import { MCPClient } from './configuration';
11
-
12
- vi.setConfig({ testTimeout: 80000, hookTimeout: 80000 });
13
-
14
- describe('MCPClient', () => {
15
- let mcp: MCPClient;
16
- let weatherProcess: ReturnType<typeof spawn>;
17
- let clients: MCPClient[] = [];
18
- let weatherServerPort: number;
19
-
20
- beforeAll(async () => {
21
- weatherServerPort = 60000 + Math.floor(Math.random() * 1000); // Generate a random port
22
- // Start the weather SSE server
23
- weatherProcess = spawn('npx', ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/weather.ts')], {
24
- env: { ...process.env, WEATHER_SERVER_PORT: String(weatherServerPort) }, // Pass port as env var
25
- });
26
-
27
- // Wait for SSE server to be ready
28
- let resolved = false;
29
- await new Promise<void>((resolve, reject) => {
30
- weatherProcess.on(`exit`, () => {
31
- if (!resolved) reject();
32
- });
33
- if (weatherProcess.stderr) {
34
- weatherProcess.stderr.on(`data`, chunk => {
35
- console.error(chunk.toString());
36
- });
37
- }
38
- if (weatherProcess.stdout) {
39
- weatherProcess.stdout.on('data', chunk => {
40
- if (chunk.toString().includes('server is running on SSE')) {
41
- resolve();
42
- resolved = true;
43
- }
44
- });
45
- }
46
- });
47
- });
48
-
49
- beforeEach(async () => {
50
- // Give each MCPClient a unique ID to prevent re-initialization errors across tests
51
- const testId = "testId"
52
- mcp = new MCPClient({
53
- id: testId,
54
- servers: {
55
- stockPrice: {
56
- command: 'npx',
57
- args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
58
- env: {
59
- FAKE_CREDS: 'test',
60
- },
61
- },
62
- weather: {
63
- url: new URL(`http://localhost:${weatherServerPort}/sse`), // Use the dynamic port
64
- },
65
- },
66
- });
67
- clients.push(mcp);
68
- });
69
-
70
- afterEach(async () => {
71
- // Clean up any connected clients
72
- await mcp.disconnect();
73
- const index = clients.indexOf(mcp);
74
- if (index > -1) {
75
- clients.splice(index, 1);
76
- }
77
- });
78
-
79
- afterAll(async () => {
80
- // Kill the weather SSE server
81
- weatherProcess.kill('SIGINT');
82
- });
83
-
84
- describe('Instance Management', () => {
85
- it('should initialize with server configurations', () => {
86
- expect(mcp['serverConfigs']).toEqual({
87
- stockPrice: {
88
- command: 'npx',
89
- args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
90
- env: {
91
- FAKE_CREDS: 'test',
92
- },
93
- },
94
- weather: {
95
- url: new URL(`http://localhost:${weatherServerPort}/sse`),
96
- },
97
- });
98
- });
99
-
100
- it('should get connected tools with namespaced tool names', async () => {
101
- const connectedTools = await mcp.getTools();
102
-
103
- // Each tool should be namespaced with its server name
104
- expect(connectedTools).toHaveProperty('stockPrice_getStockPrice');
105
- expect(connectedTools).toHaveProperty('weather_getWeather');
106
- });
107
-
108
- it('should get connected toolsets grouped by server', async () => {
109
- const connectedToolsets = await mcp.getToolsets();
110
-
111
- expect(connectedToolsets).toHaveProperty('stockPrice');
112
- expect(connectedToolsets).toHaveProperty('weather');
113
- expect(connectedToolsets.stockPrice).toHaveProperty('getStockPrice');
114
- expect(connectedToolsets.weather).toHaveProperty('getWeather');
115
- });
116
- });
117
-
118
- describe('Resources', () => {
119
- it('should get resources from connected MCP servers', async () => {
120
- const resources = await mcp.resources.list();
121
-
122
- expect(resources).toHaveProperty('weather');
123
- expect(resources.weather).toBeDefined();
124
- expect(resources.weather).toHaveLength(3);
125
-
126
- // Verify that each expected resource exists with the correct structure
127
- const weatherResources = resources.weather;
128
- const currentWeather = weatherResources.find(r => r.uri === 'weather://current');
129
- expect(currentWeather).toBeDefined();
130
- expect(currentWeather).toMatchObject({
131
- uri: 'weather://current',
132
- name: 'Current Weather Data',
133
- description: expect.any(String),
134
- mimeType: 'application/json',
135
- });
136
-
137
- const forecast = weatherResources.find(r => r.uri === 'weather://forecast');
138
- expect(forecast).toBeDefined();
139
- expect(forecast).toMatchObject({
140
- uri: 'weather://forecast',
141
- name: 'Weather Forecast',
142
- description: expect.any(String),
143
- mimeType: 'application/json',
144
- });
145
-
146
- const historical = weatherResources.find(r => r.uri === 'weather://historical');
147
- expect(historical).toBeDefined();
148
- expect(historical).toMatchObject({
149
- uri: 'weather://historical',
150
- name: 'Historical Weather Data',
151
- description: expect.any(String),
152
- mimeType: 'application/json',
153
- });
154
- });
155
-
156
- it('should list resource templates from connected MCP servers', async () => {
157
- const templates = await mcp.resources.templates();
158
- expect(templates).toHaveProperty('weather');
159
- expect(templates.weather).toBeDefined();
160
- expect(templates.weather.length).toBeGreaterThan(0);
161
- const customForecastTemplate = templates.weather.find(
162
- (t: ResourceTemplate) => t.uriTemplate === 'weather://custom/{city}/{days}',
163
- );
164
- expect(customForecastTemplate).toBeDefined();
165
- expect(customForecastTemplate).toMatchObject({
166
- uriTemplate: 'weather://custom/{city}/{days}',
167
- name: 'Custom Weather Forecast',
168
- description: expect.any(String),
169
- mimeType: 'application/json',
170
- });
171
- });
172
-
173
- it('should read a specific resource from a server', async () => {
174
- const resourceContent = await mcp.resources.read('weather', 'weather://current');
175
- expect(resourceContent).toBeDefined();
176
- expect(resourceContent.contents).toBeInstanceOf(Array);
177
- expect(resourceContent.contents.length).toBe(1);
178
- const contentItem = resourceContent.contents[0];
179
- expect(contentItem.uri).toBe('weather://current');
180
- expect(contentItem.mimeType).toBe('application/json');
181
- expect(contentItem.text).toBeDefined();
182
- let parsedText: any = {};
183
- if (contentItem.text && typeof contentItem.text === 'string') {
184
- try {
185
- parsedText = JSON.parse(contentItem.text);
186
- } catch {
187
- // If parsing fails, parsedText remains an empty object
188
- // console.error("Failed to parse resource content text:", _e);
189
- }
190
- }
191
- expect(parsedText).toHaveProperty('location');
192
- });
193
-
194
- it('should subscribe and unsubscribe from a resource on a specific server', async () => {
195
- const serverName = 'weather';
196
- const resourceUri = 'weather://current';
197
-
198
- const subResult = await mcp.resources.subscribe(serverName, resourceUri);
199
- expect(subResult).toEqual({});
200
-
201
- const unsubResult = await mcp.resources.unsubscribe(serverName, resourceUri);
202
- expect(unsubResult).toEqual({});
203
- });
204
-
205
- it('should receive resource updated notification from a specific server', async () => {
206
- const serverName = 'weather';
207
- const resourceUri = 'weather://current';
208
- let notificationReceived = false;
209
- let receivedUri = '';
210
-
211
- await mcp.resources.list(); // Initial call to establish connection if needed
212
- // Create the promise for the notification BEFORE subscribing
213
- const resourceUpdatedPromise = new Promise<void>((resolve, reject) => {
214
- mcp.resources.onUpdated(serverName, (params: { uri: string }) => {
215
- if (params.uri === resourceUri) {
216
- notificationReceived = true;
217
- receivedUri = params.uri;
218
- resolve();
219
- } else {
220
- console.log(`[Test LOG] Received update for ${params.uri}, waiting for ${resourceUri}`);
221
- }
222
- });
223
- setTimeout(() => reject(new Error(`Timeout waiting for resourceUpdated notification for ${resourceUri}`)), 4500);
224
- });
225
-
226
- await mcp.resources.subscribe(serverName, resourceUri); // Ensure subscription is active
227
-
228
- await expect(resourceUpdatedPromise).resolves.toBeUndefined(); // Wait for the notification
229
-
230
- expect(notificationReceived).toBe(true);
231
- expect(receivedUri).toBe(resourceUri);
232
-
233
- await mcp.resources.unsubscribe(serverName, resourceUri); // Cleanup
234
- }, 5000);
235
-
236
- it('should receive resource list changed notification from a specific server', async () => {
237
- const serverName = 'weather';
238
- let notificationReceived = false;
239
-
240
- await mcp.resources.list(); // Initial call to establish connection
241
-
242
- const resourceListChangedPromise = new Promise<void>((resolve, reject) => {
243
- mcp.resources.onListChanged(serverName, () => {
244
- notificationReceived = true;
245
- resolve();
246
- });
247
- setTimeout(() => reject(new Error('Timeout waiting for resourceListChanged notification')), 4500);
248
- });
249
-
250
- // In a real scenario, something would trigger the server to send this.
251
- // For the test, we rely on the interval in weather.ts or a direct call if available.
252
- // Adding a small delay or an explicit trigger if the fixture supported it would be more robust.
253
- // For now, we assume the interval in weather.ts will eventually fire it.
254
-
255
- await expect(resourceListChangedPromise).resolves.toBeUndefined(); // Wait for the notification
256
-
257
- expect(notificationReceived).toBe(true);
258
- });
259
-
260
- it('should handle errors when getting resources', async () => {
261
- const errorClient = new MCPClient({
262
- id: 'error-test-client',
263
- servers: {
264
- weather: {
265
- url: new URL(`http://localhost:${weatherServerPort}/sse`),
266
- },
267
- nonexistentServer: {
268
- command: 'nonexistent-command',
269
- args: [],
270
- },
271
- },
272
- });
273
-
274
- try {
275
- const resources = await errorClient.resources.list();
276
-
277
- expect(resources).toHaveProperty('weather');
278
- expect(resources.weather).toBeDefined();
279
- expect(resources.weather.length).toBeGreaterThan(0);
280
-
281
- expect(resources).not.toHaveProperty('nonexistentServer');
282
- } finally {
283
- await errorClient.disconnect();
284
- }
285
- });
286
- })
287
-
288
- describe('Prompts', () => {
289
- it('should get prompts from connected MCP servers', async () => {
290
- const prompts = await mcp.prompts.list();
291
-
292
- expect(prompts).toHaveProperty('weather');
293
- expect(prompts['weather']).toBeDefined();
294
- expect(prompts['weather']).toHaveLength(3);
295
-
296
- // Verify that each expected resource exists with the correct structure
297
- const promptResources = prompts['weather'];
298
- const currentWeatherPrompt = promptResources.find(r => r.name === 'current');
299
- expect(currentWeatherPrompt).toBeDefined();
300
- expect(currentWeatherPrompt).toMatchObject({
301
- name: 'current',
302
- version: 'v1',
303
- description: expect.any(String),
304
- mimeType: 'application/json',
305
- });
306
-
307
- const forecast = promptResources.find(r => r.name === 'forecast');
308
- expect(forecast).toBeDefined();
309
- expect(forecast).toMatchObject({
310
- name: 'forecast',
311
- version: 'v1',
312
- description: expect.any(String),
313
- mimeType: 'application/json',
314
- });
315
-
316
- const historical = promptResources.find(r => r.name === 'historical');
317
- expect(historical).toBeDefined();
318
- expect(historical).toMatchObject({
319
- name: 'historical',
320
- version: 'v1',
321
- description: expect.any(String),
322
- mimeType: 'application/json',
323
- });
324
- });
325
-
326
- it('should get a specific prompt from a server', async () => {
327
- const {prompt, messages} = await mcp.prompts.get({serverName: 'weather', name: 'current'});
328
- expect(prompt).toBeDefined();
329
- expect(prompt).toMatchObject({
330
- name: 'current',
331
- version: 'v1',
332
- description: expect.any(String),
333
- mimeType: 'application/json',
334
- });
335
- expect(messages).toBeDefined();
336
- const messageItem = messages[0];
337
- let parsedText: any = {};
338
- if (messageItem.content.text && typeof messageItem.content.text === 'string') {
339
- try {
340
- parsedText = JSON.parse(messageItem.content.text);
341
- } catch {
342
- // If parsing fails, parsedText remains an empty object
343
- // console.error("Failed to parse resource content text:", _e);
344
- }
345
- }
346
- expect(parsedText).toHaveProperty('location');
347
- });
348
-
349
- it('should receive prompt list changed notification from a specific server', async () => {
350
- const serverName = 'weather';
351
- let notificationReceived = false;
352
-
353
- await mcp.prompts.list();
354
-
355
- const promptListChangedPromise = new Promise<void>((resolve, reject) => {
356
- mcp.prompts.onListChanged(serverName, () => {
357
- notificationReceived = true;
358
- resolve();
359
- });
360
- setTimeout(() => reject(new Error('Timeout waiting for promptListChanged notification')), 4500);
361
- });
362
-
363
- await expect(promptListChangedPromise).resolves.toBeUndefined();
364
-
365
- expect(notificationReceived).toBe(true);
366
- });
367
-
368
- it('should handle errors when getting prompts', async () => {
369
- const errorClient = new MCPClient({
370
- id: 'error-test-client',
371
- servers: {
372
- weather: {
373
- url: new URL(`http://localhost:${weatherServerPort}/sse`),
374
- },
375
- nonexistentServer: {
376
- command: 'nonexistent-command',
377
- args: [],
378
- },
379
- },
380
- });
381
-
382
- try {
383
- const prompts = await errorClient.prompts.list();
384
-
385
- expect(prompts).toHaveProperty('weather');
386
- expect(prompts['weather']).toBeDefined();
387
- expect(prompts['weather'].length).toBeGreaterThan(0);
388
-
389
- expect(prompts).not.toHaveProperty('nonexistentServer');
390
- } finally {
391
- await errorClient.disconnect();
392
- }
393
- });
394
- })
395
-
396
- describe('Instance Management', () => {
397
- it('should allow multiple instances with different IDs', async () => {
398
- const config2 = new MCPClient({
399
- id: 'custom-id',
400
- servers: {
401
- stockPrice: {
402
- command: 'npx',
403
- args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
404
- env: {
405
- FAKE_CREDS: 'test',
406
- },
407
- },
408
- },
409
- });
410
-
411
- expect(config2).not.toBe(mcp);
412
- await config2.disconnect();
413
- });
414
-
415
- it('should allow reuse of configuration after closing', async () => {
416
- await mcp.disconnect();
417
-
418
- const config2 = new MCPClient({
419
- servers: {
420
- stockPrice: {
421
- command: 'npx',
422
- args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
423
- env: {
424
- FAKE_CREDS: 'test',
425
- },
426
- },
427
- weather: {
428
- url: new URL(`http://localhost:${weatherServerPort}/sse`),
429
- },
430
- },
431
- });
432
-
433
- expect(config2).not.toBe(mcp);
434
- await config2.disconnect();
435
- });
436
-
437
- it('should throw error when creating duplicate instance without ID', async () => {
438
- const existingConfig = new MCPClient({
439
- servers: {
440
- stockPrice: {
441
- command: 'npx',
442
- args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
443
- env: {
444
- FAKE_CREDS: 'test',
445
- },
446
- },
447
- },
448
- });
449
-
450
- expect(
451
- () =>
452
- new MCPClient({
453
- servers: {
454
- stockPrice: {
455
- command: 'npx',
456
- args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
457
- env: {
458
- FAKE_CREDS: 'test',
459
- },
460
- },
461
- },
462
- }),
463
- ).toThrow(/MCPClient was initialized multiple times/);
464
-
465
- await existingConfig.disconnect();
466
- });
467
- });
468
- describe('MCPClient Operation Timeouts', () => {
469
- it('should respect custom timeout in configuration', async () => {
470
- const config = new MCPClient({
471
- id: 'test-timeout-config',
472
- timeout: 3000, // 3 second timeout
473
- servers: {
474
- test: {
475
- command: 'node',
476
- args: [
477
- '-e',
478
- `
479
- const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
480
- const server = new Server({ name: 'test', version: '1.0.0' });
481
- setTimeout(() => process.exit(0), 2000); // 2 second delay
482
- `,
483
- ],
484
- },
485
- },
486
- });
487
-
488
- const error = await config.getTools().catch(e => e);
489
- expect(error).toBeDefined(); // Will throw since server exits before responding
490
- expect(error.message).not.toMatch(/Request timed out/);
491
-
492
- await config.disconnect();
493
- });
494
-
495
- it('should respect per-server timeout override', async () => {
496
- const config = new MCPClient({
497
- id: 'test-server-timeout-config',
498
- timeout: 500, // Global timeout of 500ms
499
- servers: {
500
- test: {
501
- command: 'node',
502
- args: [
503
- '-e',
504
- `
505
- const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
506
- const server = new Server({ name: 'test', version: '1.0.0' });
507
- setTimeout(() => process.exit(0), 2000); // 2 second delay
508
- `,
509
- ],
510
- timeout: 3000, // Server-specific timeout of 3s
511
- },
512
- },
513
- });
514
-
515
- // This should succeed since server timeout (3s) is longer than delay (2s)
516
- const error = await config.getTools().catch(e => e);
517
- expect(error).toBeDefined(); // Will throw since server exits before responding
518
- expect(error.message).not.toMatch(/Request timed out/);
519
-
520
- await config.disconnect();
521
- });
522
- });
523
-
524
- describe('MCPClient Connection Timeout', () => {
525
- it('should throw timeout error for slow starting server', async () => {
526
- const slowConfig = new MCPClient({
527
- id: 'test-slow-server',
528
- servers: {
529
- slowServer: {
530
- command: 'node',
531
- args: ['-e', 'setTimeout(() => process.exit(0), 65000)'], // Simulate a server that takes 65 seconds to start
532
- timeout: 1000,
533
- },
534
- },
535
- });
536
-
537
- await expect(slowConfig.getTools()).rejects.toThrow(/Request timed out/);
538
- await slowConfig.disconnect();
539
- });
540
-
541
- it('timeout should be longer than configured timeout', async () => {
542
- const slowConfig = new MCPClient({
543
- id: 'test-slow-server',
544
- timeout: 2000,
545
- servers: {
546
- slowServer: {
547
- command: 'node',
548
- args: ['-e', 'setTimeout(() => process.exit(0), 1000)'], // Simulate a server that takes 1 second to start
549
- },
550
- },
551
- });
552
-
553
- const error = await slowConfig.getTools().catch(e => e);
554
- expect(error).toBeDefined();
555
- expect(error.message).not.toMatch(/Request timed out/);
556
- await slowConfig.disconnect();
557
- });
558
-
559
- it('should respect per-server timeout configuration', async () => {
560
- const mixedConfig = new MCPClient({
561
- id: 'test-mixed-timeout',
562
- timeout: 1000, // Short global timeout
563
- servers: {
564
- quickServer: {
565
- command: 'node',
566
- args: ['-e', 'setTimeout(() => process.exit(0), 2000)'], // Takes 2 seconds to exit
567
- },
568
- slowServer: {
569
- command: 'node',
570
- args: ['-e', 'setTimeout(() => process.exit(0), 2000)'], // Takes 2 seconds to exit
571
- timeout: 3000, // But has a longer timeout
572
- },
573
- },
574
- });
575
-
576
- // Quick server should timeout
577
- await expect(mixedConfig.getTools()).rejects.toThrow(/Request timed out/);
578
- await mixedConfig.disconnect();
579
- });
580
-
581
- it('should handle connection errors gracefully', async () => {
582
- const badConfig = new MCPClient({
583
- servers: {
584
- badServer: {
585
- command: 'nonexistent-command',
586
- args: [],
587
- },
588
- },
589
- });
590
-
591
- await expect(badConfig.getTools()).rejects.toThrow();
592
- await badConfig.disconnect();
593
- });
594
- });
595
-
596
- describe('Schema Handling', () => {
597
- let complexClient: MCPClient;
598
- let mockLogHandler: LogHandler & ReturnType<typeof vi.fn>;
599
-
600
- beforeEach(async () => {
601
- mockLogHandler = vi.fn();
602
-
603
- complexClient = new MCPClient({
604
- id: 'complex-schema-test-client-log-handler-firecrawl',
605
- servers: {
606
- 'firecrawl-mcp': {
607
- command: 'npx',
608
- args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/fire-crawl-complex-schema.ts')],
609
- logger: mockLogHandler,
610
- },
611
- },
612
- });
613
- });
614
-
615
- afterEach(async () => {
616
- mockLogHandler.mockClear();
617
- await complexClient?.disconnect().catch(() => { });
618
- });
619
-
620
- it('should process tools from firecrawl-mcp without crashing', async () => {
621
- const tools = await complexClient.getTools();
622
-
623
- Object.keys(allTools).forEach(toolName => {
624
- expect(tools).toHaveProperty(`${mcpServerName.replace(`-fixture`, ``)}_${toolName}`);
625
- });
626
-
627
- expect(mockLogHandler.mock.calls.length).toBeGreaterThan(0);
628
- });
629
- });
630
-
631
- describe('MCPClient Configuration', () => {
632
- let clientsToCleanup: MCPClient[] = [];
633
-
634
- afterEach(async () => {
635
- await Promise.all(
636
- clientsToCleanup.map(client =>
637
- client.disconnect().catch(e => console.error(`Error disconnecting client during test cleanup: ${e}`)),
638
- ),
639
- );
640
- clientsToCleanup = []; // Reset for the next test
641
- });
642
-
643
- it('should pass runtimeContext to the server logger function during tool execution', async () => {
644
- type TestContext = { channel: string; userId: string };
645
- const testContextInstance = new RuntimeContext<TestContext>();
646
- testContextInstance.set('channel', 'test-channel-123');
647
- testContextInstance.set('userId', 'user-abc-987');
648
- const loggerFn = vi.fn();
649
-
650
- const clientForTest = new MCPClient({
651
- servers: {
652
- stockPrice: {
653
- command: 'npx',
654
- args: ['tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
655
- env: { FAKE_CREDS: 'test' },
656
- logger: loggerFn,
657
- },
658
- },
659
- });
660
- clientsToCleanup.push(clientForTest);
661
-
662
- const tools = await clientForTest.getTools();
663
- const stockTool = tools['stockPrice_getStockPrice'];
664
- expect(stockTool).toBeDefined();
665
-
666
- await stockTool.execute({
667
- context: { symbol: 'MSFT' },
668
- runtimeContext: testContextInstance,
669
- });
670
-
671
- expect(loggerFn).toHaveBeenCalled();
672
- const callWithContext = loggerFn.mock.calls.find(call => {
673
- const logMessage = call[0] as LogMessage;
674
- return (
675
- logMessage.runtimeContext &&
676
- typeof logMessage.runtimeContext.get === 'function' &&
677
- logMessage.runtimeContext.get('channel') === 'test-channel-123' &&
678
- logMessage.runtimeContext.get('userId') === 'user-abc-987'
679
- );
680
- });
681
- expect(callWithContext).toBeDefined();
682
- const capturedLogMessage = callWithContext?.[0] as LogMessage;
683
- expect(capturedLogMessage?.serverName).toEqual('stockPrice');
684
- }, 15000);
685
-
686
- it('should pass runtimeContext to MCP logger when tool is called via an Agent', async () => {
687
- type TestAgentContext = { traceId: string; tenant: string };
688
- const agentTestContext = new RuntimeContext<TestAgentContext>();
689
- agentTestContext.set('traceId', 'agent-trace-xyz');
690
- agentTestContext.set('tenant', 'acme-corp');
691
- const loggerFn = vi.fn();
692
-
693
- const mcpClientForAgentTest = new MCPClient({
694
- id: 'mcp-for-agent-test-suite',
695
- servers: {
696
- stockPriceServer: {
697
- command: 'npx',
698
- args: ['tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
699
- env: { FAKE_CREDS: 'test' },
700
- logger: loggerFn,
701
- },
702
- },
703
- });
704
- clientsToCleanup.push(mcpClientForAgentTest);
705
-
706
- const agentName = 'stockAgentForContextTest';
707
- const agent = new Agent({
708
- name: agentName,
709
- model: openai('gpt-4o'),
710
- instructions: 'Use the getStockPrice tool to find the price of MSFT.',
711
- tools: await mcpClientForAgentTest.getTools(),
712
- });
713
-
714
- await agent.generate('What is the price of MSFT?', { runtimeContext: agentTestContext });
715
-
716
- expect(loggerFn).toHaveBeenCalled();
717
- const callWithAgentContext = loggerFn.mock.calls.find(call => {
718
- const logMessage = call[0] as LogMessage;
719
- return (
720
- logMessage.runtimeContext &&
721
- typeof logMessage.runtimeContext.get === 'function' &&
722
- logMessage.runtimeContext.get('traceId') === 'agent-trace-xyz' &&
723
- logMessage.runtimeContext.get('tenant') === 'acme-corp'
724
- );
725
- });
726
- expect(callWithAgentContext).toBeDefined();
727
- if (callWithAgentContext) {
728
- const capturedLogMessage = callWithAgentContext[0] as LogMessage;
729
- expect(capturedLogMessage?.serverName).toEqual('stockPriceServer');
730
- }
731
- }, 20000);
732
-
733
- it('should correctly use different runtimeContexts on sequential direct tool calls', async () => {
734
- const loggerFn = vi.fn();
735
- const clientForSeqTest = new MCPClient({
736
- id: 'mcp-sequential-context-test',
737
- servers: {
738
- stockPriceServer: {
739
- command: 'npx',
740
- args: ['tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')],
741
- env: { FAKE_CREDS: 'test' },
742
- logger: loggerFn,
743
- },
744
- },
745
- });
746
- clientsToCleanup.push(clientForSeqTest);
747
-
748
- const tools = await clientForSeqTest.getTools();
749
- const stockTool = tools['stockPriceServer_getStockPrice'];
750
- expect(stockTool).toBeDefined();
751
-
752
- type ContextA = { callId: string };
753
- const runtimeContextA = new RuntimeContext<ContextA>();
754
- runtimeContextA.set('callId', 'call-A-111');
755
- await stockTool.execute({ context: { symbol: 'MSFT' }, runtimeContext: runtimeContextA });
756
-
757
- expect(loggerFn).toHaveBeenCalled();
758
- let callsAfterA = [...loggerFn.mock.calls];
759
- const logCallForA = callsAfterA.find(
760
- call => (call[0] as LogMessage).runtimeContext?.get('callId') === 'call-A-111',
761
- );
762
- expect(logCallForA).toBeDefined();
763
- expect((logCallForA?.[0] as LogMessage)?.runtimeContext?.get('callId')).toBe('call-A-111');
764
-
765
- loggerFn.mockClear();
766
-
767
- type ContextB = { sessionId: string };
768
- const runtimeContextB = new RuntimeContext<ContextB>();
769
- runtimeContextB.set('sessionId', 'session-B-222');
770
- await stockTool.execute({ context: { symbol: 'GOOG' }, runtimeContext: runtimeContextB });
771
-
772
- expect(loggerFn).toHaveBeenCalled();
773
- let callsAfterB = [...loggerFn.mock.calls];
774
- const logCallForB = callsAfterB.find(
775
- call => (call[0] as LogMessage).runtimeContext?.get('sessionId') === 'session-B-222',
776
- );
777
- expect(logCallForB).toBeDefined();
778
- expect((logCallForB?.[0] as LogMessage)?.runtimeContext?.get('sessionId')).toBe('session-B-222');
779
-
780
- const contextALeak = callsAfterB.some(
781
- call => (call[0] as LogMessage).runtimeContext?.get('callId') === 'call-A-111',
782
- );
783
- expect(contextALeak).toBe(false);
784
- }, 20000);
785
-
786
- it('should isolate runtimeContext between different servers on the same MCPClient', async () => {
787
- const sharedLoggerFn = vi.fn();
788
-
789
- const clientWithTwoServers = new MCPClient({
790
- id: 'mcp-multi-server-context-isolation',
791
- servers: {
792
- serverX: {
793
- command: 'npx',
794
- args: ['tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')], // Re-use fixture, tool name will differ by server
795
- logger: sharedLoggerFn,
796
- env: { FAKE_CREDS: 'serverX-creds' }, // Make env slightly different for clarity if needed
797
- },
798
- serverY: {
799
- command: 'npx',
800
- args: ['tsx', path.join(__dirname, '..', '__fixtures__/stock-price.ts')], // Re-use fixture
801
- logger: sharedLoggerFn,
802
- env: { FAKE_CREDS: 'serverY-creds' },
803
- },
804
- },
805
- });
806
- clientsToCleanup.push(clientWithTwoServers);
807
-
808
- const tools = await clientWithTwoServers.getTools();
809
- const toolX = tools['serverX_getStockPrice'];
810
- const toolY = tools['serverY_getStockPrice'];
811
- expect(toolX).toBeDefined();
812
- expect(toolY).toBeDefined();
813
-
814
- // --- Call tool on Server X with contextX ---
815
- type ContextX = { requestId: string };
816
- const runtimeContextX = new RuntimeContext<ContextX>();
817
- runtimeContextX.set('requestId', 'req-X-001');
818
-
819
- await toolX.execute({ context: { symbol: 'AAA' }, runtimeContext: runtimeContextX });
820
-
821
- expect(sharedLoggerFn).toHaveBeenCalled();
822
- let callsAfterToolX = [...sharedLoggerFn.mock.calls];
823
- const logCallForX = callsAfterToolX.find(call => {
824
- const logMessage = call[0] as LogMessage;
825
- return logMessage.serverName === 'serverX' && logMessage.runtimeContext?.get('requestId') === 'req-X-001';
826
- });
827
- expect(logCallForX).toBeDefined();
828
- expect((logCallForX?.[0] as LogMessage)?.runtimeContext?.get('requestId')).toBe('req-X-001');
829
-
830
- sharedLoggerFn.mockClear(); // Clear for next distinct operation
831
-
832
- // --- Call tool on Server Y with contextY ---
833
- type ContextY = { customerId: string };
834
- const runtimeContextY = new RuntimeContext<ContextY>();
835
- runtimeContextY.set('customerId', 'cust-Y-002');
836
-
837
- await toolY.execute({ context: { symbol: 'BBB' }, runtimeContext: runtimeContextY });
838
-
839
- expect(sharedLoggerFn).toHaveBeenCalled();
840
- let callsAfterToolY = [...sharedLoggerFn.mock.calls];
841
- const logCallForY = callsAfterToolY.find(call => {
842
- const logMessage = call[0] as LogMessage;
843
- return logMessage.serverName === 'serverY' && logMessage.runtimeContext?.get('customerId') === 'cust-Y-002';
844
- });
845
- expect(logCallForY).toBeDefined();
846
- expect((logCallForY?.[0] as LogMessage)?.runtimeContext?.get('customerId')).toBe('cust-Y-002');
847
-
848
- // Ensure contextX did not leak into logs from serverY's operation
849
- const contextXLeakInYLogs = callsAfterToolY.some(call => {
850
- const logMessage = call[0] as LogMessage;
851
- return logMessage.runtimeContext?.get('requestId') === 'req-X-001';
852
- });
853
- expect(contextXLeakInYLogs).toBe(false);
854
- }, 25000); // Increased timeout for multiple server ops
855
- });
856
- });