@mastra/mcp 0.4.1-alpha.2 → 0.4.1-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 +15 -0
- package/README.md +144 -92
- package/dist/_tsup-dts-rollup.d.cts +100 -40
- package/dist/_tsup-dts-rollup.d.ts +100 -40
- package/dist/index.cjs +200 -66
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +197 -67
- package/package.json +4 -3
- package/src/client.test.ts +121 -33
- package/src/client.ts +172 -56
- package/src/configuration.test.ts +21 -36
- package/src/configuration.ts +107 -26
- package/src/index.ts +2 -1
- package/src/server-logging.test.ts +4 -4
- package/src/server.test.ts +2 -2
package/src/client.ts
CHANGED
|
@@ -5,7 +5,8 @@ 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';
|
|
7
7
|
import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
8
|
-
import
|
|
8
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
9
|
+
import type { StreamableHTTPClientTransportOptions } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
9
10
|
import { DEFAULT_REQUEST_TIMEOUT_MSEC } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
10
11
|
import type { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
11
12
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
@@ -28,18 +29,43 @@ export interface LogMessage {
|
|
|
28
29
|
|
|
29
30
|
export type LogHandler = (logMessage: LogMessage) => void;
|
|
30
31
|
|
|
31
|
-
//
|
|
32
|
-
type
|
|
33
|
-
url: URL;
|
|
34
|
-
} & SSEClientTransportOptions;
|
|
35
|
-
|
|
36
|
-
export type MastraMCPServerDefinition = (StdioServerParameters | SSEClientParameters) & {
|
|
32
|
+
// Base options common to all server definitions
|
|
33
|
+
type BaseServerOptions = {
|
|
37
34
|
logger?: LogHandler;
|
|
38
35
|
timeout?: number;
|
|
39
36
|
capabilities?: ClientCapabilities;
|
|
40
37
|
enableServerLogs?: boolean;
|
|
41
38
|
};
|
|
42
39
|
|
|
40
|
+
type StdioServerDefinition = BaseServerOptions & {
|
|
41
|
+
command: string; // 'command' is required for Stdio
|
|
42
|
+
args?: string[];
|
|
43
|
+
env?: Record<string, string>;
|
|
44
|
+
|
|
45
|
+
url?: never; // Exclude 'url' for Stdio
|
|
46
|
+
requestInit?: never; // Exclude HTTP options for Stdio
|
|
47
|
+
eventSourceInit?: never; // Exclude HTTP options for Stdio
|
|
48
|
+
reconnectionOptions?: never; // Exclude Streamable HTTP specific options
|
|
49
|
+
sessionId?: never; // Exclude Streamable HTTP specific options
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// HTTP Server Definition (Streamable HTTP or SSE fallback)
|
|
53
|
+
type HttpServerDefinition = BaseServerOptions & {
|
|
54
|
+
url: URL; // 'url' is required for HTTP
|
|
55
|
+
|
|
56
|
+
command?: never; // Exclude 'command' for HTTP
|
|
57
|
+
args?: never; // Exclude Stdio options for HTTP
|
|
58
|
+
env?: never; // Exclude Stdio options for HTTP
|
|
59
|
+
|
|
60
|
+
// Include relevant options from SDK HTTP transport types
|
|
61
|
+
requestInit?: StreamableHTTPClientTransportOptions['requestInit'];
|
|
62
|
+
eventSourceInit?: SSEClientTransportOptions['eventSourceInit'];
|
|
63
|
+
reconnectionOptions?: StreamableHTTPClientTransportOptions['reconnectionOptions'];
|
|
64
|
+
sessionId?: StreamableHTTPClientTransportOptions['sessionId'];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type MastraMCPServerDefinition = StdioServerDefinition | HttpServerDefinition;
|
|
68
|
+
|
|
43
69
|
/**
|
|
44
70
|
* Convert an MCP LoggingLevel to a logger method name that exists in our logger
|
|
45
71
|
*/
|
|
@@ -63,13 +89,15 @@ function convertLogLevelToLoggerMethod(level: LoggingLevel): 'debug' | 'info' |
|
|
|
63
89
|
}
|
|
64
90
|
}
|
|
65
91
|
|
|
66
|
-
export class
|
|
92
|
+
export class InternalMastraMCPClient extends MastraBase {
|
|
67
93
|
name: string;
|
|
68
|
-
private transport: Transport;
|
|
69
94
|
private client: Client;
|
|
70
95
|
private readonly timeout: number;
|
|
71
96
|
private logHandler?: LogHandler;
|
|
72
97
|
private enableServerLogs?: boolean;
|
|
98
|
+
private static hasWarned = false;
|
|
99
|
+
private serverConfig: MastraMCPServerDefinition;
|
|
100
|
+
private transport?: Transport;
|
|
73
101
|
|
|
74
102
|
constructor({
|
|
75
103
|
name,
|
|
@@ -85,26 +113,18 @@ export class MastraMCPClient extends MastraBase {
|
|
|
85
113
|
timeout?: number;
|
|
86
114
|
}) {
|
|
87
115
|
super({ name: 'MastraMCPClient' });
|
|
116
|
+
if (!InternalMastraMCPClient.hasWarned) {
|
|
117
|
+
// eslint-disable-next-line no-console
|
|
118
|
+
console.warn(
|
|
119
|
+
'[DEPRECATION] MastraMCPClient is deprecated and will be removed in a future release. Please use MCPClient instead.',
|
|
120
|
+
);
|
|
121
|
+
InternalMastraMCPClient.hasWarned = true;
|
|
122
|
+
}
|
|
88
123
|
this.name = name;
|
|
89
124
|
this.timeout = timeout;
|
|
90
125
|
this.logHandler = server.logger;
|
|
91
126
|
this.enableServerLogs = server.enableServerLogs ?? true;
|
|
92
|
-
|
|
93
|
-
// Extract log handler from server config to avoid passing it to transport
|
|
94
|
-
const { logger, enableServerLogs, ...serverConfig } = server;
|
|
95
|
-
|
|
96
|
-
if (`url` in serverConfig) {
|
|
97
|
-
this.transport = new SSEClientTransport(serverConfig.url, {
|
|
98
|
-
requestInit: serverConfig.requestInit,
|
|
99
|
-
eventSourceInit: serverConfig.eventSourceInit,
|
|
100
|
-
});
|
|
101
|
-
} else {
|
|
102
|
-
this.transport = new StdioClientTransport({
|
|
103
|
-
...serverConfig,
|
|
104
|
-
// without ...getDefaultEnvironment() commands like npx will fail because there will be no PATH env var
|
|
105
|
-
env: { ...getDefaultEnvironment(), ...(serverConfig.env || {}) },
|
|
106
|
-
});
|
|
107
|
-
}
|
|
127
|
+
this.serverConfig = server;
|
|
108
128
|
|
|
109
129
|
this.client = new Client(
|
|
110
130
|
{
|
|
@@ -130,14 +150,16 @@ export class MastraMCPClient extends MastraBase {
|
|
|
130
150
|
// Convert MCP logging level to our logger method
|
|
131
151
|
const loggerMethod = convertLogLevelToLoggerMethod(level);
|
|
132
152
|
|
|
153
|
+
const msg = `[${this.name}] ${message}`;
|
|
154
|
+
|
|
133
155
|
// Log to internal logger
|
|
134
|
-
this.logger[loggerMethod](
|
|
156
|
+
this.logger[loggerMethod](msg, details);
|
|
135
157
|
|
|
136
158
|
// Send to registered handler if available
|
|
137
159
|
if (this.logHandler) {
|
|
138
160
|
this.logHandler({
|
|
139
161
|
level,
|
|
140
|
-
message,
|
|
162
|
+
message: msg,
|
|
141
163
|
timestamp: new Date(),
|
|
142
164
|
serverName: this.name,
|
|
143
165
|
details,
|
|
@@ -164,46 +186,135 @@ export class MastraMCPClient extends MastraBase {
|
|
|
164
186
|
}
|
|
165
187
|
}
|
|
166
188
|
|
|
189
|
+
private async connectStdio(command: string) {
|
|
190
|
+
this.log('debug', `Using Stdio transport for command: ${command}`);
|
|
191
|
+
try {
|
|
192
|
+
this.transport = new StdioClientTransport({
|
|
193
|
+
command,
|
|
194
|
+
args: this.serverConfig.args,
|
|
195
|
+
env: { ...getDefaultEnvironment(), ...(this.serverConfig.env || {}) },
|
|
196
|
+
});
|
|
197
|
+
await this.client.connect(this.transport, { timeout: this.serverConfig.timeout ?? this.timeout });
|
|
198
|
+
this.log('debug', `Successfully connected to MCP server via Stdio`);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
this.log('error', e instanceof Error ? e.stack || e.message : JSON.stringify(e));
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async connectHttp(url: URL) {
|
|
206
|
+
const { requestInit, eventSourceInit } = this.serverConfig;
|
|
207
|
+
|
|
208
|
+
this.log('debug', `Attempting to connect to URL: ${url}`);
|
|
209
|
+
|
|
210
|
+
// Assume /sse means sse.
|
|
211
|
+
let shouldTrySSE = url.pathname.endsWith(`/sse`);
|
|
212
|
+
|
|
213
|
+
if (!shouldTrySSE) {
|
|
214
|
+
try {
|
|
215
|
+
// Try Streamable HTTP transport first
|
|
216
|
+
this.log('debug', 'Trying Streamable HTTP transport...');
|
|
217
|
+
const streamableTransport = new StreamableHTTPClientTransport(url, {
|
|
218
|
+
requestInit,
|
|
219
|
+
reconnectionOptions: this.serverConfig.reconnectionOptions,
|
|
220
|
+
sessionId: this.serverConfig.sessionId,
|
|
221
|
+
});
|
|
222
|
+
await this.client.connect(streamableTransport, {
|
|
223
|
+
timeout:
|
|
224
|
+
// this is hardcoded to 3s because the long default timeout would be extremely slow for sse backwards compat (60s)
|
|
225
|
+
3000,
|
|
226
|
+
});
|
|
227
|
+
this.transport = streamableTransport;
|
|
228
|
+
this.log('debug', 'Successfully connected using Streamable HTTP transport.');
|
|
229
|
+
} catch (error) {
|
|
230
|
+
this.log('debug', `Streamable HTTP transport failed: ${error}`);
|
|
231
|
+
shouldTrySSE = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (shouldTrySSE) {
|
|
236
|
+
this.log('debug', 'Falling back to deprecated HTTP+SSE transport...');
|
|
237
|
+
try {
|
|
238
|
+
// Fallback to SSE transport
|
|
239
|
+
const sseTransport = new SSEClientTransport(url, { requestInit, eventSourceInit });
|
|
240
|
+
await this.client.connect(sseTransport, { timeout: this.serverConfig.timeout ?? this.timeout });
|
|
241
|
+
this.transport = sseTransport;
|
|
242
|
+
this.log('debug', 'Successfully connected using deprecated HTTP+SSE transport.');
|
|
243
|
+
} catch (sseError) {
|
|
244
|
+
this.log(
|
|
245
|
+
'error',
|
|
246
|
+
`Failed to connect with SSE transport after failing to connect to Streamable HTTP transport first. SSE error: ${sseError}`,
|
|
247
|
+
);
|
|
248
|
+
throw new Error('Could not connect to server with any available HTTP transport');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
167
253
|
private isConnected = false;
|
|
168
254
|
|
|
169
255
|
async connect() {
|
|
170
256
|
if (this.isConnected) return;
|
|
171
|
-
try {
|
|
172
|
-
this.log('debug', `Connecting to MCP server`);
|
|
173
|
-
await this.client.connect(this.transport, {
|
|
174
|
-
timeout: this.timeout,
|
|
175
|
-
});
|
|
176
|
-
this.isConnected = true;
|
|
177
|
-
const originalOnClose = this.client.onclose;
|
|
178
|
-
this.client.onclose = () => {
|
|
179
|
-
this.log('debug', `MCP server connection closed`);
|
|
180
|
-
this.isConnected = false;
|
|
181
|
-
if (typeof originalOnClose === `function`) {
|
|
182
|
-
originalOnClose();
|
|
183
|
-
}
|
|
184
|
-
};
|
|
185
|
-
asyncExitHook(
|
|
186
|
-
async () => {
|
|
187
|
-
this.log('debug', `Disconnecting MCP server during exit`);
|
|
188
|
-
await this.disconnect();
|
|
189
|
-
},
|
|
190
|
-
{ wait: 5000 },
|
|
191
|
-
);
|
|
192
257
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.
|
|
197
|
-
|
|
198
|
-
|
|
258
|
+
const { command, url } = this.serverConfig;
|
|
259
|
+
|
|
260
|
+
if (command) {
|
|
261
|
+
await this.connectStdio(command);
|
|
262
|
+
} else if (url) {
|
|
263
|
+
await this.connectHttp(url);
|
|
264
|
+
} else {
|
|
265
|
+
throw new Error('Server configuration must include either a command or a url.');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.isConnected = true;
|
|
269
|
+
const originalOnClose = this.client.onclose;
|
|
270
|
+
this.client.onclose = () => {
|
|
271
|
+
this.log('debug', `MCP server connection closed`);
|
|
199
272
|
this.isConnected = false;
|
|
200
|
-
|
|
273
|
+
if (typeof originalOnClose === `function`) {
|
|
274
|
+
originalOnClose();
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
asyncExitHook(
|
|
278
|
+
async () => {
|
|
279
|
+
this.log('debug', `Disconnecting MCP server during exit`);
|
|
280
|
+
await this.disconnect();
|
|
281
|
+
},
|
|
282
|
+
{ wait: 5000 },
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
process.on('SIGTERM', () => gracefulExit());
|
|
286
|
+
this.log('debug', `Successfully connected to MCP server`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get the current session ID if using the Streamable HTTP transport.
|
|
291
|
+
* Returns undefined if not connected or not using Streamable HTTP.
|
|
292
|
+
*/
|
|
293
|
+
get sessionId(): string | undefined {
|
|
294
|
+
if (this.transport instanceof StreamableHTTPClientTransport) {
|
|
295
|
+
return this.transport.sessionId;
|
|
201
296
|
}
|
|
297
|
+
return undefined;
|
|
202
298
|
}
|
|
203
299
|
|
|
204
300
|
async disconnect() {
|
|
301
|
+
if (!this.transport) {
|
|
302
|
+
this.log('debug', 'Disconnect called but no transport was connected.');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
205
305
|
this.log('debug', `Disconnecting from MCP server`);
|
|
206
|
-
|
|
306
|
+
try {
|
|
307
|
+
await this.transport.close();
|
|
308
|
+
this.log('debug', 'Successfully disconnected from MCP server');
|
|
309
|
+
} catch (e) {
|
|
310
|
+
this.log('error', 'Error during MCP server disconnect', {
|
|
311
|
+
error: e instanceof Error ? e.stack : JSON.stringify(e, null, 2),
|
|
312
|
+
});
|
|
313
|
+
throw e;
|
|
314
|
+
} finally {
|
|
315
|
+
this.transport = undefined;
|
|
316
|
+
this.isConnected = false;
|
|
317
|
+
}
|
|
207
318
|
}
|
|
208
319
|
|
|
209
320
|
// TODO: do the type magic to return the right method type. Right now we get infinitely deep infered type errors from Zod without using "any"
|
|
@@ -259,3 +370,8 @@ export class MastraMCPClient extends MastraBase {
|
|
|
259
370
|
return toolsRes;
|
|
260
371
|
}
|
|
261
372
|
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* @deprecated MastraMCPClient is deprecated and will be removed in a future release. Please use MCPClient instead.
|
|
376
|
+
*/
|
|
377
|
+
export const MastraMCPClient = InternalMastraMCPClient;
|
|
@@ -1,12 +1,12 @@
|
|
|
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 {
|
|
4
|
+
import { MCPClient } from './configuration';
|
|
5
5
|
|
|
6
6
|
vi.setConfig({ testTimeout: 80000, hookTimeout: 80000 });
|
|
7
7
|
|
|
8
|
-
describe('
|
|
9
|
-
let mcp:
|
|
8
|
+
describe('MCPClient', () => {
|
|
9
|
+
let mcp: MCPClient;
|
|
10
10
|
let weatherProcess: ReturnType<typeof spawn>;
|
|
11
11
|
|
|
12
12
|
beforeAll(async () => {
|
|
@@ -36,7 +36,7 @@ describe('MCPConfiguration', () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
beforeEach(async () => {
|
|
39
|
-
mcp = new
|
|
39
|
+
mcp = new MCPClient({
|
|
40
40
|
servers: {
|
|
41
41
|
stockPrice: {
|
|
42
42
|
command: 'npx',
|
|
@@ -95,7 +95,7 @@ describe('MCPConfiguration', () => {
|
|
|
95
95
|
});
|
|
96
96
|
|
|
97
97
|
it('should handle connection errors gracefully', async () => {
|
|
98
|
-
const badConfig = new
|
|
98
|
+
const badConfig = new MCPClient({
|
|
99
99
|
servers: {
|
|
100
100
|
badServer: {
|
|
101
101
|
command: 'nonexistent-command',
|
|
@@ -110,7 +110,7 @@ describe('MCPConfiguration', () => {
|
|
|
110
110
|
|
|
111
111
|
describe('Instance Management', () => {
|
|
112
112
|
it('should allow multiple instances with different IDs', async () => {
|
|
113
|
-
const config2 = new
|
|
113
|
+
const config2 = new MCPClient({
|
|
114
114
|
id: 'custom-id',
|
|
115
115
|
servers: {
|
|
116
116
|
stockPrice: {
|
|
@@ -130,7 +130,7 @@ describe('MCPConfiguration', () => {
|
|
|
130
130
|
it('should allow reuse of configuration after closing', async () => {
|
|
131
131
|
await mcp.disconnect();
|
|
132
132
|
|
|
133
|
-
const config2 = new
|
|
133
|
+
const config2 = new MCPClient({
|
|
134
134
|
servers: {
|
|
135
135
|
stockPrice: {
|
|
136
136
|
command: 'npx',
|
|
@@ -150,7 +150,7 @@ describe('MCPConfiguration', () => {
|
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
it('should throw error when creating duplicate instance without ID', async () => {
|
|
153
|
-
const existingConfig = new
|
|
153
|
+
const existingConfig = new MCPClient({
|
|
154
154
|
servers: {
|
|
155
155
|
stockPrice: {
|
|
156
156
|
command: 'npx',
|
|
@@ -164,7 +164,7 @@ describe('MCPConfiguration', () => {
|
|
|
164
164
|
|
|
165
165
|
expect(
|
|
166
166
|
() =>
|
|
167
|
-
new
|
|
167
|
+
new MCPClient({
|
|
168
168
|
servers: {
|
|
169
169
|
stockPrice: {
|
|
170
170
|
command: 'npx',
|
|
@@ -175,14 +175,14 @@ describe('MCPConfiguration', () => {
|
|
|
175
175
|
},
|
|
176
176
|
},
|
|
177
177
|
}),
|
|
178
|
-
).toThrow(/
|
|
178
|
+
).toThrow(/MCPClient was initialized multiple times/);
|
|
179
179
|
|
|
180
180
|
await existingConfig.disconnect();
|
|
181
181
|
});
|
|
182
182
|
});
|
|
183
|
-
describe('
|
|
183
|
+
describe('MCPClient Operation Timeouts', () => {
|
|
184
184
|
it('should respect custom timeout in configuration', async () => {
|
|
185
|
-
const config = new
|
|
185
|
+
const config = new MCPClient({
|
|
186
186
|
id: 'test-timeout-config',
|
|
187
187
|
timeout: 3000, // 3 second timeout
|
|
188
188
|
servers: {
|
|
@@ -208,7 +208,7 @@ describe('MCPConfiguration', () => {
|
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
it('should respect per-server timeout override', async () => {
|
|
211
|
-
const config = new
|
|
211
|
+
const config = new MCPClient({
|
|
212
212
|
id: 'test-server-timeout-config',
|
|
213
213
|
timeout: 500, // Global timeout of 500ms
|
|
214
214
|
servers: {
|
|
@@ -236,14 +236,15 @@ describe('MCPConfiguration', () => {
|
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
-
describe('
|
|
239
|
+
describe('MCPClient Connection Timeout', () => {
|
|
240
240
|
it('should throw timeout error for slow starting server', async () => {
|
|
241
|
-
const slowConfig = new
|
|
241
|
+
const slowConfig = new MCPClient({
|
|
242
242
|
id: 'test-slow-server',
|
|
243
243
|
servers: {
|
|
244
244
|
slowServer: {
|
|
245
245
|
command: 'node',
|
|
246
246
|
args: ['-e', 'setTimeout(() => process.exit(0), 65000)'], // Simulate a server that takes 65 seconds to start
|
|
247
|
+
timeout: 1000,
|
|
247
248
|
},
|
|
248
249
|
},
|
|
249
250
|
});
|
|
@@ -252,14 +253,14 @@ describe('MCPConfiguration', () => {
|
|
|
252
253
|
await slowConfig.disconnect();
|
|
253
254
|
});
|
|
254
255
|
|
|
255
|
-
it('timeout should be longer than
|
|
256
|
-
const slowConfig = new
|
|
256
|
+
it('timeout should be longer than configured timeout', async () => {
|
|
257
|
+
const slowConfig = new MCPClient({
|
|
257
258
|
id: 'test-slow-server',
|
|
258
|
-
timeout:
|
|
259
|
+
timeout: 2000,
|
|
259
260
|
servers: {
|
|
260
261
|
slowServer: {
|
|
261
262
|
command: 'node',
|
|
262
|
-
args: ['-e', 'setTimeout(() => process.exit(0),
|
|
263
|
+
args: ['-e', 'setTimeout(() => process.exit(0), 1000)'], // Simulate a server that takes 1 second to start
|
|
263
264
|
},
|
|
264
265
|
},
|
|
265
266
|
});
|
|
@@ -270,24 +271,8 @@ describe('MCPConfiguration', () => {
|
|
|
270
271
|
await slowConfig.disconnect();
|
|
271
272
|
});
|
|
272
273
|
|
|
273
|
-
it('should respect custom timeout configuration', async () => {
|
|
274
|
-
const quickConfig = new MCPConfiguration({
|
|
275
|
-
id: 'test-quick-timeout',
|
|
276
|
-
timeout: 1000, // Very short global timeout
|
|
277
|
-
servers: {
|
|
278
|
-
slowServer: {
|
|
279
|
-
command: 'node',
|
|
280
|
-
args: ['-e', 'setTimeout(() => process.exit(0), 30000)'], // Takes 30 seconds to exit
|
|
281
|
-
},
|
|
282
|
-
},
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
await expect(quickConfig.getTools()).rejects.toThrow(/Request timed out/);
|
|
286
|
-
await quickConfig.disconnect();
|
|
287
|
-
});
|
|
288
|
-
|
|
289
274
|
it('should respect per-server timeout configuration', async () => {
|
|
290
|
-
const mixedConfig = new
|
|
275
|
+
const mixedConfig = new MCPClient({
|
|
291
276
|
id: 'test-mixed-timeout',
|
|
292
277
|
timeout: 1000, // Short global timeout
|
|
293
278
|
servers: {
|
package/src/configuration.ts
CHANGED
|
@@ -1,66 +1,100 @@
|
|
|
1
1
|
import { MastraBase } from '@mastra/core/base';
|
|
2
2
|
import { DEFAULT_REQUEST_TIMEOUT_MSEC } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
3
|
+
import equal from 'fast-deep-equal';
|
|
3
4
|
import { v5 as uuidv5 } from 'uuid';
|
|
4
|
-
import {
|
|
5
|
+
import { InternalMastraMCPClient } from './client';
|
|
5
6
|
import type { MastraMCPServerDefinition } from './client';
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const mcpClientInstances = new Map<string, InstanceType<typeof MCPClient>>();
|
|
8
9
|
|
|
9
|
-
export interface
|
|
10
|
+
export interface MCPClientOptions {
|
|
10
11
|
id?: string;
|
|
11
12
|
servers: Record<string, MastraMCPServerDefinition>;
|
|
12
13
|
timeout?: number; // Optional global timeout
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export class
|
|
16
|
+
export class MCPClient extends MastraBase {
|
|
16
17
|
private serverConfigs: Record<string, MastraMCPServerDefinition> = {};
|
|
17
18
|
private id: string;
|
|
18
19
|
private defaultTimeout: number;
|
|
20
|
+
private mcpClientsById = new Map<string, InternalMastraMCPClient>();
|
|
21
|
+
private disconnectPromise: Promise<void> | null = null;
|
|
19
22
|
|
|
20
|
-
constructor(args:
|
|
21
|
-
super({ name: '
|
|
23
|
+
constructor(args: MCPClientOptions) {
|
|
24
|
+
super({ name: 'MCPClient' });
|
|
22
25
|
this.defaultTimeout = args.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
|
|
23
26
|
this.serverConfigs = args.servers;
|
|
24
27
|
this.id = args.id ?? this.makeId();
|
|
25
28
|
|
|
29
|
+
if (args.id) {
|
|
30
|
+
this.id = args.id;
|
|
31
|
+
const cached = mcpClientInstances.get(this.id);
|
|
32
|
+
|
|
33
|
+
if (cached && !equal(cached.serverConfigs, args.servers)) {
|
|
34
|
+
const existingInstance = mcpClientInstances.get(this.id);
|
|
35
|
+
if (existingInstance) {
|
|
36
|
+
void existingInstance.disconnect();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
this.id = this.makeId();
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
// to prevent memory leaks return the same MCP server instance when configured the same way multiple times
|
|
27
|
-
const existingInstance =
|
|
44
|
+
const existingInstance = mcpClientInstances.get(this.id);
|
|
28
45
|
if (existingInstance) {
|
|
29
46
|
if (!args.id) {
|
|
30
|
-
throw new Error(`
|
|
47
|
+
throw new Error(`MCPClient was initialized multiple times with the same configuration options.
|
|
31
48
|
|
|
32
49
|
This error is intended to prevent memory leaks.
|
|
33
50
|
|
|
34
51
|
To fix this you have three different options:
|
|
35
|
-
1. If you need multiple
|
|
36
|
-
2. Call "await
|
|
37
|
-
3. If you only need one instance of
|
|
52
|
+
1. If you need multiple MCPClient class instances with identical server configurations, set an id when configuring: new MCPClient({ id: "my-unique-id" })
|
|
53
|
+
2. Call "await client.disconnect()" after you're done using the client and before you recreate another instance with the same options. If the identical MCPClient instance is already closed at the time of re-creating it, you will not see this error.
|
|
54
|
+
3. If you only need one instance of MCPClient in your app, refactor your code so it's only created one time (ex. move it out of a loop into a higher scope code block)
|
|
38
55
|
`);
|
|
39
56
|
}
|
|
40
57
|
return existingInstance;
|
|
41
58
|
}
|
|
59
|
+
|
|
60
|
+
mcpClientInstances.set(this.id, this);
|
|
42
61
|
this.addToInstanceCache();
|
|
43
62
|
return this;
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
private addToInstanceCache() {
|
|
47
|
-
if (!
|
|
48
|
-
|
|
66
|
+
if (!mcpClientInstances.has(this.id)) {
|
|
67
|
+
mcpClientInstances.set(this.id, this);
|
|
49
68
|
}
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
private makeId() {
|
|
53
72
|
const text = JSON.stringify(this.serverConfigs).normalize('NFKC');
|
|
54
|
-
const idNamespace = uuidv5(`
|
|
73
|
+
const idNamespace = uuidv5(`MCPClient`, uuidv5.DNS);
|
|
55
74
|
|
|
56
75
|
return uuidv5(text, idNamespace);
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
public async disconnect() {
|
|
60
|
-
|
|
79
|
+
// Helps to prevent race condition
|
|
80
|
+
// If there is already a disconnect ongoing, return the existing promise.
|
|
81
|
+
if (this.disconnectPromise) {
|
|
82
|
+
return this.disconnectPromise;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.disconnectPromise = (async () => {
|
|
86
|
+
try {
|
|
87
|
+
mcpClientInstances.delete(this.id);
|
|
61
88
|
|
|
62
|
-
|
|
63
|
-
|
|
89
|
+
// Disconnect all clients in the cache
|
|
90
|
+
await Promise.all(Array.from(this.mcpClientsById.values()).map(client => client.disconnect()));
|
|
91
|
+
this.mcpClientsById.clear();
|
|
92
|
+
} finally {
|
|
93
|
+
this.disconnectPromise = null;
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
|
|
97
|
+
return this.disconnectPromise;
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
public async getTools() {
|
|
@@ -89,35 +123,61 @@ To fix this you have three different options:
|
|
|
89
123
|
return connectedToolsets;
|
|
90
124
|
}
|
|
91
125
|
|
|
92
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Get the current session IDs for all connected MCP clients using the Streamable HTTP transport.
|
|
128
|
+
* Returns an object mapping server names to their session IDs.
|
|
129
|
+
*/
|
|
130
|
+
get sessionIds(): Record<string, string> {
|
|
131
|
+
const sessionIds: Record<string, string> = {};
|
|
132
|
+
for (const [serverName, client] of this.mcpClientsById.entries()) {
|
|
133
|
+
if (client.sessionId) {
|
|
134
|
+
sessionIds[serverName] = client.sessionId;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return sessionIds;
|
|
138
|
+
}
|
|
139
|
+
|
|
93
140
|
private async getConnectedClient(name: string, config: MastraMCPServerDefinition) {
|
|
141
|
+
// Helps to prevent race condition.
|
|
142
|
+
// If we want to call connect() we need to wait for the disconnect to complete first if any is ongoing.
|
|
143
|
+
if (this.disconnectPromise) {
|
|
144
|
+
await this.disconnectPromise;
|
|
145
|
+
}
|
|
146
|
+
|
|
94
147
|
const exists = this.mcpClientsById.has(name);
|
|
148
|
+
const existingClient = this.mcpClientsById.get(name);
|
|
95
149
|
|
|
96
150
|
if (exists) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
151
|
+
// This is just to satisfy Typescript since technically you could have this.mcpClientsById.set('someKey', undefined);
|
|
152
|
+
// Should never reach this point basically we always create a new MastraMCPClient instance when we add to the Map.
|
|
153
|
+
if (!existingClient) {
|
|
154
|
+
throw new Error(`Client ${name} exists but is undefined`);
|
|
155
|
+
}
|
|
156
|
+
await existingClient.connect();
|
|
157
|
+
return existingClient;
|
|
101
158
|
}
|
|
102
159
|
|
|
103
160
|
this.logger.debug(`Connecting to ${name} MCP server`);
|
|
104
161
|
|
|
105
162
|
// Create client with server configuration including log handler
|
|
106
|
-
const mcpClient = new
|
|
163
|
+
const mcpClient = new InternalMastraMCPClient({
|
|
107
164
|
name,
|
|
108
165
|
server: config,
|
|
109
166
|
timeout: config.timeout ?? this.defaultTimeout,
|
|
110
167
|
});
|
|
111
168
|
|
|
112
169
|
this.mcpClientsById.set(name, mcpClient);
|
|
170
|
+
|
|
113
171
|
try {
|
|
114
172
|
await mcpClient.connect();
|
|
115
173
|
} catch (e) {
|
|
116
174
|
this.mcpClientsById.delete(name);
|
|
117
|
-
this.logger.error(`
|
|
175
|
+
this.logger.error(`MCPClient errored connecting to MCP server ${name}`, {
|
|
118
176
|
error: e instanceof Error ? e.message : String(e),
|
|
119
177
|
});
|
|
120
|
-
throw new Error(
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Failed to connect to MCP server ${name}: ${e instanceof Error ? e.stack || e.message : String(e)}`,
|
|
180
|
+
);
|
|
121
181
|
}
|
|
122
182
|
|
|
123
183
|
this.logger.debug(`Connected to ${name} MCP server`);
|
|
@@ -129,7 +189,7 @@ To fix this you have three different options:
|
|
|
129
189
|
cb: (input: {
|
|
130
190
|
serverName: string;
|
|
131
191
|
tools: Record<string, any>; // <- any because we don't have proper tool schemas
|
|
132
|
-
client: InstanceType<typeof
|
|
192
|
+
client: InstanceType<typeof InternalMastraMCPClient>;
|
|
133
193
|
}) => Promise<void>,
|
|
134
194
|
) {
|
|
135
195
|
await Promise.all(
|
|
@@ -141,3 +201,24 @@ To fix this you have three different options:
|
|
|
141
201
|
);
|
|
142
202
|
}
|
|
143
203
|
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @deprecated MCPConfigurationOptions is deprecated and will be removed in a future release. Use MCPClientOptions instead.
|
|
207
|
+
*/
|
|
208
|
+
export interface MCPConfigurationOptions {
|
|
209
|
+
id?: string;
|
|
210
|
+
servers: Record<string, MastraMCPServerDefinition>;
|
|
211
|
+
timeout?: number; // Optional global timeout
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @deprecated MCPConfiguration is deprecated and will be removed in a future release. Use MCPClient instead.
|
|
216
|
+
*/
|
|
217
|
+
export class MCPConfiguration extends MCPClient {
|
|
218
|
+
constructor(args: MCPClientOptions) {
|
|
219
|
+
super(args);
|
|
220
|
+
this.logger.warn(
|
|
221
|
+
`MCPConfiguration has been renamed to MCPClient and MCPConfiguration is deprecated. The API is identical but the MCPConfiguration export will be removed in the future. Update your imports now to prevent future errors.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/index.ts
CHANGED