@mastra/mcp 0.5.0-alpha.3 → 0.5.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +12 -0
- package/dist/_tsup-dts-rollup.d.cts +26 -1
- package/dist/_tsup-dts-rollup.d.ts +26 -1
- package/dist/index.cjs +220 -41
- package/dist/index.js +220 -40
- package/package.json +7 -3
- package/src/__fixtures__/fire-crawl-complex-schema.ts +1013 -0
- package/src/client.test.ts +4 -4
- package/src/client.ts +69 -38
- package/src/configuration.test.ts +37 -0
- package/src/server.test.ts +183 -23
- package/src/server.ts +86 -10
package/src/client.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
|
8
8
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { InternalMastraMCPClient } from './client.js';
|
|
12
12
|
|
|
13
13
|
async function setupTestServer(withSessionManagement: boolean) {
|
|
14
14
|
const httpServer: HttpServer = createServer();
|
|
@@ -62,12 +62,12 @@ describe('MastraMCPClient with Streamable HTTP', () => {
|
|
|
62
62
|
serverTransport: StreamableHTTPServerTransport;
|
|
63
63
|
baseUrl: URL;
|
|
64
64
|
};
|
|
65
|
-
let client:
|
|
65
|
+
let client: InternalMastraMCPClient;
|
|
66
66
|
|
|
67
67
|
describe('Stateless Mode', () => {
|
|
68
68
|
beforeEach(async () => {
|
|
69
69
|
testServer = await setupTestServer(false);
|
|
70
|
-
client = new
|
|
70
|
+
client = new InternalMastraMCPClient({
|
|
71
71
|
name: 'test-stateless-client',
|
|
72
72
|
server: {
|
|
73
73
|
url: testServer.baseUrl,
|
|
@@ -99,7 +99,7 @@ describe('MastraMCPClient with Streamable HTTP', () => {
|
|
|
99
99
|
describe('Stateful Mode', () => {
|
|
100
100
|
beforeEach(async () => {
|
|
101
101
|
testServer = await setupTestServer(true);
|
|
102
|
-
client = new
|
|
102
|
+
client = new InternalMastraMCPClient({
|
|
103
103
|
name: 'test-stateful-client',
|
|
104
104
|
server: {
|
|
105
105
|
url: testServer.baseUrl,
|
package/src/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MastraBase } from '@mastra/core/base';
|
|
2
2
|
import { createTool } from '@mastra/core/tools';
|
|
3
|
-
import { isZodType
|
|
3
|
+
import { isZodType } from '@mastra/core/utils';
|
|
4
4
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
5
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
6
6
|
import type { SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
@@ -14,9 +14,9 @@ import type { ClientCapabilities, LoggingLevel } from '@modelcontextprotocol/sdk
|
|
|
14
14
|
import { CallToolResultSchema, ListResourcesResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
15
|
|
|
16
16
|
import { asyncExitHook, gracefulExit } from 'exit-hook';
|
|
17
|
-
import jsonSchemaToZod from 'json-schema-to-zod';
|
|
18
|
-
import type { JsonSchema } from 'json-schema-to-zod';
|
|
19
17
|
import { z } from 'zod';
|
|
18
|
+
import { jsonSchemaObjectToZodRawShape } from 'zod-from-json-schema';
|
|
19
|
+
import type { JSONSchema } from 'zod-from-json-schema';
|
|
20
20
|
|
|
21
21
|
// Re-export MCP SDK LoggingLevel for convenience
|
|
22
22
|
export type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js';
|
|
@@ -323,11 +323,35 @@ export class InternalMastraMCPClient extends MastraBase {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
private convertInputSchema(
|
|
326
|
-
inputSchema: Awaited<ReturnType<Client['listTools']>>['tools'][0]['inputSchema'] |
|
|
326
|
+
inputSchema: Awaited<ReturnType<Client['listTools']>>['tools'][0]['inputSchema'] | JSONSchema,
|
|
327
327
|
): z.ZodType {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
if (isZodType(inputSchema)) {
|
|
329
|
+
return inputSchema;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// Assuming inputSchema is a JSONSchema object type for tool inputs
|
|
334
|
+
const rawShape = jsonSchemaObjectToZodRawShape(inputSchema as JSONSchema);
|
|
335
|
+
return z.object(rawShape); // Wrap the raw shape to return a ZodType (object)
|
|
336
|
+
} catch (error: unknown) {
|
|
337
|
+
let errorDetails: string | undefined;
|
|
338
|
+
if (error instanceof Error) {
|
|
339
|
+
errorDetails = error.stack;
|
|
340
|
+
} else {
|
|
341
|
+
// Attempt to stringify, fallback to String()
|
|
342
|
+
try {
|
|
343
|
+
errorDetails = JSON.stringify(error);
|
|
344
|
+
} catch {
|
|
345
|
+
errorDetails = String(error);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
this.log('error', 'Failed to convert JSON schema to Zod schema using zodFromJsonSchema', {
|
|
349
|
+
error: errorDetails,
|
|
350
|
+
originalJsonSchema: inputSchema,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
throw new Error(errorDetails);
|
|
354
|
+
}
|
|
331
355
|
}
|
|
332
356
|
|
|
333
357
|
async tools() {
|
|
@@ -336,38 +360,45 @@ export class InternalMastraMCPClient extends MastraBase {
|
|
|
336
360
|
const toolsRes: Record<string, any> = {};
|
|
337
361
|
tools.forEach(tool => {
|
|
338
362
|
this.log('debug', `Processing tool: ${tool.name}`);
|
|
363
|
+
try {
|
|
364
|
+
const mastraTool = createTool({
|
|
365
|
+
id: `${this.name}_${tool.name}`,
|
|
366
|
+
description: tool.description || '',
|
|
367
|
+
inputSchema: this.convertInputSchema(tool.inputSchema),
|
|
368
|
+
execute: async ({ context }) => {
|
|
369
|
+
try {
|
|
370
|
+
this.log('debug', `Executing tool: ${tool.name}`, { toolArgs: context });
|
|
371
|
+
const res = await this.client.callTool(
|
|
372
|
+
{
|
|
373
|
+
name: tool.name,
|
|
374
|
+
arguments: context,
|
|
375
|
+
},
|
|
376
|
+
CallToolResultSchema,
|
|
377
|
+
{
|
|
378
|
+
timeout: this.timeout,
|
|
379
|
+
},
|
|
380
|
+
);
|
|
381
|
+
this.log('debug', `Tool executed successfully: ${tool.name}`);
|
|
382
|
+
return res;
|
|
383
|
+
} catch (e) {
|
|
384
|
+
this.log('error', `Error calling tool: ${tool.name}`, {
|
|
385
|
+
error: e instanceof Error ? e.stack : JSON.stringify(e, null, 2),
|
|
386
|
+
toolArgs: context,
|
|
387
|
+
});
|
|
388
|
+
throw e;
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
});
|
|
339
392
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
name: tool.name,
|
|
350
|
-
arguments: context,
|
|
351
|
-
},
|
|
352
|
-
CallToolResultSchema,
|
|
353
|
-
{
|
|
354
|
-
timeout: this.timeout,
|
|
355
|
-
},
|
|
356
|
-
);
|
|
357
|
-
this.log('debug', `Tool executed successfully: ${tool.name}`);
|
|
358
|
-
return res;
|
|
359
|
-
} catch (e) {
|
|
360
|
-
this.log('error', `Error calling tool: ${tool.name}`, {
|
|
361
|
-
error: e instanceof Error ? e.stack : JSON.stringify(e, null, 2),
|
|
362
|
-
toolArgs: context,
|
|
363
|
-
});
|
|
364
|
-
throw e;
|
|
365
|
-
}
|
|
366
|
-
},
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
if (tool.name) {
|
|
370
|
-
toolsRes[tool.name] = mastraTool;
|
|
393
|
+
if (tool.name) {
|
|
394
|
+
toolsRes[tool.name] = mastraTool;
|
|
395
|
+
}
|
|
396
|
+
} catch (toolCreationError: unknown) {
|
|
397
|
+
// Catch errors during tool creation itself (e.g., if createTool has issues)
|
|
398
|
+
this.log('error', `Failed to create Mastra tool wrapper for MCP tool: ${tool.name}`, {
|
|
399
|
+
error: toolCreationError instanceof Error ? toolCreationError.stack : String(toolCreationError),
|
|
400
|
+
mcpToolDefinition: tool,
|
|
401
|
+
});
|
|
371
402
|
}
|
|
372
403
|
});
|
|
373
404
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { describe, it, expect, beforeEach, afterEach, afterAll, beforeAll, vi } from 'vitest';
|
|
4
|
+
import { allTools, mcpServerName } from './__fixtures__/fire-crawl-complex-schema';
|
|
5
|
+
import type { LogHandler } from './client';
|
|
4
6
|
import { MCPClient } from './configuration';
|
|
5
7
|
|
|
6
8
|
vi.setConfig({ testTimeout: 80000, hookTimeout: 80000 });
|
|
@@ -357,4 +359,39 @@ describe('MCPClient', () => {
|
|
|
357
359
|
await mixedConfig.disconnect();
|
|
358
360
|
});
|
|
359
361
|
});
|
|
362
|
+
|
|
363
|
+
describe('Schema Handling', () => {
|
|
364
|
+
let complexClient: MCPClient;
|
|
365
|
+
let mockLogHandler: LogHandler & ReturnType<typeof vi.fn>;
|
|
366
|
+
|
|
367
|
+
beforeEach(async () => {
|
|
368
|
+
mockLogHandler = vi.fn();
|
|
369
|
+
|
|
370
|
+
complexClient = new MCPClient({
|
|
371
|
+
id: 'complex-schema-test-client-log-handler-firecrawl',
|
|
372
|
+
servers: {
|
|
373
|
+
'firecrawl-mcp': {
|
|
374
|
+
command: 'npx',
|
|
375
|
+
args: ['-y', 'tsx', path.join(__dirname, '__fixtures__/fire-crawl-complex-schema.ts')],
|
|
376
|
+
logger: mockLogHandler,
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
afterEach(async () => {
|
|
383
|
+
mockLogHandler.mockClear();
|
|
384
|
+
await complexClient?.disconnect().catch(() => {});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('should process tools from firecrawl-mcp without crashing', async () => {
|
|
388
|
+
const tools = await complexClient.getTools();
|
|
389
|
+
|
|
390
|
+
Object.keys(allTools).forEach(toolName => {
|
|
391
|
+
expect(tools).toHaveProperty(`${mcpServerName.replace(`-fixture`, ``)}_${toolName}`);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(mockLogHandler.mock.calls.length).toBeGreaterThan(0);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
360
397
|
});
|
package/src/server.test.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import type { ServerType } from '@hono/node-server';
|
|
4
|
+
import { serve } from '@hono/node-server';
|
|
3
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { Hono } from 'hono';
|
|
4
7
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
|
5
8
|
import { weatherTool } from './__fixtures__/tools';
|
|
6
9
|
import { MCPClient } from './configuration';
|
|
@@ -13,34 +16,34 @@ let httpServer: http.Server;
|
|
|
13
16
|
vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 });
|
|
14
17
|
|
|
15
18
|
describe('MCPServer', () => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
version: '0.1.0',
|
|
20
|
-
tools: { weatherTool },
|
|
21
|
-
});
|
|
19
|
+
describe('MCPServer SSE transport', () => {
|
|
20
|
+
let sseRes: Response | undefined;
|
|
21
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
messagePath: '/message',
|
|
29
|
-
req,
|
|
30
|
-
res,
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
server = new MCPServer({
|
|
25
|
+
name: 'Test MCP Server',
|
|
26
|
+
version: '0.1.0',
|
|
27
|
+
tools: { weatherTool },
|
|
31
28
|
});
|
|
32
|
-
});
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
httpServer = http.createServer(async (req, res) => {
|
|
31
|
+
const url = new URL(req.url || '', `http://localhost:${PORT}`);
|
|
32
|
+
await server.startSSE({
|
|
33
|
+
url,
|
|
34
|
+
ssePath: '/sse',
|
|
35
|
+
messagePath: '/message',
|
|
36
|
+
req,
|
|
37
|
+
res,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
41
|
+
await new Promise<void>(resolve => httpServer.listen(PORT, () => resolve()));
|
|
42
|
+
});
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
|
46
|
+
});
|
|
44
47
|
|
|
45
48
|
afterEach(async () => {
|
|
46
49
|
if (reader) {
|
|
@@ -111,4 +114,161 @@ describe('MCPServer', () => {
|
|
|
111
114
|
await existingConfig.disconnect();
|
|
112
115
|
});
|
|
113
116
|
});
|
|
117
|
+
describe('MCPServer HTTP Transport', () => {
|
|
118
|
+
let server: MCPServer;
|
|
119
|
+
let client: MCPClient;
|
|
120
|
+
const PORT = 9200 + Math.floor(Math.random() * 1000);
|
|
121
|
+
|
|
122
|
+
beforeAll(async () => {
|
|
123
|
+
server = new MCPServer({
|
|
124
|
+
name: 'Test MCP Server',
|
|
125
|
+
version: '0.1.0',
|
|
126
|
+
tools: { weatherTool },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
httpServer = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
130
|
+
const url = new URL(req.url || '', `http://localhost:${PORT}`);
|
|
131
|
+
await server.startHTTP({
|
|
132
|
+
url,
|
|
133
|
+
httpPath: '/http',
|
|
134
|
+
req,
|
|
135
|
+
res,
|
|
136
|
+
options: {
|
|
137
|
+
sessionIdGenerator: undefined,
|
|
138
|
+
enableJsonResponse: true,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await new Promise<void>(resolve => httpServer.listen(PORT, () => resolve()));
|
|
144
|
+
|
|
145
|
+
client = new MCPClient({
|
|
146
|
+
servers: {
|
|
147
|
+
local: {
|
|
148
|
+
url: new URL(`http://localhost:${PORT}/http`),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
afterAll(async () => {
|
|
155
|
+
httpServer.closeAllConnections?.();
|
|
156
|
+
await new Promise<void>(resolve =>
|
|
157
|
+
httpServer.close(() => {
|
|
158
|
+
resolve();
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
await server.close();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should return 404 for wrong path', async () => {
|
|
165
|
+
const res = await fetch(`http://localhost:${PORT}/wrong`);
|
|
166
|
+
expect(res.status).toBe(404);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should respond to HTTP request using client', async () => {
|
|
170
|
+
const tools = await client.getTools();
|
|
171
|
+
const tool = tools['local_weatherTool'];
|
|
172
|
+
expect(tool).toBeDefined();
|
|
173
|
+
|
|
174
|
+
// Call the tool
|
|
175
|
+
const result = await tool.execute({ context: { location: 'Austin' } });
|
|
176
|
+
|
|
177
|
+
// Check the result
|
|
178
|
+
expect(result).toBeDefined();
|
|
179
|
+
expect(result.content).toBeInstanceOf(Array);
|
|
180
|
+
expect(result.content.length).toBeGreaterThan(0);
|
|
181
|
+
|
|
182
|
+
const toolOutput = result.content[0];
|
|
183
|
+
expect(toolOutput.type).toBe('text');
|
|
184
|
+
const toolResult = JSON.parse(toolOutput.text);
|
|
185
|
+
expect(toolResult.location).toEqual('Austin');
|
|
186
|
+
expect(toolResult).toHaveProperty('temperature');
|
|
187
|
+
expect(toolResult).toHaveProperty('feelsLike');
|
|
188
|
+
expect(toolResult).toHaveProperty('humidity');
|
|
189
|
+
expect(toolResult).toHaveProperty('conditions');
|
|
190
|
+
expect(toolResult).toHaveProperty('windSpeed');
|
|
191
|
+
expect(toolResult).toHaveProperty('windGust');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('MCPServer Hono SSE Transport', () => {
|
|
196
|
+
let server: MCPServer;
|
|
197
|
+
let hono: Hono;
|
|
198
|
+
let honoServer: ServerType;
|
|
199
|
+
let client: MCPClient;
|
|
200
|
+
const PORT = 9300 + Math.floor(Math.random() * 1000);
|
|
201
|
+
|
|
202
|
+
beforeAll(async () => {
|
|
203
|
+
server = new MCPServer({
|
|
204
|
+
name: 'Test MCP Server',
|
|
205
|
+
version: '0.1.0',
|
|
206
|
+
tools: { weatherTool },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
hono = new Hono();
|
|
210
|
+
|
|
211
|
+
hono.get('/sse', async c => {
|
|
212
|
+
const url = new URL(c.req.url, `http://localhost:${PORT}`);
|
|
213
|
+
return await server.startHonoSSE({
|
|
214
|
+
url,
|
|
215
|
+
ssePath: '/sse',
|
|
216
|
+
messagePath: '/message',
|
|
217
|
+
context: c,
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
hono.post('/message', async c => {
|
|
222
|
+
// Use MCPServer's startHonoSSE to handle message endpoint
|
|
223
|
+
const url = new URL(c.req.url, `http://localhost:${PORT}`);
|
|
224
|
+
return await server.startHonoSSE({
|
|
225
|
+
url,
|
|
226
|
+
ssePath: '/sse',
|
|
227
|
+
messagePath: '/message',
|
|
228
|
+
context: c,
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
honoServer = serve({ fetch: hono.fetch, port: PORT });
|
|
233
|
+
|
|
234
|
+
// Initialize MCPClient with SSE endpoint
|
|
235
|
+
client = new MCPClient({
|
|
236
|
+
servers: {
|
|
237
|
+
local: {
|
|
238
|
+
url: new URL(`http://localhost:${PORT}/sse`),
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
afterAll(async () => {
|
|
245
|
+
honoServer.close();
|
|
246
|
+
await server.close();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should respond to SSE connection and tool call', async () => {
|
|
250
|
+
// Get tools from the client
|
|
251
|
+
const tools = await client.getTools();
|
|
252
|
+
const tool = tools['local_weatherTool'];
|
|
253
|
+
expect(tool).toBeDefined();
|
|
254
|
+
|
|
255
|
+
// Call the tool using the MCPClient (SSE transport)
|
|
256
|
+
const result = await tool.execute({ context: { location: 'Austin' } });
|
|
257
|
+
|
|
258
|
+
expect(result).toBeDefined();
|
|
259
|
+
expect(result.content).toBeInstanceOf(Array);
|
|
260
|
+
expect(result.content.length).toBeGreaterThan(0);
|
|
261
|
+
|
|
262
|
+
const toolOutput = result.content[0];
|
|
263
|
+
expect(toolOutput.type).toBe('text');
|
|
264
|
+
const toolResult = JSON.parse(toolOutput.text);
|
|
265
|
+
expect(toolResult.location).toEqual('Austin');
|
|
266
|
+
expect(toolResult).toHaveProperty('temperature');
|
|
267
|
+
expect(toolResult).toHaveProperty('feelsLike');
|
|
268
|
+
expect(toolResult).toHaveProperty('humidity');
|
|
269
|
+
expect(toolResult).toHaveProperty('conditions');
|
|
270
|
+
expect(toolResult).toHaveProperty('windSpeed');
|
|
271
|
+
expect(toolResult).toHaveProperty('windGust');
|
|
272
|
+
});
|
|
273
|
+
});
|
|
114
274
|
});
|
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { InternalCoreTool } from '@mastra/core';
|
|
|
4
4
|
import { makeCoreTool } from '@mastra/core';
|
|
5
5
|
import type { ToolsInput } from '@mastra/core/agent';
|
|
6
6
|
import { MCPServerBase } from '@mastra/core/mcp';
|
|
7
|
-
import type { MCPServerSSEOptions, ConvertedTool } from '@mastra/core/mcp';
|
|
7
|
+
import type { MCPServerSSEOptions, ConvertedTool, MCPServerHonoSSEOptions } from '@mastra/core/mcp';
|
|
8
8
|
import { RuntimeContext } from '@mastra/core/runtime-context';
|
|
9
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
10
10
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
@@ -12,12 +12,16 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
12
12
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
13
13
|
import type { StreamableHTTPServerTransportOptions } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
14
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
+
import type { SSEStreamingApi } from 'hono/streaming';
|
|
16
|
+
import { streamSSE } from 'hono/streaming';
|
|
17
|
+
import { SSETransport } from 'hono-mcp-server-sse-transport';
|
|
15
18
|
import { z } from 'zod';
|
|
16
19
|
|
|
17
20
|
export class MCPServer extends MCPServerBase {
|
|
18
21
|
private server: Server;
|
|
19
22
|
private stdioTransport?: StdioServerTransport;
|
|
20
23
|
private sseTransport?: SSEServerTransport;
|
|
24
|
+
private sseHonoTransports: Map<string, SSETransport>;
|
|
21
25
|
private streamableHTTPTransport?: StreamableHTTPServerTransport;
|
|
22
26
|
private listToolsHandlerIsRegistered: boolean = false;
|
|
23
27
|
private callToolHandlerIsRegistered: boolean = false;
|
|
@@ -36,6 +40,13 @@ export class MCPServer extends MCPServerBase {
|
|
|
36
40
|
return this.sseTransport;
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Get the current SSE Hono transport.
|
|
45
|
+
*/
|
|
46
|
+
public getSseHonoTransport(sessionId: string): SSETransport | undefined {
|
|
47
|
+
return this.sseHonoTransports.get(sessionId);
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
/**
|
|
40
51
|
* Get the current streamable HTTP transport.
|
|
41
52
|
*/
|
|
@@ -58,6 +69,7 @@ export class MCPServer extends MCPServerBase {
|
|
|
58
69
|
`Initialized MCPServer '${name}' v${version} with tools: ${Object.keys(this.convertedTools).join(', ')}`,
|
|
59
70
|
);
|
|
60
71
|
|
|
72
|
+
this.sseHonoTransports = new Map();
|
|
61
73
|
this.registerListToolsHandler();
|
|
62
74
|
this.registerCallToolHandler();
|
|
63
75
|
}
|
|
@@ -242,6 +254,44 @@ export class MCPServer extends MCPServerBase {
|
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Handles MCP-over-SSE protocol for user-provided HTTP servers.
|
|
259
|
+
* Call this from your HTTP server for both the SSE and message endpoints.
|
|
260
|
+
*
|
|
261
|
+
* @param url Parsed URL of the incoming request
|
|
262
|
+
* @param ssePath Path for establishing the SSE connection (e.g. '/sse')
|
|
263
|
+
* @param messagePath Path for POSTing client messages (e.g. '/message')
|
|
264
|
+
* @param context Incoming Hono context
|
|
265
|
+
*/
|
|
266
|
+
public async startHonoSSE({ url, ssePath, messagePath, context }: MCPServerHonoSSEOptions) {
|
|
267
|
+
if (url.pathname === ssePath) {
|
|
268
|
+
return streamSSE(context, async stream => {
|
|
269
|
+
await this.connectHonoSSE({
|
|
270
|
+
messagePath,
|
|
271
|
+
stream,
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
} else if (url.pathname === messagePath) {
|
|
275
|
+
this.logger.debug('Received message');
|
|
276
|
+
const sessionId = context.req.query('sessionId');
|
|
277
|
+
this.logger.debug('Received message for sessionId', { sessionId });
|
|
278
|
+
if (!sessionId) {
|
|
279
|
+
return context.text('No sessionId provided', 400);
|
|
280
|
+
}
|
|
281
|
+
if (!this.sseHonoTransports.has(sessionId)) {
|
|
282
|
+
return context.text(`No transport found for sessionId ${sessionId}`, 400);
|
|
283
|
+
}
|
|
284
|
+
const message = await this.sseHonoTransports.get(sessionId)?.handlePostMessage(context);
|
|
285
|
+
if (!message) {
|
|
286
|
+
return context.text('Transport not found', 400);
|
|
287
|
+
}
|
|
288
|
+
return message;
|
|
289
|
+
} else {
|
|
290
|
+
this.logger.debug('Unknown path:', { path: url.pathname });
|
|
291
|
+
return context.text('Unknown path', 404);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
245
295
|
/**
|
|
246
296
|
* Handles MCP-over-StreamableHTTP protocol for user-provided HTTP servers.
|
|
247
297
|
* Call this from your HTTP server for the streamable HTTP endpoint.
|
|
@@ -299,15 +349,6 @@ export class MCPServer extends MCPServerBase {
|
|
|
299
349
|
}
|
|
300
350
|
}
|
|
301
351
|
|
|
302
|
-
public async handlePostMessage(req: http.IncomingMessage, res: http.ServerResponse<http.IncomingMessage>) {
|
|
303
|
-
if (!this.sseTransport) {
|
|
304
|
-
res.writeHead(503);
|
|
305
|
-
res.end('SSE connection not established');
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
await this.sseTransport.handlePostMessage(req, res);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
352
|
public async connectSSE({
|
|
312
353
|
messagePath,
|
|
313
354
|
res,
|
|
@@ -329,6 +370,35 @@ export class MCPServer extends MCPServerBase {
|
|
|
329
370
|
});
|
|
330
371
|
}
|
|
331
372
|
|
|
373
|
+
public async connectHonoSSE({ messagePath, stream }: { messagePath: string; stream: SSEStreamingApi }) {
|
|
374
|
+
this.logger.debug('Received SSE connection');
|
|
375
|
+
const sseTransport = new SSETransport(messagePath, stream);
|
|
376
|
+
const sessionId = sseTransport.sessionId;
|
|
377
|
+
this.logger.debug('SSE Transport created with sessionId:', { sessionId });
|
|
378
|
+
this.sseHonoTransports.set(sessionId, sseTransport);
|
|
379
|
+
|
|
380
|
+
stream.onAbort(() => {
|
|
381
|
+
this.logger.debug('SSE Transport aborted with sessionId:', { sessionId });
|
|
382
|
+
this.sseHonoTransports.delete(sessionId);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await this.server.connect(sseTransport);
|
|
386
|
+
this.server.onclose = async () => {
|
|
387
|
+
this.logger.debug('SSE Transport closed with sessionId:', { sessionId });
|
|
388
|
+
this.sseHonoTransports.delete(sessionId);
|
|
389
|
+
await this.server.close();
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
while (true) {
|
|
393
|
+
// This will keep the connection alive
|
|
394
|
+
// You can also await for a promise that never resolves
|
|
395
|
+
const sessionIds = Array.from(this.sseHonoTransports.keys() || []);
|
|
396
|
+
this.logger.debug('Active Hono SSE sessions:', { sessionIds });
|
|
397
|
+
await stream.write(':keep-alive\n\n');
|
|
398
|
+
await stream.sleep(60_000);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
332
402
|
/**
|
|
333
403
|
* Close the MCP server and all its connections
|
|
334
404
|
*/
|
|
@@ -344,6 +414,12 @@ export class MCPServer extends MCPServerBase {
|
|
|
344
414
|
await this.sseTransport.close?.();
|
|
345
415
|
this.sseTransport = undefined;
|
|
346
416
|
}
|
|
417
|
+
if (this.sseHonoTransports) {
|
|
418
|
+
for (const transport of this.sseHonoTransports.values()) {
|
|
419
|
+
await transport.close?.();
|
|
420
|
+
}
|
|
421
|
+
this.sseHonoTransports.clear();
|
|
422
|
+
}
|
|
347
423
|
if (this.streamableHTTPTransport) {
|
|
348
424
|
await this.streamableHTTPTransport.close?.();
|
|
349
425
|
this.streamableHTTPTransport = undefined;
|