@mastra/mcp 0.10.0 → 0.10.1

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,1126 @@
1
+ import http from 'node:http';
2
+ import path from 'path';
3
+ import type { ServerType } from '@hono/node-server';
4
+ import { serve } from '@hono/node-server';
5
+ import { Agent } from '@mastra/core/agent';
6
+ import type { ToolsInput } from '@mastra/core/agent';
7
+ import type { MCPServerConfig, Repository, PackageInfo, RemoteInfo, ConvertedTool } from '@mastra/core/mcp';
8
+ import { createStep, Workflow } from '@mastra/core/workflows';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import type {
11
+ Resource,
12
+ ResourceTemplate,
13
+ ListResourcesResult,
14
+ ReadResourceResult,
15
+ ListResourceTemplatesResult,
16
+ } from '@modelcontextprotocol/sdk/types.js';
17
+ import { MockLanguageModelV1 } from 'ai/test';
18
+ import { Hono } from 'hono';
19
+ import { describe, it, expect, beforeAll, afterAll, afterEach, vi, beforeEach } from 'vitest';
20
+ import { z } from 'zod';
21
+ import { weatherTool } from '../__fixtures__/tools';
22
+ import { InternalMastraMCPClient } from '../client/client';
23
+ import { MCPClient } from '../client/configuration';
24
+ import { MCPServer } from './server';
25
+ import type { MCPServerResources, MCPServerResourceContent } from './server';
26
+
27
+ const PORT = 9100 + Math.floor(Math.random() * 1000);
28
+ let server: MCPServer;
29
+ let httpServer: http.Server;
30
+
31
+ vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 });
32
+
33
+ // Mock Date constructor for predictable release dates
34
+ const mockDateISO = '2024-01-01T00:00:00.000Z';
35
+ const mockDate = new Date(mockDateISO);
36
+ const OriginalDate = global.Date; // Store original Date
37
+
38
+ // Mock a simple tool
39
+ const mockToolExecute = vi.fn(async (args: any) => ({ result: 'tool executed', args }));
40
+ const mockTools: ToolsInput = {
41
+ testTool: {
42
+ description: 'A test tool',
43
+ parameters: z.object({ input: z.string().optional() }),
44
+ execute: mockToolExecute,
45
+ },
46
+ };
47
+
48
+ const minimalTestTool: ToolsInput = {
49
+ minTool: {
50
+ description: 'A minimal tool',
51
+ parameters: z.object({}),
52
+ execute: async () => ({ result: 'ok' }),
53
+ },
54
+ };
55
+
56
+ const mockAgentGenerate = vi.fn(async (query: string) => {
57
+ return {
58
+ rawCall: { rawPrompt: null, rawSettings: {} },
59
+ finishReason: 'stop',
60
+ usage: { promptTokens: 10, completionTokens: 20 },
61
+ text: `{"content":"Agent response to: "${JSON.stringify(query)}"}`,
62
+ };
63
+ });
64
+
65
+ const mockAgentGetInstructions = vi.fn(() => 'This is a mock agent for testing.');
66
+
67
+ const createMockAgent = (name: string, generateFn: any, instructionsFn?: any, description?: string) => {
68
+ return new Agent({
69
+ name: name,
70
+ instructions: instructionsFn,
71
+ description: description || '',
72
+ model: new MockLanguageModelV1({
73
+ defaultObjectGenerationMode: 'json',
74
+ doGenerate: async options => {
75
+ return generateFn((options.prompt.at(-1)?.content[0] as { text: string }).text);
76
+ },
77
+ }),
78
+ });
79
+ };
80
+
81
+ const createMockWorkflow = (
82
+ id: string,
83
+ description?: string,
84
+ inputSchema?: z.ZodTypeAny,
85
+ outputSchema?: z.ZodTypeAny,
86
+ ) => {
87
+ return new Workflow({
88
+ id,
89
+ description: description || '',
90
+ inputSchema: inputSchema as z.ZodType<any>,
91
+ outputSchema: outputSchema as z.ZodType<any>,
92
+ steps: [],
93
+ });
94
+ };
95
+
96
+ const minimalConfig: MCPServerConfig = {
97
+ name: 'TestServer',
98
+ version: '1.0.0',
99
+ tools: mockTools,
100
+ };
101
+
102
+ describe('MCPServer', () => {
103
+ beforeEach(() => {
104
+ vi.clearAllMocks();
105
+
106
+ // @ts-ignore - Mocking Date completely
107
+ global.Date = vi.fn((...args: any[]) => {
108
+ if (args.length === 0) {
109
+ // new Date()
110
+ return mockDate;
111
+ }
112
+ // @ts-ignore
113
+ return new OriginalDate(...args); // new Date('some-string') or new Date(timestamp)
114
+ }) as any;
115
+
116
+ // @ts-ignore
117
+ global.Date.now = vi.fn(() => mockDate.getTime());
118
+ // @ts-ignore
119
+ global.Date.prototype.toISOString = vi.fn(() => mockDateISO);
120
+ // @ts-ignore // Static Date.toISOString() might be used by some libraries
121
+ global.Date.toISOString = vi.fn(() => mockDateISO);
122
+ });
123
+
124
+ // Restore original Date after all tests in this describe block
125
+ afterAll(() => {
126
+ global.Date = OriginalDate;
127
+ });
128
+
129
+ describe('Constructor and Metadata Initialization', () => {
130
+ it('should initialize with default metadata if not provided', () => {
131
+ const server = new MCPServer(minimalConfig);
132
+ expect(server.id).toBeDefined();
133
+ expect(server.name).toBe('TestServer');
134
+ expect(server.version).toBe('1.0.0');
135
+ expect(server.description).toBeUndefined();
136
+ expect(server.repository).toBeUndefined();
137
+ // MCPServerBase stores releaseDate as string, compare directly or re-parse
138
+ expect(server.releaseDate).toBe(mockDateISO);
139
+ expect(server.isLatest).toBe(true);
140
+ expect(server.packageCanonical).toBeUndefined();
141
+ expect(server.packages).toBeUndefined();
142
+ expect(server.remotes).toBeUndefined();
143
+ });
144
+
145
+ it('should initialize with custom metadata when provided', () => {
146
+ const repository: Repository = { url: 'https://github.com/test/repo', source: 'github', id: 'repo-id' };
147
+ const packages: PackageInfo[] = [{ registry_name: 'npm', name: 'test-package', version: '1.0.0' }];
148
+ const remotes: RemoteInfo[] = [{ transport_type: 'sse', url: 'https://test.com/sse' }];
149
+ const customReleaseDate = '2023-12-31T00:00:00.000Z';
150
+ const customConfig: MCPServerConfig = {
151
+ ...minimalConfig,
152
+ id: 'custom-id-doesnt-need-uuid-format-if-set-explicitly',
153
+ description: 'A custom server description',
154
+ repository,
155
+ releaseDate: customReleaseDate,
156
+ isLatest: false,
157
+ packageCanonical: 'npm',
158
+ packages,
159
+ remotes,
160
+ };
161
+ const server = new MCPServer(customConfig);
162
+
163
+ expect(server.id).toBe('custom-id-doesnt-need-uuid-format-if-set-explicitly');
164
+ expect(server.description).toBe('A custom server description');
165
+ expect(server.repository).toEqual(repository);
166
+ expect(server.releaseDate).toBe(customReleaseDate);
167
+ expect(server.isLatest).toBe(false);
168
+ expect(server.packageCanonical).toBe('npm');
169
+ expect(server.packages).toEqual(packages);
170
+ expect(server.remotes).toEqual(remotes);
171
+ });
172
+ });
173
+
174
+ describe('getServerInfo()', () => {
175
+ it('should return correct ServerInfo with default metadata', () => {
176
+ const server = new MCPServer(minimalConfig);
177
+ const serverInfo = server.getServerInfo();
178
+
179
+ expect(serverInfo).toEqual({
180
+ id: expect.any(String),
181
+ name: 'TestServer',
182
+ description: undefined,
183
+ repository: undefined,
184
+ version_detail: {
185
+ version: '1.0.0',
186
+ release_date: mockDateISO,
187
+ is_latest: true,
188
+ },
189
+ });
190
+ });
191
+
192
+ it('should return correct ServerInfo with custom metadata', () => {
193
+ const repository: Repository = { url: 'https://github.com/test/repo', source: 'github', id: 'repo-id' };
194
+ const customReleaseDate = '2023-11-01T00:00:00.000Z';
195
+ const customConfig: MCPServerConfig = {
196
+ ...minimalConfig,
197
+ id: 'custom-id-for-info',
198
+ description: 'Custom description',
199
+ repository,
200
+ releaseDate: customReleaseDate,
201
+ isLatest: false,
202
+ };
203
+ const server = new MCPServer(customConfig);
204
+ const serverInfo = server.getServerInfo();
205
+
206
+ expect(serverInfo).toEqual({
207
+ id: 'custom-id-for-info',
208
+ name: 'TestServer',
209
+ description: 'Custom description',
210
+ repository,
211
+ version_detail: {
212
+ version: '1.0.0',
213
+ release_date: customReleaseDate,
214
+ is_latest: false,
215
+ },
216
+ });
217
+ });
218
+ });
219
+
220
+ describe('getServerDetail()', () => {
221
+ it('should return correct ServerDetailInfo with default metadata', () => {
222
+ const server = new MCPServer(minimalConfig);
223
+ const serverDetail = server.getServerDetail();
224
+
225
+ expect(serverDetail).toEqual({
226
+ id: expect.any(String),
227
+ name: 'TestServer',
228
+ description: undefined,
229
+ repository: undefined,
230
+ version_detail: {
231
+ version: '1.0.0',
232
+ release_date: mockDateISO,
233
+ is_latest: true,
234
+ },
235
+ package_canonical: undefined,
236
+ packages: undefined,
237
+ remotes: undefined,
238
+ });
239
+ });
240
+
241
+ it('should return correct ServerDetailInfo with custom metadata', () => {
242
+ const repository: Repository = { url: 'https://github.com/test/repo', source: 'github', id: 'repo-id' };
243
+ const packages: PackageInfo[] = [{ registry_name: 'npm', name: 'test-package', version: '1.0.0' }];
244
+ const remotes: RemoteInfo[] = [{ transport_type: 'sse', url: 'https://test.com/sse' }];
245
+ const customReleaseDate = '2023-10-01T00:00:00.000Z';
246
+ const customConfig: MCPServerConfig = {
247
+ ...minimalConfig,
248
+ id: 'custom-id-for-detail',
249
+ description: 'Custom detail description',
250
+ repository,
251
+ releaseDate: customReleaseDate,
252
+ isLatest: true,
253
+ packageCanonical: 'docker',
254
+ packages,
255
+ remotes,
256
+ };
257
+ const server = new MCPServer(customConfig);
258
+ const serverDetail = server.getServerDetail();
259
+
260
+ expect(serverDetail).toEqual({
261
+ id: 'custom-id-for-detail',
262
+ name: 'TestServer',
263
+ description: 'Custom detail description',
264
+ repository,
265
+ version_detail: {
266
+ version: '1.0.0',
267
+ release_date: customReleaseDate,
268
+ is_latest: true,
269
+ },
270
+ package_canonical: 'docker',
271
+ packages,
272
+ remotes,
273
+ });
274
+ });
275
+ });
276
+
277
+ describe('MCPServer Resource Handling', () => {
278
+ let resourceTestServerInstance: MCPServer;
279
+ let localHttpServerForResources: http.Server;
280
+ let resourceTestInternalClient: InternalMastraMCPClient;
281
+ const RESOURCE_TEST_PORT = 9200 + Math.floor(Math.random() * 1000);
282
+
283
+ const mockResourceContents: Record<string, MCPServerResourceContent> = {
284
+ 'weather://current': {
285
+ text: JSON.stringify({
286
+ location: 'Test City',
287
+ temperature: 22,
288
+ conditions: 'Sunny',
289
+ }),
290
+ },
291
+ 'weather://forecast': {
292
+ text: JSON.stringify([
293
+ { day: 1, high: 25, low: 15, conditions: 'Clear' },
294
+ { day: 2, high: 26, low: 16, conditions: 'Cloudy' },
295
+ ]),
296
+ },
297
+ 'weather://historical': {
298
+ text: JSON.stringify({ averageHigh: 20, averageLow: 10 }),
299
+ },
300
+ };
301
+
302
+ const initialResourcesForTest: Resource[] = [
303
+ {
304
+ uri: 'weather://current',
305
+ name: 'Current Weather Data',
306
+ description: 'Real-time weather data',
307
+ mimeType: 'application/json',
308
+ },
309
+ {
310
+ uri: 'weather://forecast',
311
+ name: 'Weather Forecast',
312
+ description: '5-day weather forecast',
313
+ mimeType: 'application/json',
314
+ },
315
+ {
316
+ uri: 'weather://historical',
317
+ name: 'Historical Weather Data',
318
+ description: 'Past 30 days weather data',
319
+ mimeType: 'application/json',
320
+ },
321
+ ];
322
+
323
+ const mockAppResourcesFunctions: MCPServerResources = {
324
+ listResources: async () => initialResourcesForTest,
325
+ getResourceContent: async ({ uri }) => {
326
+ if (mockResourceContents[uri]) {
327
+ return mockResourceContents[uri];
328
+ }
329
+ throw new Error(`Mock resource content not found for ${uri}`);
330
+ },
331
+ };
332
+
333
+ beforeAll(async () => {
334
+ resourceTestServerInstance = new MCPServer({
335
+ name: 'ResourceTestServer',
336
+ version: '1.0.0',
337
+ tools: minimalTestTool,
338
+ resources: mockAppResourcesFunctions,
339
+ });
340
+
341
+ localHttpServerForResources = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => {
342
+ const url = new URL(req.url || '', `http://localhost:${RESOURCE_TEST_PORT}`);
343
+ await resourceTestServerInstance.startHTTP({
344
+ url,
345
+ httpPath: '/http',
346
+ req,
347
+ res,
348
+ options: {
349
+ sessionIdGenerator: undefined,
350
+ },
351
+ });
352
+ });
353
+
354
+ await new Promise<void>(resolve => localHttpServerForResources.listen(RESOURCE_TEST_PORT, () => resolve()));
355
+
356
+ resourceTestInternalClient = new InternalMastraMCPClient({
357
+ name: 'resource-test-internal-client',
358
+ server: {
359
+ url: new URL(`http://localhost:${RESOURCE_TEST_PORT}/http`),
360
+ },
361
+ });
362
+ await resourceTestInternalClient.connect();
363
+ });
364
+
365
+ afterAll(async () => {
366
+ await resourceTestInternalClient.disconnect();
367
+ if (localHttpServerForResources) {
368
+ localHttpServerForResources.closeAllConnections?.();
369
+ await new Promise<void>((resolve, reject) => {
370
+ localHttpServerForResources.close(err => {
371
+ if (err) return reject(err);
372
+ resolve();
373
+ });
374
+ });
375
+ }
376
+ if (resourceTestServerInstance) {
377
+ await resourceTestServerInstance.close();
378
+ }
379
+ });
380
+
381
+ it('should list available resources', async () => {
382
+ const result = (await resourceTestInternalClient.listResources()) as ListResourcesResult;
383
+ expect(result).toBeDefined();
384
+ expect(result.resources.length).toBe(initialResourcesForTest.length);
385
+ initialResourcesForTest.forEach(mockResource => {
386
+ expect(result.resources).toContainEqual(expect.objectContaining(mockResource));
387
+ });
388
+ });
389
+
390
+ it('should read content for weather://current', async () => {
391
+ const uri = 'weather://current';
392
+ const resourceContentResult = (await resourceTestInternalClient.readResource(uri)) as ReadResourceResult;
393
+
394
+ expect(resourceContentResult).toBeDefined();
395
+ expect(resourceContentResult.contents).toBeDefined();
396
+ expect(resourceContentResult.contents.length).toBe(1);
397
+
398
+ const content = resourceContentResult.contents[0];
399
+ expect(content.uri).toBe(uri);
400
+ expect(content.mimeType).toBe('application/json');
401
+ expect(content.text).toBe((mockResourceContents[uri] as { text: string }).text);
402
+ });
403
+
404
+ it('should read content for weather://forecast', async () => {
405
+ const uri = 'weather://forecast';
406
+ const resourceContentResult = (await resourceTestInternalClient.readResource(uri)) as ReadResourceResult;
407
+ expect(resourceContentResult.contents.length).toBe(1);
408
+ const content = resourceContentResult.contents[0];
409
+ expect(content.uri).toBe(uri);
410
+ expect(content.mimeType).toBe('application/json');
411
+ expect(content.text).toBe((mockResourceContents[uri] as { text: string }).text);
412
+ });
413
+
414
+ it('should read content for weather://historical', async () => {
415
+ const uri = 'weather://historical';
416
+ const resourceContentResult = (await resourceTestInternalClient.readResource(uri)) as ReadResourceResult;
417
+ expect(resourceContentResult.contents.length).toBe(1);
418
+ const content = resourceContentResult.contents[0];
419
+ expect(content.uri).toBe(uri);
420
+ expect(content.mimeType).toBe('application/json');
421
+ expect(content.text).toBe((mockResourceContents[uri] as { text: string }).text);
422
+ });
423
+
424
+ it('should throw an error when reading a non-existent resource URI', async () => {
425
+ const uri = 'weather://nonexistent';
426
+ await expect(resourceTestInternalClient.readResource(uri)).rejects.toThrow(
427
+ 'Resource not found: weather://nonexistent',
428
+ );
429
+ });
430
+ });
431
+
432
+ describe('MCPServer Resource Handling with Notifications and Templates', () => {
433
+ let notificationTestServer: MCPServer;
434
+ let notificationTestInternalClient: InternalMastraMCPClient;
435
+ let notificationHttpServer: http.Server;
436
+ const NOTIFICATION_PORT = 9400 + Math.floor(Math.random() * 1000);
437
+
438
+ const mockInitialResources: Resource[] = [
439
+ {
440
+ uri: 'test://resource/1',
441
+ name: 'Resource 1',
442
+ mimeType: 'text/plain',
443
+ },
444
+ {
445
+ uri: 'test://resource/2',
446
+ name: 'Resource 2',
447
+ mimeType: 'application/json',
448
+ },
449
+ ];
450
+
451
+ let mockCurrentResourceContents: Record<string, MCPServerResourceContent> = {
452
+ 'test://resource/1': { text: 'Initial content for R1' },
453
+ 'test://resource/2': { text: JSON.stringify({ data: 'Initial for R2' }) },
454
+ };
455
+
456
+ const mockResourceTemplates: ResourceTemplate[] = [
457
+ {
458
+ uriTemplate: 'test://template/{id}',
459
+ name: 'Test Template',
460
+ description: 'A template for test resources',
461
+ },
462
+ ];
463
+
464
+ const getResourceContentCallback = vi.fn(async ({ uri }: { uri: string }) => {
465
+ if (mockCurrentResourceContents[uri]) {
466
+ return mockCurrentResourceContents[uri];
467
+ }
468
+ throw new Error(`Mock content not found for ${uri}`);
469
+ });
470
+
471
+ const listResourcesCallback = vi.fn(async () => mockInitialResources);
472
+ const resourceTemplatesCallback = vi.fn(async () => mockResourceTemplates);
473
+
474
+ beforeAll(async () => {
475
+ const serverOptions: MCPServerConfig & { resources?: MCPServerResources } = {
476
+ name: 'NotificationTestServer',
477
+ version: '1.0.0',
478
+ tools: minimalTestTool,
479
+ resources: {
480
+ listResources: listResourcesCallback,
481
+ getResourceContent: getResourceContentCallback,
482
+ resourceTemplates: resourceTemplatesCallback,
483
+ },
484
+ };
485
+ notificationTestServer = new MCPServer(serverOptions);
486
+
487
+ notificationHttpServer = http.createServer(async (req, res) => {
488
+ const url = new URL(req.url || '', `http://localhost:${NOTIFICATION_PORT}`);
489
+ await notificationTestServer.startSSE({
490
+ url,
491
+ ssePath: '/sse',
492
+ messagePath: '/message',
493
+ req,
494
+ res,
495
+ });
496
+ });
497
+ await new Promise<void>(resolve => notificationHttpServer.listen(NOTIFICATION_PORT, resolve));
498
+
499
+ notificationTestInternalClient = new InternalMastraMCPClient({
500
+ name: 'notification-internal-client',
501
+ server: {
502
+ url: new URL(`http://localhost:${NOTIFICATION_PORT}/sse`),
503
+ logger: logMessage =>
504
+ console.log(
505
+ `[${logMessage.serverName} - ${logMessage.level.toUpperCase()}]: ${logMessage.message}`,
506
+ logMessage.details || '',
507
+ ),
508
+ },
509
+ });
510
+ await notificationTestInternalClient.connect();
511
+ });
512
+
513
+ afterAll(async () => {
514
+ await notificationTestInternalClient.disconnect();
515
+ if (notificationHttpServer) {
516
+ await new Promise<void>((resolve, reject) =>
517
+ notificationHttpServer.close(err => {
518
+ if (err) return reject(err);
519
+ resolve();
520
+ }),
521
+ );
522
+ }
523
+ await notificationTestServer.close();
524
+ });
525
+
526
+ beforeEach(() => {
527
+ vi.clearAllMocks();
528
+ // Reset resource contents for isolation, though specific tests might override
529
+ mockCurrentResourceContents = {
530
+ 'test://resource/1': { text: 'Initial content for R1' },
531
+ 'test://resource/2': { text: JSON.stringify({ data: 'Initial for R2' }) },
532
+ };
533
+ });
534
+
535
+ it('should list initial resources', async () => {
536
+ const result = (await notificationTestInternalClient.listResources()) as ListResourcesResult;
537
+ expect(listResourcesCallback).toHaveBeenCalledTimes(1);
538
+ expect(result.resources).toEqual(mockInitialResources);
539
+ });
540
+
541
+ it('should read resource content for an existing resource', async () => {
542
+ const uri = 'test://resource/1';
543
+ const result = (await notificationTestInternalClient.readResource(uri)) as ReadResourceResult;
544
+ expect(getResourceContentCallback).toHaveBeenCalledWith({ uri });
545
+ expect(result.contents).toEqual([
546
+ {
547
+ uri,
548
+ mimeType: mockInitialResources.find(r => r.uri === uri)?.mimeType,
549
+ text: (mockCurrentResourceContents[uri] as { text: string }).text,
550
+ },
551
+ ]);
552
+ });
553
+
554
+ it('should throw an error when reading a non-existent resource', async () => {
555
+ const uri = 'test://resource/nonexistent';
556
+ await expect(notificationTestInternalClient.readResource(uri)).rejects.toThrow(
557
+ 'Resource not found: test://resource/nonexistent',
558
+ );
559
+ });
560
+
561
+ it('should list resource templates', async () => {
562
+ const result = (await notificationTestInternalClient.listResourceTemplates()) as ListResourceTemplatesResult;
563
+ expect(resourceTemplatesCallback).toHaveBeenCalledTimes(1);
564
+ expect(result.resourceTemplates).toEqual(mockResourceTemplates);
565
+ });
566
+
567
+ it('should subscribe and unsubscribe from a resource', async () => {
568
+ const uri = 'test://resource/1';
569
+ const subscribeResult = await notificationTestInternalClient.subscribeResource(uri);
570
+ expect(subscribeResult).toEqual({});
571
+
572
+ const unsubscribeResult = await notificationTestInternalClient.unsubscribeResource(uri);
573
+ expect(unsubscribeResult).toEqual({});
574
+ });
575
+
576
+ it('should receive resource updated notification when subscribed resource changes', async () => {
577
+ const uriToSubscribe = 'test://resource/1';
578
+ const newContent = 'Updated content for R1';
579
+ const resourceUpdatedPromise = new Promise<void>(resolve => {
580
+ notificationTestInternalClient.setResourceUpdatedNotificationHandler((params: { uri: string }) => {
581
+ if (params.uri === uriToSubscribe) {
582
+ resolve();
583
+ }
584
+ });
585
+ });
586
+
587
+ await notificationTestInternalClient.subscribeResource(uriToSubscribe);
588
+
589
+ mockCurrentResourceContents[uriToSubscribe] = { text: newContent };
590
+
591
+ await notificationTestServer.resources.notifyUpdated({ uri: uriToSubscribe });
592
+
593
+ await expect(resourceUpdatedPromise).resolves.toBeUndefined(); // Wait for the notification
594
+ await notificationTestInternalClient.unsubscribeResource(uriToSubscribe);
595
+ });
596
+
597
+ it('should receive resource list changed notification', async () => {
598
+ const listChangedPromise = new Promise<void>(resolve => {
599
+ notificationTestInternalClient.setResourceListChangedNotificationHandler(() => {
600
+ resolve();
601
+ });
602
+ });
603
+
604
+ await notificationTestServer.resources.notifyListChanged();
605
+
606
+ await expect(listChangedPromise).resolves.toBeUndefined(); // Wait for the notification
607
+ });
608
+ });
609
+
610
+ describe('MCPServer SSE transport', () => {
611
+ let sseRes: Response | undefined;
612
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
613
+
614
+ beforeAll(async () => {
615
+ server = new MCPServer({
616
+ name: 'Test MCP Server',
617
+ version: '0.1.0',
618
+ tools: { weatherTool },
619
+ });
620
+
621
+ httpServer = http.createServer(async (req, res) => {
622
+ const url = new URL(req.url || '', `http://localhost:${PORT}`);
623
+ await server.startSSE({
624
+ url,
625
+ ssePath: '/sse',
626
+ messagePath: '/message',
627
+ req,
628
+ res,
629
+ });
630
+ });
631
+
632
+ await new Promise<void>(resolve => httpServer.listen(PORT, () => resolve()));
633
+ });
634
+
635
+ afterAll(async () => {
636
+ await new Promise<void>((resolve, reject) =>
637
+ httpServer.close(err => {
638
+ if (err) return reject(err);
639
+ resolve();
640
+ }),
641
+ );
642
+ });
643
+
644
+ afterEach(async () => {
645
+ if (reader) {
646
+ try {
647
+ await reader.cancel();
648
+ } catch {}
649
+ reader = undefined;
650
+ }
651
+ if (sseRes && 'body' in sseRes && sseRes.body) {
652
+ try {
653
+ await sseRes.body.cancel();
654
+ } catch {}
655
+ sseRes = undefined;
656
+ }
657
+ });
658
+
659
+ it('should parse SSE stream and contain tool output', async () => {
660
+ sseRes = await fetch(`http://localhost:${PORT}/sse`, {
661
+ headers: { Accept: 'text/event-stream' },
662
+ });
663
+ expect(sseRes.status).toBe(200);
664
+ reader = sseRes.body?.getReader();
665
+ expect(reader).toBeDefined();
666
+ await fetch(`http://localhost:${PORT}/message`, {
667
+ method: 'POST',
668
+ headers: { 'Content-Type': 'application/json' },
669
+ body: JSON.stringify({ tool: 'weatherTool', input: { location: 'Austin' } }),
670
+ });
671
+ if (reader) {
672
+ const { value } = await reader.read();
673
+ const text = value ? new TextDecoder().decode(value) : '';
674
+ expect(text).toMatch(/data:/);
675
+ }
676
+ });
677
+
678
+ it('should return 503 if message sent before SSE connection', async () => {
679
+ (server as any).sseTransport = undefined;
680
+ const res = await fetch(`http://localhost:${PORT}/message`, {
681
+ method: 'POST',
682
+ headers: { 'Content-Type': 'application/json' },
683
+ body: JSON.stringify({ tool: 'weatherTool', input: { location: 'Austin' } }),
684
+ });
685
+ expect(res.status).toBe(503);
686
+ });
687
+ });
688
+
689
+ describe('MCPServer stdio transport', () => {
690
+ it('should connect and expose stdio transport', async () => {
691
+ await server.startStdio();
692
+ expect(server.getStdioTransport()).toBeInstanceOf(StdioServerTransport);
693
+ });
694
+ it('should use stdio transport to get tools', async () => {
695
+ const existingConfig = new MCPClient({
696
+ servers: {
697
+ weather: {
698
+ command: 'npx',
699
+ args: ['-y', 'tsx', path.join(__dirname, '..', '__fixtures__/server-weather.ts')],
700
+ env: {
701
+ FAKE_CREDS: 'test',
702
+ },
703
+ },
704
+ },
705
+ });
706
+
707
+ const tools = await existingConfig.getTools();
708
+ expect(Object.keys(tools).length).toBeGreaterThan(0);
709
+ expect(Object.keys(tools)[0]).toBe('weather_weatherTool');
710
+ await existingConfig.disconnect();
711
+ });
712
+ });
713
+ describe('MCPServer HTTP Transport', () => {
714
+ let server: MCPServer;
715
+ let client: MCPClient;
716
+ const PORT = 9200 + Math.floor(Math.random() * 1000);
717
+
718
+ beforeAll(async () => {
719
+ server = new MCPServer({
720
+ name: 'Test MCP Server',
721
+ version: '0.1.0',
722
+ tools: { weatherTool },
723
+ });
724
+
725
+ httpServer = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => {
726
+ const url = new URL(req.url || '', `http://localhost:${PORT}`);
727
+ await server.startHTTP({
728
+ url,
729
+ httpPath: '/http',
730
+ req,
731
+ res,
732
+ options: {
733
+ sessionIdGenerator: undefined,
734
+ },
735
+ });
736
+ });
737
+
738
+ await new Promise<void>(resolve => httpServer.listen(PORT, () => resolve()));
739
+
740
+ client = new MCPClient({
741
+ servers: {
742
+ local: {
743
+ url: new URL(`http://localhost:${PORT}/http`),
744
+ },
745
+ },
746
+ });
747
+ });
748
+
749
+ afterAll(async () => {
750
+ httpServer.closeAllConnections?.();
751
+ await new Promise<void>(resolve =>
752
+ httpServer.close(() => {
753
+ resolve();
754
+ }),
755
+ );
756
+ await server.close();
757
+ });
758
+
759
+ it('should return 404 for wrong path', async () => {
760
+ const res = await fetch(`http://localhost:${PORT}/wrong`);
761
+ expect(res.status).toBe(404);
762
+ });
763
+
764
+ it('should respond to HTTP request using client', async () => {
765
+ const tools = await client.getTools();
766
+ const tool = tools['local_weatherTool'];
767
+ expect(tool).toBeDefined();
768
+
769
+ // Call the tool
770
+ const result = await tool.execute({ context: { location: 'Austin' } });
771
+
772
+ // Check the result
773
+ expect(result).toBeDefined();
774
+ expect(result.content).toBeInstanceOf(Array);
775
+ expect(result.content.length).toBeGreaterThan(0);
776
+
777
+ const toolOutput = result.content[0];
778
+ expect(toolOutput.type).toBe('text');
779
+ const toolResult = JSON.parse(toolOutput.text);
780
+ expect(toolResult.location).toEqual('Austin');
781
+ expect(toolResult).toHaveProperty('temperature');
782
+ expect(toolResult).toHaveProperty('feelsLike');
783
+ expect(toolResult).toHaveProperty('humidity');
784
+ expect(toolResult).toHaveProperty('conditions');
785
+ expect(toolResult).toHaveProperty('windSpeed');
786
+ expect(toolResult).toHaveProperty('windGust');
787
+ });
788
+ });
789
+
790
+ describe('MCPServer Hono SSE Transport', () => {
791
+ let server: MCPServer;
792
+ let hono: Hono;
793
+ let honoServer: ServerType;
794
+ let client: MCPClient;
795
+ const PORT = 9300 + Math.floor(Math.random() * 1000);
796
+
797
+ beforeAll(async () => {
798
+ server = new MCPServer({
799
+ name: 'Test MCP Server',
800
+ version: '0.1.0',
801
+ tools: { weatherTool },
802
+ });
803
+
804
+ hono = new Hono();
805
+
806
+ hono.get('/sse', async c => {
807
+ const url = new URL(c.req.url, `http://localhost:${PORT}`);
808
+ return await server.startHonoSSE({
809
+ url,
810
+ ssePath: '/sse',
811
+ messagePath: '/message',
812
+ context: c,
813
+ });
814
+ });
815
+
816
+ hono.post('/message', async c => {
817
+ // Use MCPServer's startHonoSSE to handle message endpoint
818
+ const url = new URL(c.req.url, `http://localhost:${PORT}`);
819
+ return await server.startHonoSSE({
820
+ url,
821
+ ssePath: '/sse',
822
+ messagePath: '/message',
823
+ context: c,
824
+ });
825
+ });
826
+
827
+ honoServer = serve({ fetch: hono.fetch, port: PORT });
828
+
829
+ // Initialize MCPClient with SSE endpoint
830
+ client = new MCPClient({
831
+ servers: {
832
+ local: {
833
+ url: new URL(`http://localhost:${PORT}/sse`),
834
+ },
835
+ },
836
+ });
837
+ });
838
+
839
+ afterAll(async () => {
840
+ honoServer.close();
841
+ await server.close();
842
+ });
843
+
844
+ it('should respond to SSE connection and tool call', async () => {
845
+ // Get tools from the client
846
+ const tools = await client.getTools();
847
+ const tool = tools['local_weatherTool'];
848
+ expect(tool).toBeDefined();
849
+
850
+ // Call the tool using the MCPClient (SSE transport)
851
+ const result = await tool.execute({ context: { location: 'Austin' } });
852
+
853
+ expect(result).toBeDefined();
854
+ expect(result.content).toBeInstanceOf(Array);
855
+ expect(result.content.length).toBeGreaterThan(0);
856
+
857
+ const toolOutput = result.content[0];
858
+ expect(toolOutput.type).toBe('text');
859
+ const toolResult = JSON.parse(toolOutput.text);
860
+ expect(toolResult.location).toEqual('Austin');
861
+ expect(toolResult).toHaveProperty('temperature');
862
+ expect(toolResult).toHaveProperty('feelsLike');
863
+ expect(toolResult).toHaveProperty('humidity');
864
+ expect(toolResult).toHaveProperty('conditions');
865
+ expect(toolResult).toHaveProperty('windSpeed');
866
+ expect(toolResult).toHaveProperty('windGust');
867
+ });
868
+ });
869
+ });
870
+
871
+ describe('MCPServer - Agent to Tool Conversion', () => {
872
+ let server: MCPServer;
873
+
874
+ beforeEach(() => {
875
+ vi.clearAllMocks();
876
+ });
877
+
878
+ it('should convert a provided agent to an MCP tool with sync dynamic description', () => {
879
+ const testAgent = createMockAgent(
880
+ 'MyTestAgent',
881
+ mockAgentGenerate,
882
+ mockAgentGetInstructions,
883
+ 'Simple mock description.',
884
+ );
885
+ server = new MCPServer({
886
+ name: 'AgentToolServer',
887
+ version: '1.0.0',
888
+ tools: {},
889
+ agents: { testAgentKey: testAgent },
890
+ });
891
+
892
+ const tools = server.tools();
893
+ const agentToolName = 'ask_testAgentKey';
894
+ expect(tools[agentToolName]).toBeDefined();
895
+ expect(tools[agentToolName].description).toContain("Ask agent 'MyTestAgent' a question.");
896
+ expect(tools[agentToolName].description).toContain('Agent description: Simple mock description.');
897
+
898
+ const schema = tools[agentToolName].parameters.jsonSchema;
899
+ expect(schema.type).toBe('object');
900
+ if (schema.properties) {
901
+ expect(schema.properties.message).toBeDefined();
902
+ const querySchema = schema.properties.message as any;
903
+ expect(querySchema.type).toBe('string');
904
+ } else {
905
+ throw new Error('Schema properties are undefined'); // Fail test if properties not found
906
+ }
907
+ });
908
+
909
+ it('should call agent.generate when the derived tool is executed', async () => {
910
+ const testAgent = createMockAgent(
911
+ 'MyExecAgent',
912
+ mockAgentGenerate,
913
+ mockAgentGetInstructions,
914
+ 'Executable mock agent',
915
+ );
916
+ server = new MCPServer({
917
+ name: 'AgentExecServer',
918
+ version: '1.0.0',
919
+ tools: {},
920
+ agents: { execAgentKey: testAgent },
921
+ });
922
+
923
+ const agentTool = server.tools()['ask_execAgentKey'];
924
+ expect(agentTool).toBeDefined();
925
+
926
+ const queryInput = { message: 'Hello Agent' };
927
+
928
+ if (agentTool && agentTool.execute) {
929
+ const result = await agentTool.execute(queryInput, { toolCallId: 'mcp-call-123', messages: [] });
930
+
931
+ expect(mockAgentGenerate).toHaveBeenCalledTimes(1);
932
+ expect(mockAgentGenerate).toHaveBeenCalledWith(queryInput.message);
933
+ expect(result.text).toBe(`{"content":"Agent response to: ""Hello Agent""}`);
934
+ } else {
935
+ throw new Error('Agent tool or its execute function is undefined');
936
+ }
937
+ });
938
+
939
+ it('should handle name collision: explicit tool wins over agent-derived tool', () => {
940
+ const explicitToolName = 'ask_collidingAgentKey';
941
+ const explicitToolExecute = vi.fn(async () => 'explicit tool response');
942
+ const collidingAgent = createMockAgent(
943
+ 'CollidingAgent',
944
+ mockAgentGenerate,
945
+ undefined,
946
+ 'Colliding agent description',
947
+ );
948
+
949
+ server = new MCPServer({
950
+ name: 'CollisionServer',
951
+ version: '1.0.0',
952
+ tools: {
953
+ [explicitToolName]: {
954
+ description: 'An explicit tool that collides.',
955
+ parameters: z.object({ query: z.string() }),
956
+ execute: explicitToolExecute,
957
+ },
958
+ },
959
+ agents: { collidingAgentKey: collidingAgent },
960
+ });
961
+
962
+ const tools = server.tools();
963
+ expect(tools[explicitToolName]).toBeDefined();
964
+ expect(tools[explicitToolName].description).toBe('An explicit tool that collides.');
965
+ expect(mockAgentGenerate).not.toHaveBeenCalled();
966
+ });
967
+
968
+ it('should use agentKey for tool name ask_<agentKey>', () => {
969
+ const uniqueKeyAgent = createMockAgent(
970
+ 'AgentNameDoesNotMatterForToolKey',
971
+ mockAgentGenerate,
972
+ undefined,
973
+ 'Agent description',
974
+ );
975
+ server = new MCPServer({
976
+ name: 'UniqueKeyServer',
977
+ version: '1.0.0',
978
+ tools: {},
979
+ agents: { unique_agent_key_123: uniqueKeyAgent },
980
+ });
981
+ expect(server.tools()['ask_unique_agent_key_123']).toBeDefined();
982
+ });
983
+
984
+ it('should throw an error if description is undefined (not provided to mock)', () => {
985
+ const agentWithNoDesc = createMockAgent('NoDescAgent', mockAgentGenerate, mockAgentGetInstructions, undefined); // getDescription will return ''
986
+
987
+ expect(
988
+ () =>
989
+ new MCPServer({
990
+ name: 'NoDescProvidedServer',
991
+ version: '1.0.0',
992
+ tools: {},
993
+ agents: { noDescKey: agentWithNoDesc as unknown as Agent }, // Cast for test setup
994
+ }),
995
+ ).toThrow('must have a non-empty description');
996
+ });
997
+ });
998
+
999
+ describe('MCPServer - Workflow to Tool Conversion', () => {
1000
+ let server: MCPServer;
1001
+
1002
+ beforeEach(() => {
1003
+ vi.clearAllMocks();
1004
+ });
1005
+
1006
+ it('should convert a provided workflow to an MCP tool', () => {
1007
+ const testWorkflow = createMockWorkflow('MyTestWorkflow', 'A test workflow.');
1008
+ server = new MCPServer({
1009
+ name: 'WorkflowToolServer',
1010
+ version: '1.0.0',
1011
+ tools: {},
1012
+ workflows: { testWorkflowKey: testWorkflow },
1013
+ });
1014
+
1015
+ const tools = server.tools();
1016
+ const workflowToolName = 'run_testWorkflowKey';
1017
+ expect(tools[workflowToolName]).toBeDefined();
1018
+ expect(tools[workflowToolName].description).toBe(
1019
+ "Run workflow 'testWorkflowKey'. Workflow description: A test workflow.",
1020
+ );
1021
+ expect(tools[workflowToolName].parameters.jsonSchema).toBeDefined();
1022
+ expect(tools[workflowToolName].parameters.jsonSchema.type).toBe('object');
1023
+ });
1024
+
1025
+ it('should throw an error if workflow.description is undefined or empty', () => {
1026
+ const testWorkflowNoDesc = createMockWorkflow('MyWorkflowNoDesc', undefined);
1027
+ expect(
1028
+ () =>
1029
+ new MCPServer({
1030
+ name: 'WorkflowNoDescServer',
1031
+ version: '1.0.0',
1032
+ tools: {},
1033
+ workflows: { testKeyNoDesc: testWorkflowNoDesc },
1034
+ }),
1035
+ ).toThrow('must have a non-empty description');
1036
+
1037
+ const testWorkflowEmptyDesc = createMockWorkflow('MyWorkflowEmptyDesc', '');
1038
+ expect(
1039
+ () =>
1040
+ new MCPServer({
1041
+ name: 'WorkflowEmptyDescServer',
1042
+ version: '1.0.0',
1043
+ tools: {},
1044
+ workflows: { testKeyEmptyDesc: testWorkflowEmptyDesc },
1045
+ }),
1046
+ ).toThrow('must have a non-empty description');
1047
+ });
1048
+
1049
+ it('should call workflow.createRun().start() when the derived tool is executed', async () => {
1050
+ const testWorkflow = createMockWorkflow('MyExecWorkflow', 'Executable workflow');
1051
+ const step = createStep({
1052
+ id: 'my-step',
1053
+ description: 'My step description',
1054
+ inputSchema: z.object({
1055
+ data: z.string(),
1056
+ }),
1057
+ outputSchema: z.object({
1058
+ result: z.string(),
1059
+ }),
1060
+ execute: async ({ inputData }) => {
1061
+ return {
1062
+ result: inputData.data,
1063
+ };
1064
+ },
1065
+ });
1066
+ testWorkflow.then(step).commit();
1067
+ server = new MCPServer({
1068
+ name: 'WorkflowExecServer',
1069
+ version: '1.0.0',
1070
+ tools: {},
1071
+ workflows: { execWorkflowKey: testWorkflow },
1072
+ });
1073
+
1074
+ const workflowTool = server.tools()['run_execWorkflowKey'] as ConvertedTool;
1075
+ expect(workflowTool).toBeDefined();
1076
+
1077
+ const inputData = { data: 'Hello Workflow' };
1078
+ if (workflowTool && workflowTool.execute) {
1079
+ const result = await workflowTool.execute(inputData, { toolCallId: 'mcp-wf-call-123', messages: [] });
1080
+ expect(result).toEqual({
1081
+ status: 'success',
1082
+ steps: {
1083
+ input: { data: 'Hello Workflow' },
1084
+ 'my-step': { status: 'success', output: { result: 'Hello Workflow' } },
1085
+ },
1086
+ result: { result: 'Hello Workflow' },
1087
+ });
1088
+ } else {
1089
+ throw new Error('Workflow tool or its execute function is undefined');
1090
+ }
1091
+ });
1092
+
1093
+ it('should handle name collision: explicit tool wins over workflow-derived tool', () => {
1094
+ const explicitToolName = 'run_collidingWorkflowKey';
1095
+ const explicitToolExecute = vi.fn(async () => 'explicit tool response');
1096
+ const collidingWorkflow = createMockWorkflow('CollidingWorkflow', 'Colliding workflow description');
1097
+
1098
+ server = new MCPServer({
1099
+ name: 'WFCollisionServer',
1100
+ version: '1.0.0',
1101
+ tools: {
1102
+ [explicitToolName]: {
1103
+ description: 'An explicit tool that collides with a workflow.',
1104
+ parameters: z.object({ query: z.string() }),
1105
+ execute: explicitToolExecute,
1106
+ },
1107
+ },
1108
+ workflows: { collidingWorkflowKey: collidingWorkflow },
1109
+ });
1110
+
1111
+ const tools = server.tools();
1112
+ expect(tools[explicitToolName]).toBeDefined();
1113
+ expect(tools[explicitToolName].description).toBe('An explicit tool that collides with a workflow.');
1114
+ });
1115
+
1116
+ it('should use workflowKey for tool name run_<workflowKey>', () => {
1117
+ const uniqueKeyWorkflow = createMockWorkflow('WorkflowNameDoesNotMatter', 'WF description');
1118
+ server = new MCPServer({
1119
+ name: 'UniqueWFKeyServer',
1120
+ version: '1.0.0',
1121
+ tools: {},
1122
+ workflows: { unique_workflow_key_789: uniqueKeyWorkflow },
1123
+ });
1124
+ expect(server.tools()['run_unique_workflow_key_789']).toBeDefined();
1125
+ });
1126
+ });