@mcpjam/inspector 0.3.4 → 0.3.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.
@@ -0,0 +1,130 @@
1
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
2
+ import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import { findActualExecutable } from "spawn-rx";
5
+ import { validateServerConfig, ConsoleLogger } from "./utils.js";
6
+ const SSE_HEADERS_PASSTHROUGH = ["authorization"];
7
+ const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
8
+ "authorization",
9
+ "mcp-session-id",
10
+ "last-event-id",
11
+ ];
12
+ export class TransportFactory {
13
+ logger;
14
+ defaultEnvironment;
15
+ constructor(options = {}) {
16
+ this.logger = options.logger || new ConsoleLogger();
17
+ this.defaultEnvironment = {
18
+ ...getDefaultEnvironment(),
19
+ ...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}),
20
+ };
21
+ }
22
+ async createTransport(config, requestHeaders) {
23
+ validateServerConfig(config);
24
+ this.logger.info(`Creating ${config.type} transport for ${config.name}`);
25
+ try {
26
+ switch (config.type) {
27
+ case "stdio":
28
+ return await this.createStdioTransport(config);
29
+ case "sse":
30
+ return await this.createSSETransport(config, requestHeaders);
31
+ case "streamable-http":
32
+ return await this.createStreamableHTTPTransport(config, requestHeaders);
33
+ default:
34
+ throw new Error(`Unsupported transport type: ${config.type}`);
35
+ }
36
+ }
37
+ catch (error) {
38
+ this.logger.error(`Failed to create transport for ${config.name}:`, error);
39
+ throw error;
40
+ }
41
+ }
42
+ async createStdioTransport(config) {
43
+ const command = config.command;
44
+ const origArgs = config.args || [];
45
+ const queryEnv = config.env || {};
46
+ // Filter out undefined values from process.env
47
+ const processEnv = Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined));
48
+ const env = { ...processEnv, ...this.defaultEnvironment, ...queryEnv };
49
+ const { cmd, args } = findActualExecutable(command, origArgs);
50
+ this.logger.info(`🚀 Stdio transport: command=${cmd}, args=${args}`);
51
+ const transport = new StdioClientTransport({
52
+ command: cmd,
53
+ args,
54
+ env,
55
+ stderr: "pipe",
56
+ });
57
+ await this.setupTransportLifecycle(transport, config.id);
58
+ await transport.start();
59
+ return transport;
60
+ }
61
+ async createSSETransport(config, requestHeaders) {
62
+ const url = config.url;
63
+ const headers = {
64
+ Accept: "text/event-stream",
65
+ ...config.headers,
66
+ };
67
+ // Add headers passed through from the request
68
+ if (requestHeaders) {
69
+ for (const key of SSE_HEADERS_PASSTHROUGH) {
70
+ if (requestHeaders[key] !== undefined) {
71
+ headers[key] = requestHeaders[key];
72
+ }
73
+ }
74
+ }
75
+ this.logger.info(`🚀 SSE transport: url=${url}`);
76
+ const transport = new SSEClientTransport(new URL(url), {
77
+ eventSourceInit: {
78
+ fetch: (url, init) => fetch(url, { ...init, headers }),
79
+ },
80
+ requestInit: {
81
+ headers,
82
+ },
83
+ });
84
+ await this.setupTransportLifecycle(transport, config.id);
85
+ await transport.start();
86
+ return transport;
87
+ }
88
+ async createStreamableHTTPTransport(config, requestHeaders) {
89
+ const url = config.url;
90
+ const headers = {
91
+ Accept: "text/event-stream, application/json",
92
+ ...config.headers,
93
+ };
94
+ // Add headers passed through from the request
95
+ if (requestHeaders) {
96
+ for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) {
97
+ if (requestHeaders[key] !== undefined) {
98
+ headers[key] = requestHeaders[key];
99
+ }
100
+ }
101
+ }
102
+ this.logger.info(`🚀 StreamableHTTP transport: url=${url}`);
103
+ const transport = new StreamableHTTPClientTransport(new URL(url), {
104
+ requestInit: {
105
+ headers,
106
+ },
107
+ });
108
+ await this.setupTransportLifecycle(transport, config.id);
109
+ await transport.start();
110
+ return transport;
111
+ }
112
+ async setupTransportLifecycle(transport, configId) {
113
+ // Set up event handlers without aggressive timeouts
114
+ // The original server didn't have connection timeouts, so we preserve that behavior
115
+ const originalOnClose = transport.onclose;
116
+ transport.onclose = () => {
117
+ this.logger.info(`Transport closed for ${configId}`);
118
+ if (originalOnClose) {
119
+ originalOnClose();
120
+ }
121
+ };
122
+ const originalOnError = transport.onerror;
123
+ transport.onerror = (error) => {
124
+ this.logger.error(`Transport error for ${configId}:`, error);
125
+ if (originalOnError) {
126
+ originalOnError(error);
127
+ }
128
+ };
129
+ }
130
+ }
@@ -0,0 +1,4 @@
1
+ // Export all shared components for easy importing
2
+ export { MCPProxyService } from "./MCPProxyService.js";
3
+ export { TransportFactory } from "./TransportFactory.js";
4
+ export { generateSessionId, validateServerConfig, ConsoleLogger, } from "./utils.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from "crypto";
2
+ export function generateSessionId() {
3
+ return randomUUID();
4
+ }
5
+ export function validateServerConfig(config) {
6
+ if (!config.id || !config.type || !config.name) {
7
+ throw new Error("Invalid server configuration: id, type, and name are required");
8
+ }
9
+ if (config.type === "stdio" && !config.command) {
10
+ throw new Error("STDIO transport requires command");
11
+ }
12
+ if ((config.type === "sse" || config.type === "streamable-http") &&
13
+ !config.url) {
14
+ throw new Error("SSE and StreamableHTTP transports require URL");
15
+ }
16
+ }
17
+ export class ConsoleLogger {
18
+ info(message, ...args) {
19
+ console.log(`[INFO] ${message}`, ...args);
20
+ }
21
+ error(message, ...args) {
22
+ console.error(`[ERROR] ${message}`, ...args);
23
+ }
24
+ warn(message, ...args) {
25
+ console.warn(`[WARN] ${message}`, ...args);
26
+ }
27
+ }
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ import { TestServer } from "./testing/TestServer.js";
3
+ import { MCPProxyService } from "./shared/MCPProxyService.js";
4
+ import { DatabaseManager } from "./database/DatabaseManager.js";
5
+ import { ConsoleLogger } from "./shared/utils.js";
6
+ import { getDatabaseConfig } from "./database/utils.js";
7
+ import { createServer } from "node:net";
8
+ import { parseArgs } from "node:util";
9
+ // Function to find an available port
10
+ const findAvailablePort = async (startPort) => {
11
+ return new Promise((resolve, reject) => {
12
+ const server = createServer();
13
+ server.listen(startPort, () => {
14
+ const port = server.address()?.port;
15
+ server.close(() => {
16
+ resolve(port);
17
+ });
18
+ });
19
+ server.on("error", (err) => {
20
+ if (err.code === "EADDRINUSE") {
21
+ // Port is in use, try the next one
22
+ findAvailablePort(startPort + 1)
23
+ .then(resolve)
24
+ .catch(reject);
25
+ }
26
+ else {
27
+ reject(err);
28
+ }
29
+ });
30
+ });
31
+ };
32
+ async function startTestServer() {
33
+ const logger = new ConsoleLogger();
34
+ // Parse command line arguments
35
+ const { values } = parseArgs({
36
+ args: process.argv.slice(2),
37
+ options: {
38
+ port: { type: "string", short: "p", default: "3002" },
39
+ host: { type: "string", short: "h", default: "localhost" },
40
+ env: { type: "string", short: "e", default: "development" },
41
+ config: { type: "string", short: "c" },
42
+ help: { type: "boolean", default: false },
43
+ },
44
+ allowPositionals: true,
45
+ });
46
+ if (values.help) {
47
+ console.log(`
48
+ 🧪 MCPJam Inspector Test Server
49
+
50
+ Usage: node test-server.js [options]
51
+
52
+ Options:
53
+ -p, --port <port> Port to run the test server on (default: 3002)
54
+ -h, --host <host> Host to bind the server to (default: localhost)
55
+ -e, --env <env> Environment mode (default: development)
56
+ -c, --config <file> Configuration file path
57
+ --help Show this help message
58
+
59
+ Environment Variables:
60
+ TEST_PORT Override the default port
61
+ TEST_HOST Override the default host
62
+ NODE_ENV Set the environment mode
63
+ DATABASE_URL Database connection string
64
+ LOG_LEVEL Logging level (debug, info, warn, error)
65
+
66
+ Examples:
67
+ node test-server.js --port 4000 --host 0.0.0.0
68
+ TEST_PORT=5000 node test-server.js
69
+ NODE_ENV=production node test-server.js
70
+ `);
71
+ process.exit(0);
72
+ }
73
+ try {
74
+ // Initialize core services
75
+ const mcpProxyService = new MCPProxyService({
76
+ logger,
77
+ maxConnections: 100, // Higher limit for test server
78
+ });
79
+ const dbConfig = getDatabaseConfig();
80
+ const database = new DatabaseManager(dbConfig);
81
+ await database.initialize();
82
+ // Determine port with fallback logic
83
+ const preferredPort = process.env.TEST_PORT
84
+ ? parseInt(process.env.TEST_PORT)
85
+ : parseInt(values.port);
86
+ const actualPort = await findAvailablePort(preferredPort);
87
+ // Create and start test server
88
+ const testServer = new TestServer({
89
+ port: actualPort,
90
+ host: process.env.TEST_HOST || values.host,
91
+ cors: values.env !== "production",
92
+ rateLimiting: true,
93
+ database: {
94
+ url: process.env.DATABASE_URL || "sqlite://test.db",
95
+ maxConnections: 10,
96
+ timeout: 5000,
97
+ },
98
+ logging: {
99
+ level: process.env.LOG_LEVEL || "info",
100
+ format: "json",
101
+ outputs: ["console"],
102
+ },
103
+ });
104
+ await testServer.start(mcpProxyService, database);
105
+ if (actualPort !== preferredPort) {
106
+ logger.info(`⚠️ Port ${preferredPort} was in use, using available port ${actualPort} instead`);
107
+ }
108
+ logger.info(`🧪 Test server started on http://${testServer.config.host}:${testServer.config.port}`);
109
+ logger.info(`📊 Health check available at http://${testServer.config.host}:${testServer.config.port}/api/test/health`);
110
+ logger.info(`🔍 Status endpoint available at http://${testServer.config.host}:${testServer.config.port}/api/test/status`);
111
+ logger.info(`🎯 Test execution endpoint available at http://${testServer.config.host}:${testServer.config.port}/api/test/run`);
112
+ // Graceful shutdown
113
+ const shutdown = async (signal) => {
114
+ logger.info(`🔄 Received ${signal}, shutting down test server...`);
115
+ try {
116
+ await testServer.stop();
117
+ await mcpProxyService.closeAllConnections();
118
+ await database.close();
119
+ logger.info("✅ Test server shutdown complete");
120
+ process.exit(0);
121
+ }
122
+ catch (error) {
123
+ logger.error("❌ Error during shutdown:", error);
124
+ process.exit(1);
125
+ }
126
+ };
127
+ process.on("SIGINT", () => shutdown("SIGINT"));
128
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
129
+ // Handle unhandled promise rejections
130
+ process.on("unhandledRejection", (reason, promise) => {
131
+ logger.error("❌ Unhandled Rejection at:", promise, "reason:", reason);
132
+ });
133
+ process.on("uncaughtException", (error) => {
134
+ logger.error("❌ Uncaught Exception:", error);
135
+ process.exit(1);
136
+ });
137
+ }
138
+ catch (error) {
139
+ logger.error("❌ Failed to start test server:", error);
140
+ process.exit(1);
141
+ }
142
+ }
143
+ if (import.meta.url === `file://${process.argv[1]}`) {
144
+ startTestServer();
145
+ }
@@ -0,0 +1,42 @@
1
+ export class HealthCheck {
2
+ mcpProxyService;
3
+ database;
4
+ logger;
5
+ constructor(mcpProxyService, database, logger) {
6
+ this.mcpProxyService = mcpProxyService;
7
+ this.database = database;
8
+ this.logger = logger;
9
+ }
10
+ getStatus() {
11
+ return {
12
+ testing: true,
13
+ status: "healthy",
14
+ version: process.env.npm_package_version || "0.3.5",
15
+ timestamp: new Date().toISOString(),
16
+ };
17
+ }
18
+ getDetailedStatus() {
19
+ return {
20
+ testing: true,
21
+ server: {
22
+ status: "running",
23
+ uptime: process.uptime(),
24
+ memory: process.memoryUsage(),
25
+ version: process.env.npm_package_version || "0.3.5",
26
+ },
27
+ connections: {
28
+ active: this.mcpProxyService.getActiveConnections().length,
29
+ list: this.mcpProxyService.getActiveConnections(),
30
+ },
31
+ database: {
32
+ connected: this.isDatabaseConnected(),
33
+ status: this.isDatabaseConnected() ? "connected" : "disconnected",
34
+ },
35
+ timestamp: new Date().toISOString(),
36
+ };
37
+ }
38
+ isDatabaseConnected() {
39
+ // Simple check - in a real implementation you might want to ping the database
40
+ return this.database !== null && this.database !== undefined;
41
+ }
42
+ }
@@ -0,0 +1,240 @@
1
+ // server/src/testing/TestExecutor.ts
2
+ import { randomUUID } from "node:crypto";
3
+ export class TestExecutor {
4
+ mcpProxyService;
5
+ logger;
6
+ constructor(mcpProxyService, logger) {
7
+ this.mcpProxyService = mcpProxyService;
8
+ this.logger = logger;
9
+ }
10
+ async executeTest(testCase) {
11
+ const startTime = Date.now();
12
+ const resultId = randomUUID();
13
+ this.logger.info(`🧪 Starting test execution: ${testCase.name} (${testCase.id})`);
14
+ try {
15
+ // Validate test case
16
+ this.validateTestCase(testCase);
17
+ // Initialize connections to MCP servers
18
+ const connections = await this.initializeConnections(testCase);
19
+ // Execute the test
20
+ const toolCalls = await this.executeTestLogic(testCase, connections);
21
+ // Clean up connections
22
+ await this.cleanupConnections(connections);
23
+ const duration = Date.now() - startTime;
24
+ const result = {
25
+ id: resultId,
26
+ testCase: testCase,
27
+ toolCalls: toolCalls,
28
+ duration: duration,
29
+ success: true,
30
+ timestamp: new Date(),
31
+ metadata: {
32
+ executorVersion: "1.0.0",
33
+ executionMode: "single",
34
+ },
35
+ };
36
+ this.logger.info(`✅ Test execution completed: ${testCase.name} (${duration}ms)`);
37
+ return result;
38
+ }
39
+ catch (error) {
40
+ const duration = Date.now() - startTime;
41
+ const errorMessage = error instanceof Error ? error.message : String(error);
42
+ this.logger.error(`❌ Test execution failed: ${testCase.name} - ${errorMessage}`);
43
+ const result = {
44
+ id: resultId,
45
+ testCase: testCase,
46
+ toolCalls: [],
47
+ duration: duration,
48
+ success: false,
49
+ error: errorMessage,
50
+ timestamp: new Date(),
51
+ metadata: {
52
+ executorVersion: "1.0.0",
53
+ executionMode: "single",
54
+ },
55
+ };
56
+ return result;
57
+ }
58
+ }
59
+ validateTestCase(testCase) {
60
+ if (!testCase.id) {
61
+ throw new Error("Test case must have an ID");
62
+ }
63
+ if (!testCase.name) {
64
+ throw new Error("Test case must have a name");
65
+ }
66
+ if (!testCase.prompt) {
67
+ throw new Error("Test case must have a prompt");
68
+ }
69
+ if (!testCase.serverConfigs || testCase.serverConfigs.length === 0) {
70
+ throw new Error("Test case must have at least one server configuration");
71
+ }
72
+ // Validate timeout
73
+ if (testCase.timeout && testCase.timeout < 1000) {
74
+ throw new Error("Test case timeout must be at least 1000ms");
75
+ }
76
+ }
77
+ async initializeConnections(testCase) {
78
+ const connections = new Map();
79
+ for (const serverConfig of testCase.serverConfigs) {
80
+ try {
81
+ this.logger.info(`🔗 Initializing connection to server: ${serverConfig.name}`);
82
+ // Use MCPProxyService to create connection
83
+ let sessionId;
84
+ if (serverConfig.type === "stdio") {
85
+ // For STDIO, we need to create a mock response object for SSE
86
+ const mockResponse = {
87
+ writeHead: () => { },
88
+ write: () => { },
89
+ end: () => { },
90
+ on: () => { },
91
+ setHeader: () => { },
92
+ };
93
+ const connection = await this.mcpProxyService.createSSEConnection(serverConfig, mockResponse, {});
94
+ sessionId = connection.sessionId;
95
+ }
96
+ else if (serverConfig.type === "streamable-http") {
97
+ const connection = await this.mcpProxyService.createStreamableHTTPConnection(serverConfig, {});
98
+ sessionId = connection.sessionId;
99
+ }
100
+ else {
101
+ throw new Error(`Unsupported server type: ${serverConfig.type}`);
102
+ }
103
+ connections.set(serverConfig.id, sessionId);
104
+ this.logger.info(`✅ Connected to server: ${serverConfig.name} (${sessionId})`);
105
+ }
106
+ catch (error) {
107
+ this.logger.error(`❌ Failed to connect to server: ${serverConfig.name} - ${error}`);
108
+ throw new Error(`Failed to connect to server: ${serverConfig.name}`);
109
+ }
110
+ }
111
+ return connections;
112
+ }
113
+ async executeTestLogic(testCase, connections) {
114
+ const toolCalls = [];
115
+ // Set timeout for the entire test execution
116
+ const timeout = testCase.timeout || 30000;
117
+ const timeoutPromise = new Promise((_, reject) => {
118
+ setTimeout(() => reject(new Error(`Test execution timed out after ${timeout}ms`)), timeout);
119
+ });
120
+ try {
121
+ // Execute the test logic with timeout
122
+ const result = await Promise.race([
123
+ this.runTestWithConnections(testCase, connections),
124
+ timeoutPromise,
125
+ ]);
126
+ return result;
127
+ }
128
+ catch (error) {
129
+ if (error instanceof Error && error.message.includes("timed out")) {
130
+ this.logger.error(`⏱️ Test execution timed out: ${testCase.name}`);
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+ async runTestWithConnections(testCase, connections) {
136
+ const toolCalls = [];
137
+ // For now, implement a simple test execution that simulates tool calls
138
+ // In a real implementation, this would involve:
139
+ // 1. Sending the prompt to an LLM
140
+ // 2. Processing tool calls returned by the LLM
141
+ // 3. Executing those tool calls against the connected MCP servers
142
+ // 4. Recording the results
143
+ this.logger.info(`🤖 Processing prompt: ${testCase.prompt.substring(0, 100)}...`);
144
+ // Simulate tool calls based on expected tools
145
+ if (testCase.expectedTools && testCase.expectedTools.length > 0) {
146
+ for (const expectedTool of testCase.expectedTools) {
147
+ for (const [serverId, _sessionId] of connections.entries()) {
148
+ const serverConfig = testCase.serverConfigs.find((s) => s.id === serverId);
149
+ if (!serverConfig)
150
+ continue;
151
+ const toolCallStartTime = Date.now();
152
+ try {
153
+ // Simulate tool call execution
154
+ await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 400));
155
+ const toolCall = {
156
+ toolName: expectedTool,
157
+ serverId: serverId,
158
+ serverName: serverConfig.name,
159
+ parameters: {
160
+ prompt: testCase.prompt,
161
+ timestamp: new Date().toISOString(),
162
+ },
163
+ response: {
164
+ success: true,
165
+ data: `Mock response for ${expectedTool}`,
166
+ timestamp: new Date().toISOString(),
167
+ },
168
+ executionTimeMs: Date.now() - toolCallStartTime,
169
+ success: true,
170
+ timestamp: new Date(),
171
+ };
172
+ toolCalls.push(toolCall);
173
+ this.logger.info(`🔧 Tool call executed: ${expectedTool} on ${serverConfig.name}`);
174
+ }
175
+ catch (error) {
176
+ const errorMessage = error instanceof Error ? error.message : String(error);
177
+ const toolCall = {
178
+ toolName: expectedTool,
179
+ serverId: serverId,
180
+ serverName: serverConfig.name,
181
+ parameters: {
182
+ prompt: testCase.prompt,
183
+ timestamp: new Date().toISOString(),
184
+ },
185
+ response: null,
186
+ executionTimeMs: Date.now() - toolCallStartTime,
187
+ success: false,
188
+ error: errorMessage,
189
+ timestamp: new Date(),
190
+ };
191
+ toolCalls.push(toolCall);
192
+ this.logger.error(`❌ Tool call failed: ${expectedTool} on ${serverConfig.name} - ${errorMessage}`);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ else {
198
+ // If no expected tools, simulate a basic interaction
199
+ const serverId = connections.keys().next().value;
200
+ if (serverId) {
201
+ const _sessionId = connections.get(serverId);
202
+ const serverConfig = testCase.serverConfigs.find((s) => s.id === serverId);
203
+ if (serverConfig) {
204
+ const toolCallStartTime = Date.now();
205
+ const toolCall = {
206
+ toolName: "default_interaction",
207
+ serverId: serverId,
208
+ serverName: serverConfig.name,
209
+ parameters: {
210
+ prompt: testCase.prompt,
211
+ timestamp: new Date().toISOString(),
212
+ },
213
+ response: {
214
+ success: true,
215
+ data: "Mock response for default interaction",
216
+ timestamp: new Date().toISOString(),
217
+ },
218
+ executionTimeMs: Date.now() - toolCallStartTime,
219
+ success: true,
220
+ timestamp: new Date(),
221
+ };
222
+ toolCalls.push(toolCall);
223
+ }
224
+ }
225
+ }
226
+ return toolCalls;
227
+ }
228
+ async cleanupConnections(connections) {
229
+ for (const [serverId, sessionId] of connections.entries()) {
230
+ try {
231
+ // Note: MCPProxyService doesn't have a direct cleanup method for individual sessions
232
+ // In a real implementation, you might want to add this functionality
233
+ this.logger.info(`🧹 Cleaning up connection: ${serverId} (${sessionId})`);
234
+ }
235
+ catch (error) {
236
+ this.logger.error(`❌ Failed to cleanup connection: ${serverId} - ${error}`);
237
+ }
238
+ }
239
+ }
240
+ }