@mastra/mcp 0.3.10-alpha.5 → 0.3.10-alpha.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mastra/mcp",
3
- "version": "0.3.10-alpha.5",
3
+ "version": "0.3.10-alpha.6",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "date-fns": "^4.1.0",
27
27
  "exit-hook": "^4.0.0",
28
28
  "uuid": "^11.1.0",
29
- "@mastra/core": "^0.8.3-alpha.4"
29
+ "@mastra/core": "^0.8.3-alpha.5"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@ai-sdk/anthropic": "^1.1.15",
package/src/client.ts CHANGED
@@ -9,28 +9,68 @@ import type { StdioServerParameters } from '@modelcontextprotocol/sdk/client/std
9
9
  import { DEFAULT_REQUEST_TIMEOUT_MSEC } from '@modelcontextprotocol/sdk/shared/protocol.js';
10
10
  import type { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js';
11
11
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
12
- import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js';
12
+ import type { ClientCapabilities, LoggingLevel } from '@modelcontextprotocol/sdk/types.js';
13
13
  import { CallToolResultSchema, ListResourcesResultSchema } from '@modelcontextprotocol/sdk/types.js';
14
14
 
15
15
  import { asyncExitHook, gracefulExit } from 'exit-hook';
16
+ import { z } from 'zod';
17
+
18
+ // Re-export MCP SDK LoggingLevel for convenience
19
+ export type { LoggingLevel } from '@modelcontextprotocol/sdk/types.js';
20
+
21
+ export interface LogMessage {
22
+ level: LoggingLevel;
23
+ message: string;
24
+ timestamp: Date;
25
+ serverName: string;
26
+ details?: Record<string, any>;
27
+ }
28
+
29
+ export type LogHandler = (logMessage: LogMessage) => void;
16
30
 
17
31
  // Omit the fields we want to control from the SDK options
18
32
  type SSEClientParameters = {
19
33
  url: URL;
20
- timeout?: number;
21
34
  } & SSEClientTransportOptions;
22
35
 
23
- type StdioServerParametersWithTimeout = StdioServerParameters & {
36
+ export type MastraMCPServerDefinition = (StdioServerParameters | SSEClientParameters) & {
37
+ logger?: LogHandler;
24
38
  timeout?: number;
39
+ capabilities?: ClientCapabilities;
40
+ enableServerLogs?: boolean;
25
41
  };
26
42
 
27
- export type MastraMCPServerDefinition = StdioServerParametersWithTimeout | SSEClientParameters;
43
+ /**
44
+ * Convert an MCP LoggingLevel to a logger method name that exists in our logger
45
+ */
46
+ function convertLogLevelToLoggerMethod(level: LoggingLevel): 'debug' | 'info' | 'warn' | 'error' {
47
+ switch (level) {
48
+ case 'debug':
49
+ return 'debug';
50
+ case 'info':
51
+ case 'notice':
52
+ return 'info';
53
+ case 'warning':
54
+ return 'warn';
55
+ case 'error':
56
+ case 'critical':
57
+ case 'alert':
58
+ case 'emergency':
59
+ return 'error';
60
+ default:
61
+ // For any other levels, default to info
62
+ return 'info';
63
+ }
64
+ }
28
65
 
29
66
  export class MastraMCPClient extends MastraBase {
30
67
  name: string;
31
68
  private transport: Transport;
32
69
  private client: Client;
33
70
  private readonly timeout: number;
71
+ private logHandler?: LogHandler;
72
+ private enableServerLogs?: boolean;
73
+
34
74
  constructor({
35
75
  name,
36
76
  version = '1.0.0',
@@ -47,17 +87,22 @@ export class MastraMCPClient extends MastraBase {
47
87
  super({ name: 'MastraMCPClient' });
48
88
  this.name = name;
49
89
  this.timeout = timeout;
90
+ this.logHandler = server.logger;
91
+ 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;
50
95
 
51
- if (`url` in server) {
52
- this.transport = new SSEClientTransport(server.url, {
53
- requestInit: server.requestInit,
54
- eventSourceInit: server.eventSourceInit,
96
+ if (`url` in serverConfig) {
97
+ this.transport = new SSEClientTransport(serverConfig.url, {
98
+ requestInit: serverConfig.requestInit,
99
+ eventSourceInit: serverConfig.eventSourceInit,
55
100
  });
56
101
  } else {
57
102
  this.transport = new StdioClientTransport({
58
- ...server,
103
+ ...serverConfig,
59
104
  // without ...getDefaultEnvironment() commands like npx will fail because there will be no PATH env var
60
- env: { ...getDefaultEnvironment(), ...(server.env || {}) },
105
+ env: { ...getDefaultEnvironment(), ...(serverConfig.env || {}) },
61
106
  });
62
107
  }
63
108
 
@@ -70,6 +115,53 @@ export class MastraMCPClient extends MastraBase {
70
115
  capabilities,
71
116
  },
72
117
  );
118
+
119
+ // Set up log message capturing
120
+ this.setupLogging();
121
+ }
122
+
123
+ /**
124
+ * Log a message at the specified level
125
+ * @param level Log level
126
+ * @param message Log message
127
+ * @param details Optional additional details
128
+ */
129
+ private log(level: LoggingLevel, message: string, details?: Record<string, any>): void {
130
+ // Convert MCP logging level to our logger method
131
+ const loggerMethod = convertLogLevelToLoggerMethod(level);
132
+
133
+ // Log to internal logger
134
+ this.logger[loggerMethod](message, details);
135
+
136
+ // Send to registered handler if available
137
+ if (this.logHandler) {
138
+ this.logHandler({
139
+ level,
140
+ message,
141
+ timestamp: new Date(),
142
+ serverName: this.name,
143
+ details,
144
+ });
145
+ }
146
+ }
147
+
148
+ private setupLogging(): void {
149
+ if (this.enableServerLogs) {
150
+ this.client.setNotificationHandler(
151
+ z.object({
152
+ method: z.literal('notifications/message'),
153
+ params: z
154
+ .object({
155
+ level: z.string(),
156
+ })
157
+ .passthrough(),
158
+ }),
159
+ notification => {
160
+ const { level, ...params } = notification.params;
161
+ this.log(level as LoggingLevel, '[MCP SERVER LOG]', params);
162
+ },
163
+ );
164
+ }
73
165
  }
74
166
 
75
167
  private isConnected = false;
@@ -77,12 +169,14 @@ export class MastraMCPClient extends MastraBase {
77
169
  async connect() {
78
170
  if (this.isConnected) return;
79
171
  try {
172
+ this.log('debug', `Connecting to MCP server`);
80
173
  await this.client.connect(this.transport, {
81
174
  timeout: this.timeout,
82
175
  });
83
176
  this.isConnected = true;
84
177
  const originalOnClose = this.client.onclose;
85
178
  this.client.onclose = () => {
179
+ this.log('debug', `MCP server connection closed`);
86
180
  this.isConnected = false;
87
181
  if (typeof originalOnClose === `function`) {
88
182
  originalOnClose();
@@ -90,38 +184,43 @@ export class MastraMCPClient extends MastraBase {
90
184
  };
91
185
  asyncExitHook(
92
186
  async () => {
93
- this.logger.debug(`Disconnecting ${this.name} MCP server`);
187
+ this.log('debug', `Disconnecting MCP server during exit`);
94
188
  await this.disconnect();
95
189
  },
96
190
  { wait: 5000 },
97
191
  );
98
192
 
99
193
  process.on('SIGTERM', () => gracefulExit());
194
+ this.log('info', `Successfully connected to MCP server`);
100
195
  } catch (e) {
101
- this.logger.error(
102
- `Failed connecting to MCPClient with name ${this.name}.\n${e instanceof Error ? e.stack : JSON.stringify(e, null, 2)}`,
103
- );
196
+ this.log('error', `Failed connecting to MCP server`, {
197
+ error: e instanceof Error ? e.stack : JSON.stringify(e, null, 2),
198
+ });
104
199
  this.isConnected = false;
105
200
  throw e;
106
201
  }
107
202
  }
108
203
 
109
204
  async disconnect() {
205
+ this.log('debug', `Disconnecting from MCP server`);
110
206
  return await this.client.close();
111
207
  }
112
208
 
113
209
  // 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"
114
210
 
115
211
  async resources(): Promise<ReturnType<Protocol<any, any, any>['request']>> {
212
+ this.log('debug', `Requesting resources from MCP server`);
116
213
  return await this.client.request({ method: 'resources/list' }, ListResourcesResultSchema, {
117
214
  timeout: this.timeout,
118
215
  });
119
216
  }
120
217
 
121
218
  async tools() {
219
+ this.log('debug', `Requesting tools from MCP server`);
122
220
  const { tools } = await this.client.listTools({ timeout: this.timeout });
123
221
  const toolsRes: Record<string, any> = {};
124
222
  tools.forEach(tool => {
223
+ this.log('debug', `Processing tool: ${tool.name}`);
125
224
  const s = jsonSchemaToModel(tool.inputSchema);
126
225
  const mastraTool = createTool({
127
226
  id: `${this.name}_${tool.name}`,
@@ -129,6 +228,7 @@ export class MastraMCPClient extends MastraBase {
129
228
  inputSchema: s,
130
229
  execute: async ({ context }) => {
131
230
  try {
231
+ this.log('debug', `Executing tool: ${tool.name}`, { toolArgs: context });
132
232
  const res = await this.client.callTool(
133
233
  {
134
234
  name: tool.name,
@@ -139,11 +239,13 @@ export class MastraMCPClient extends MastraBase {
139
239
  timeout: this.timeout,
140
240
  },
141
241
  );
142
-
242
+ this.log('debug', `Tool executed successfully: ${tool.name}`);
143
243
  return res;
144
244
  } catch (e) {
145
- console.log('Error calling tool', tool.name);
146
- console.error(e);
245
+ this.log('error', `Error calling tool: ${tool.name}`, {
246
+ error: e instanceof Error ? e.stack : JSON.stringify(e, null, 2),
247
+ toolArgs: context,
248
+ });
147
249
  throw e;
148
250
  }
149
251
  },
@@ -6,16 +6,18 @@ import type { MastraMCPServerDefinition } from './client';
6
6
 
7
7
  const mastraMCPConfigurationInstances = new Map<string, InstanceType<typeof MCPConfiguration>>();
8
8
 
9
+ export interface MCPConfigurationOptions {
10
+ id?: string;
11
+ servers: Record<string, MastraMCPServerDefinition>;
12
+ timeout?: number; // Optional global timeout
13
+ }
14
+
9
15
  export class MCPConfiguration extends MastraBase {
10
16
  private serverConfigs: Record<string, MastraMCPServerDefinition> = {};
11
17
  private id: string;
12
18
  private defaultTimeout: number;
13
19
 
14
- constructor(args: {
15
- id?: string;
16
- servers: Record<string, MastraMCPServerDefinition>;
17
- timeout?: number; // Optional global timeout
18
- }) {
20
+ constructor(args: MCPConfigurationOptions) {
19
21
  super({ name: 'MCPConfiguration' });
20
22
  this.defaultTimeout = args.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
21
23
  this.serverConfigs = args.servers;
@@ -100,6 +102,7 @@ To fix this you have three different options:
100
102
 
101
103
  this.logger.debug(`Connecting to ${name} MCP server`);
102
104
 
105
+ // Create client with server configuration including log handler
103
106
  const mcpClient = new MastraMCPClient({
104
107
  name,
105
108
  server: config,
@@ -111,7 +114,9 @@ To fix this you have three different options:
111
114
  await mcpClient.connect();
112
115
  } catch (e) {
113
116
  this.mcpClientsById.delete(name);
114
- this.logger.error(`MCPConfiguration errored connecting to MCP server ${name}`);
117
+ this.logger.error(`MCPConfiguration errored connecting to MCP server ${name}`, {
118
+ error: e instanceof Error ? e.message : String(e),
119
+ });
115
120
  throw e;
116
121
  }
117
122
 
@@ -0,0 +1,189 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
4
+ import type { LogMessage, LoggingLevel } from './client';
5
+ import { MCPConfiguration } from './configuration';
6
+
7
+ // Increase test timeout for server operations
8
+ vi.setConfig({ testTimeout: 80000, hookTimeout: 80000 });
9
+
10
+ describe('MCP Server Logging', () => {
11
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
12
+ let weatherProcess: ReturnType<typeof spawn>;
13
+
14
+ beforeAll(async () => {
15
+ // Start the weather SSE server
16
+ weatherProcess = spawn('npx', ['-y', 'tsx', path.join(__dirname, '__fixtures__/weather.ts')], {
17
+ env: { ...process.env, PORT: '60809' },
18
+ });
19
+
20
+ // Wait for SSE server to be ready
21
+ let resolved = false;
22
+ await new Promise<void>((resolve, reject) => {
23
+ weatherProcess.on(`exit`, () => {
24
+ if (!resolved) reject();
25
+ });
26
+ if (weatherProcess.stderr) {
27
+ weatherProcess.stderr.on(`data`, chunk => {
28
+ console.error(chunk.toString());
29
+ });
30
+ }
31
+ if (weatherProcess.stdout) {
32
+ weatherProcess.stdout.on('data', chunk => {
33
+ if (chunk.toString().includes('server is running on SSE')) {
34
+ resolve();
35
+ resolved = true;
36
+ }
37
+ });
38
+ }
39
+ });
40
+ });
41
+
42
+ beforeEach(() => {
43
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
44
+ });
45
+
46
+ afterEach(() => {
47
+ consoleLogSpy.mockRestore();
48
+ vi.clearAllMocks();
49
+ });
50
+
51
+ afterAll(async () => {
52
+ // Kill the weather SSE server
53
+ weatherProcess.kill('SIGINT');
54
+ });
55
+
56
+ it('should log events from specific servers to their handlers', async () => {
57
+ // Create individual log handlers for each server
58
+ const weatherLogHandler = vi.fn();
59
+ const stockLogHandler = vi.fn();
60
+
61
+ const config = new MCPConfiguration({
62
+ id: 'server-log-test',
63
+ servers: {
64
+ weather: {
65
+ url: new URL('http://localhost:60809/sse'),
66
+ log: weatherLogHandler,
67
+ },
68
+ stock: {
69
+ command: 'npx',
70
+ args: ['-y', 'tsx', path.join(__dirname, '__fixtures__/stock-price.ts')],
71
+ env: {
72
+ FAKE_CREDS: 'test',
73
+ },
74
+ log: stockLogHandler,
75
+ },
76
+ },
77
+ });
78
+
79
+ await config.getTools();
80
+
81
+ // Verify weather logs went to weather handler only
82
+ expect(weatherLogHandler).toHaveBeenCalled();
83
+
84
+ // Check logs contain server name
85
+ const weatherLogs = weatherLogHandler.mock.calls.map(call => call[0]);
86
+ weatherLogs.forEach(log => {
87
+ expect(log).toMatchObject({
88
+ serverName: 'weather',
89
+ timestamp: expect.any(Date),
90
+ });
91
+ });
92
+
93
+ // Verify stock logs went to stock handler only
94
+ expect(stockLogHandler).toHaveBeenCalled();
95
+
96
+ // Check logs contain server name
97
+ const stockLogs = stockLogHandler.mock.calls.map(call => call[0]);
98
+ stockLogs.forEach(log => {
99
+ expect(log).toMatchObject({
100
+ serverName: 'stock',
101
+ timestamp: expect.any(Date),
102
+ });
103
+ });
104
+
105
+ // Clean up
106
+ await config.disconnect();
107
+ });
108
+
109
+ it('should work with handlers that filter events by log level', async () => {
110
+ // Create a handler that only logs errors and critical/emergency messages
111
+ const errorCounter = { count: 0 };
112
+ const highSeverityHandler = vi.fn((logMessage: LogMessage) => {
113
+ if (['error', 'critical', 'alert', 'emergency'].includes(logMessage.level)) {
114
+ errorCounter.count++;
115
+ }
116
+ });
117
+
118
+ // Intentionally use a non-existent command to generate errors
119
+ const config = new MCPConfiguration({
120
+ id: 'error-log-test',
121
+ servers: {
122
+ badServer: {
123
+ command: 'nonexistent-command-that-will-fail',
124
+ args: [],
125
+ log: highSeverityHandler,
126
+ },
127
+ },
128
+ });
129
+
130
+ // This should fail, but our logger should capture the error
131
+ try {
132
+ await config.getTools();
133
+ } catch {
134
+ // Expected to fail
135
+ }
136
+
137
+ // Verify error logger was called
138
+ expect(highSeverityHandler).toHaveBeenCalled();
139
+
140
+ // Check we logged at least one error
141
+ expect(errorCounter.count).toBeGreaterThan(0);
142
+
143
+ // Clean up
144
+ await config.disconnect();
145
+ });
146
+
147
+ it('should support console logging patterns', async () => {
148
+ const _logLevels: LoggingLevel[] = [
149
+ 'debug',
150
+ 'info',
151
+ 'notice',
152
+ 'warning',
153
+ 'error',
154
+ 'critical',
155
+ 'alert',
156
+ 'emergency',
157
+ ];
158
+ const logMessages: string[] = [];
159
+
160
+ const consoleLogger = (logMessage: LogMessage) => {
161
+ const formatted = `${logMessage.level}: ${logMessage.message}`;
162
+ logMessages.push(formatted);
163
+ console.log(formatted);
164
+ };
165
+
166
+ const config = new MCPConfiguration({
167
+ id: 'console-log-test',
168
+ servers: {
169
+ echoServer: {
170
+ command: 'echo',
171
+ args: ['test'],
172
+ log: consoleLogger,
173
+ },
174
+ },
175
+ });
176
+
177
+ try {
178
+ await config.getTools();
179
+ } catch {
180
+ // May fail, but we just care about logging
181
+ }
182
+
183
+ // Verify console.log was called
184
+ expect(consoleLogSpy).toHaveBeenCalled();
185
+
186
+ // Clean up
187
+ await config.disconnect();
188
+ });
189
+ });