UNPKG

@leeroy/mcp-superassistant-proxy

Version:

A bulletproof proxy server for MCP (Model Context Protocol) that aggregates multiple MCP servers with comprehensive memory leak prevention and reliable HTTP/SSE transport

1,152 lines 75.5 kB
#!/usr/bin/env node /** * MCP SuperAssistant Proxy with Enhanced Connection Stability * * This proxy aggregates multiple MCP servers and provides automatic backwards compatibility: * * Server-side backwards compatibility: * - Provides modern Streamable HTTP transport (protocol version 2025-03-26) as primary * - Provides legacy HTTP+SSE transport (protocol version 2024-11-05) as fallback * * Client-side backwards compatibility: * - When connecting to remote HTTP servers, tries Streamable HTTP first * - Automatically falls back to SSE transport if modern transport fails * * Enhanced connection stability: * - Server-side heartbeats for SSE connections to prevent TCP drops * - Keep-alive mechanisms for StreamableHTTP connections * - Comprehensive logging with configurable levels * - Improved session management and cleanup * * Following the MCP specification for backwards compatibility. */ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { readFile } from "fs/promises"; import { randomUUID } from "node:crypto"; import express from "express"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; // Enhanced logger for better debugging const getLogger = (logLevel) => { const prefix = '[mcp-superassistant-proxy]'; return { info: (...args) => logLevel !== 'none' && console.log(prefix, ...args), debug: (...args) => (logLevel === 'debug') && console.debug(prefix, '[DEBUG]', ...args), error: (...args) => logLevel !== 'none' && console.error(prefix, '[ERROR]', ...args), warn: (...args) => logLevel !== 'none' && console.warn(prefix, '[WARN]', ...args), }; }; let logger = getLogger('info'); // Default, will be set by argv // Heartbeat configuration const DEFAULT_HEARTBEAT_INTERVAL = 30000; // 30 seconds const DEFAULT_SESSION_TIMEOUT = 300000; // 5 minutes class MCPSuperAssistantProxy { connectedServers = new Map(); server; app; config; options; transports = { streamable: new Map(), sse: new Map() }; httpServer; // Track all intervals and timeouts for cleanup intervals = new Set(); timeouts = new Set(); // Cleanup state tracking isShuttingDown = false; constructor(options) { this.config = options.config; this.options = options; // Initialize logger with the provided log level logger = getLogger(options.logLevel); this.server = new Server({ name: "MCP SuperAssistant Proxy", version: "1.0.0" }, { capabilities: { tools: {}, resources: {}, prompts: {}, } }); this.app = express(); this.app.use(express.json()); // Add global CORS middleware this.app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Cache-Control'); res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id, Content-Type'); // Handle preflight requests if (req.method === 'OPTIONS') { res.status(200).end(); return; } next(); }); this.setupRoutes(); this.setupServerHandlers(); // Start periodic cleanup of stale sessions (every 2 minutes) this.startSessionCleanup(); } /** * Create a tracked interval that will be cleaned up automatically */ createInterval(callback, delay) { const intervalId = setInterval(callback, delay); this.intervals.add(intervalId); return intervalId; } /** * Create a tracked timeout that will be cleaned up automatically */ createTimeout(callback, delay) { const timeoutId = setTimeout(() => { this.timeouts.delete(timeoutId); callback(); }, delay); this.timeouts.add(timeoutId); return timeoutId; } /** * Clear a tracked interval */ clearTrackedInterval(intervalId) { clearInterval(intervalId); this.intervals.delete(intervalId); } /** * Clear a tracked timeout */ clearTrackedTimeout(timeoutId) { clearTimeout(timeoutId); this.timeouts.delete(timeoutId); } /** * Start session cleanup intervals */ startSessionCleanup() { console.log('[MCPSuperAssistantProxy] Starting session cleanup intervals'); // Cleanup stale streamable sessions every 2 minutes this.createInterval(() => { this.cleanupStaleStreamableSessions(); }, 2 * 60 * 1000); // Cleanup stale SSE sessions every 2 minutes this.createInterval(() => { this.cleanupStaleSSESessions(); }, 2 * 60 * 1000); // Cleanup disconnected servers every 5 minutes this.createInterval(() => { this.cleanupDisconnectedServers(); }, 5 * 60 * 1000); } async safeAsyncOperation(operation, timeoutMs = 5000, description = 'operation') { try { return await Promise.race([ operation(), new Promise((_, reject) => setTimeout(() => reject(new Error(`${description} timed out after ${timeoutMs}ms`)), timeoutMs)) ]); } catch (error) { if (this.options.logLevel === 'debug') { const errorMsg = this.formatNetworkError(error); console.log(`Safe async operation failed (${description}): ${errorMsg}`); } return null; } } setupRoutes() { // Health endpoints (must be set up before other routes) for (const endpoint of this.options.healthEndpoints) { this.app.get(endpoint, (req, res) => { res.setHeader('Content-Type', 'text/plain'); res.status(200).send('ok'); }); if (this.options.logLevel === 'debug') { console.log(`Health endpoint configured: ${endpoint}`); } } // Unified MCP endpoint with proper session management this.app.post('/mcp', async (req, res) => { try { // Check for existing session ID const sessionId = req.headers['mcp-session-id']; let transport; if (this.options.logLevel === 'debug') { console.log(`POST /mcp request from ${req.ip}, sessionId: ${sessionId || 'none'}, active sessions: ${Object.keys(this.transports.streamable).length}`); } if (sessionId && this.transports.streamable.has(sessionId)) { // Reuse existing transport transport = this.transports.streamable.get(sessionId).transport; if (this.options.logLevel === 'debug') { console.log(`Reusing existing session: ${sessionId}`); } } else if (!sessionId && isInitializeRequest(req.body)) { // New initialization request - create a new server instance for this session if (this.options.logLevel === 'debug') { console.log(`Creating new session for initialize request from ${req.ip}`); } const sessionServer = new Server({ name: "MCP SuperAssistant Proxy", version: "1.0.0" }, { capabilities: { tools: {}, resources: {}, prompts: {}, } }); // Copy all handlers from main server to session server this.copyServerHandlers(sessionServer); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { // Store the transport by session ID with server and metadata this.transports.streamable.set(newSessionId, { transport, server: sessionServer, createdAt: Date.now() }); // Start heartbeat for this session this.startStreamableHeartbeat(newSessionId); if (this.options.logLevel === 'debug') { console.log(`New Streamable HTTP session initialized: ${newSessionId}, total sessions: ${this.transports.streamable.size}`); } } }); // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { if (this.options.logLevel === 'debug') { console.log(`Streamable HTTP session closed: ${transport.sessionId}, remaining sessions: ${this.transports.streamable.size - 1}`); } // Stop heartbeat before deleting session this.stopStreamableHeartbeat(transport.sessionId); this.transports.streamable.delete(transport.sessionId); } }; // Connect the session server to the transport (not the main server) await sessionServer.connect(transport); } else { // Invalid request if (this.options.logLevel === 'debug') { console.log(`Invalid request from ${req.ip}: sessionId=${sessionId}, isInitialize=${isInitializeRequest(req.body)}`); } res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided or not an initialize request', }, id: null, }); return; } // Handle the request await transport.handleRequest(req, res, req.body); } catch (error) { console.error(`Error handling MCP request from ${req.ip}:`, error); res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error', }, id: null, }); } }); // Handle GET requests for server-to-client notifications via SSE this.app.get('/mcp', async (req, res) => { try { const sessionId = req.headers['mcp-session-id']; if (!sessionId || !this.transports.streamable.has(sessionId)) { res.status(400).send('Invalid or missing session ID'); return; } const transport = this.transports.streamable.get(sessionId); await transport.transport.handleRequest(req, res); } catch (error) { console.error('Error handling GET request:', error); res.status(500).send('Internal server error'); } }); // Handle DELETE requests for session termination this.app.delete('/mcp', async (req, res) => { try { const sessionId = req.headers['mcp-session-id']; if (!sessionId || !this.transports.streamable.has(sessionId)) { res.status(400).send('Invalid or missing session ID'); return; } const transportData = this.transports.streamable.get(sessionId); await transportData.transport.handleRequest(req, res, req.body); // Clean up the session this.transports.streamable.delete(sessionId); } catch (error) { console.error('Error handling DELETE request:', error); res.status(500).send('Internal server error'); } }); // Backward compatibility: SSE endpoint for legacy clients this.app.get('/sse', async (req, res) => { let sessionId; try { if (this.options.logLevel === 'debug') { console.log('New SSE connection from', req.ip); } // Set headers for SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable buffering in Nginx // Create SSE transport with correct message endpoint const protocol = req.get('X-Forwarded-Proto') || (req.secure ? 'https' : 'http'); const host = req.get('Host') || `localhost:${req.socket.localPort || 3006}`; const baseUrl = `${protocol}://${host}`; const messageEndpoint = `${baseUrl}/messages`; const sseTransport = new SSEServerTransport(messageEndpoint, res); // Create a new server instance for this SSE connection const sseServer = new Server({ name: "MCP SuperAssistant Proxy SSE", version: "1.0.0" }, { capabilities: { tools: {}, resources: {}, prompts: {}, } }); // Copy all handlers from main server to SSE server this.copyServerHandlers(sseServer); await sseServer.connect(sseTransport); sessionId = sseTransport.sessionId; if (sessionId) { // Store session data this.transports.sse.set(sessionId, { transport: sseTransport, server: sseServer, response: res }); // Start heartbeat for this SSE session this.startSSEHeartbeat(sessionId); if (this.options.logLevel === 'debug') { console.log(`SSE session created: ${sessionId}`); } } // Set up cleanup flag to prevent recursive cleanup calls let isCleaningUp = false; const safeCleanup = () => { if (isCleaningUp) return; isCleaningUp = true; this.cleanupSSESession(sessionId); }; // Handle transport events sseTransport.onclose = () => { if (this.options.logLevel === 'debug') { console.log(`SSE transport closed (session ${sessionId})`); } safeCleanup(); }; sseTransport.onerror = (err) => { if (this.options.logLevel === 'debug') { const errorMsg = this.formatNetworkError(err); console.log(`SSE transport error (session ${sessionId}): ${errorMsg}`); } safeCleanup(); }; // Handle client disconnection req.on('close', () => { if (this.options.logLevel === 'debug') { console.log(`SSE client disconnected (session ${sessionId})`); } safeCleanup(); }); req.on('error', (err) => { // Only log actual errors in debug mode, not normal disconnections if (this.options.logLevel === 'debug' && err.code !== 'ECONNRESET' && err.code !== 'EPIPE') { const errorMsg = this.formatNetworkError(err); console.log(`SSE client error (session ${sessionId}): ${errorMsg}`); } safeCleanup(); }); } catch (error) { console.error('Error setting up SSE connection:', error); // Clean up any partially created session if (sessionId) { this.cleanupSSESession(sessionId); } if (!res.headersSent) { res.status(500).send('Internal server error'); } } }); // Handle POST requests to SSE endpoint (for clients trying StreamableHTTP on SSE endpoint) this.app.post('/sse', async (req, res) => { res.status(400).json({ error: 'SSE endpoint only supports GET requests. Use POST requests to /mcp for StreamableHTTP transport.', supportedMethods: ['GET'], suggestedEndpoint: '/mcp' }); }); // Backward compatibility: Messages endpoint for SSE transport this.app.post('/messages', async (req, res) => { try { const sessionId = req.query.sessionId; if (!sessionId) { res.status(400).send('Missing sessionId parameter'); return; } const session = this.transports.sse.get(sessionId); if (session?.transport?.handlePostMessage) { if (this.options.logLevel === 'debug') { console.log(`POST to SSE transport (session ${sessionId})`); } try { await session.transport.handlePostMessage(req, res, req.body); } catch (err) { const errorMsg = this.formatNetworkError(err); if (this.options.logLevel === 'debug') { console.log(`Error handling POST message (session ${sessionId}): ${errorMsg}`); } // If not already sent, send a 500 response if (!res.headersSent) { res.status(500).send('Error processing message'); } } } else { res.status(503).send(`No active SSE connection for session ${sessionId}`); } } catch (error) { console.error('Error handling SSE message:', error); res.status(500).send('Internal server error'); } }); } setupServerHandlers() { // Set up tool handlers this.server.setRequestHandler(ListToolsRequestSchema, async () => { const allTools = []; for (const [serverName, server] of this.connectedServers) { for (const tool of server.tools) { allTools.push({ name: `${serverName}.${tool.name}`, description: `[${serverName}] ${tool.description || tool.name}`, inputSchema: tool.inputSchema }); } } // Add management tools allTools.push({ name: "list_servers", description: "List all connected MCP servers and their capabilities", inputSchema: { type: "object", properties: {}, additionalProperties: false } }); allTools.push({ name: "get_server_info", description: "Get detailed information about a specific server", inputSchema: { type: "object", properties: { serverName: { type: "string", description: "Name of the server to get info for" } }, required: ["serverName"], additionalProperties: false } }); return { tools: allTools }; }); // Set up tool call handler this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "list_servers") { const serverInfo = Array.from(this.connectedServers.entries()).map(([name, server]) => ({ name, type: server.config.type, tools: server.tools.length, resources: server.resources.length, prompts: server.prompts.length, toolNames: server.tools.map(t => `${name}.${t.name}`), })); return { content: [{ type: "text", text: JSON.stringify(serverInfo, null, 2) }] }; } if (name === "get_server_info") { const serverName = args?.serverName; if (typeof serverName !== 'string') { return { content: [{ type: "text", text: `Invalid serverName parameter. Expected string, got ${typeof serverName}` }], isError: true }; } const server = this.connectedServers.get(serverName); if (!server) { return { content: [{ type: "text", text: `Server '${serverName}' not found` }], isError: true }; } return { content: [{ type: "text", text: JSON.stringify({ name: server.name, type: server.config.type, tools: server.tools, resources: server.resources, prompts: server.prompts, }, null, 2) }] }; } // Handle delegated tool calls const [serverName, toolName] = name.split('.', 2); if (!serverName || !toolName) { return { content: [{ type: "text", text: `Invalid tool name format. Use server_name.tool_name` }], isError: true }; } const server = this.connectedServers.get(serverName); if (!server) { return { content: [{ type: "text", text: `Server '${serverName}' not found` }], isError: true }; } try { const result = await server.client.callTool({ name: toolName, arguments: args || {}, }); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Failed to execute ${name}: ${errorMessage}` }], isError: true }; } }); // Set up resource handlers this.server.setRequestHandler(ListResourcesRequestSchema, async () => { const allResources = []; for (const [serverName, server] of this.connectedServers) { for (const resource of server.resources) { allResources.push({ uri: `${serverName}://${resource.uri}`, name: `[${serverName}] ${resource.name || resource.uri}`, description: resource.description, mimeType: resource.mimeType }); } } return { resources: allResources }; }); this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; const [serverName, ...uriParts] = uri.split('://'); const originalUri = uriParts.join('://'); const server = this.connectedServers.get(serverName); if (!server) { throw new Error(`Server '${serverName}' not found`); } try { const result = await server.client.readResource({ uri: originalUri, }); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read resource ${uri}: ${errorMessage}`); } }); // Set up prompt handlers this.server.setRequestHandler(ListPromptsRequestSchema, async () => { const allPrompts = []; for (const [serverName, server] of this.connectedServers) { for (const prompt of server.prompts) { allPrompts.push({ name: `${serverName}.${prompt.name}`, description: `[${serverName}] ${prompt.description || prompt.name}`, arguments: prompt.arguments || [] }); } } return { prompts: allPrompts }; }); this.server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; const [serverName, promptName] = name.split('.', 2); if (!serverName || !promptName) { throw new Error(`Invalid prompt name format. Use server_name.prompt_name`); } const server = this.connectedServers.get(serverName); if (!server) { throw new Error(`Server '${serverName}' not found`); } try { const result = await server.client.getPrompt({ name: promptName, arguments: args || {}, }); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get prompt ${name}: ${errorMessage}`); } }); } cleanupSSESession(sessionId) { if (!sessionId || !this.transports.sse.has(sessionId)) { return; } const session = this.transports.sse.get(sessionId); if (this.options.logLevel === 'debug') { console.log(`Cleaning up SSE session: ${sessionId}`); } // Stop heartbeat first this.stopSSEHeartbeat(sessionId); // Remove from tracking first to prevent re-entry this.transports.sse.delete(sessionId); // Close the transport if possible try { if (session.transport && typeof session.transport.close === 'function') { session.transport.close(); } } catch (error) { // Silently handle transport close errors to prevent stack overflow if (this.options.logLevel === 'debug') { const errorMsg = this.formatNetworkError(error); console.log(`Error closing SSE transport for session ${sessionId}: ${errorMsg}`); } } // Close the server connection if possible try { if (session.server && typeof session.server.close === 'function') { session.server.close(); } } catch (error) { // Silently handle server close errors to prevent stack overflow if (this.options.logLevel === 'debug') { const errorMsg = this.formatNetworkError(error); console.log(`Error closing SSE server for session ${sessionId}: ${errorMsg}`); } } } cleanupStaleStreamableSessions() { if (this.isShuttingDown) { return; // Skip cleanup during shutdown } const now = Date.now(); const staleThreshold = this.options.sessionTimeout; let cleanedCount = 0; for (const [sessionId, sessionData] of this.transports.streamable.entries()) { const age = now - sessionData.createdAt; if (age > staleThreshold) { console.log(`[MCPSuperAssistantProxy] Cleaning up stale streamable session: ${sessionId} (age: ${Math.round(age / 1000)}s)`); // Clear heartbeat timer if it exists if (sessionData.heartbeatTimer) { this.clearTrackedTimeout(sessionData.heartbeatTimer); } try { if (sessionData.transport && typeof sessionData.transport.close === 'function') { sessionData.transport.close(); } } catch (error) { console.error(`Error closing stale session ${sessionId}:`, error); } this.transports.streamable.delete(sessionId); cleanedCount++; } } if (cleanedCount > 0) { console.log(`[MCPSuperAssistantProxy] Cleaned up ${cleanedCount} stale streamable sessions`); } } /** * Clean up stale SSE sessions */ cleanupStaleSSESessions() { if (this.isShuttingDown) { return; // Skip cleanup during shutdown } let cleanedCount = 0; for (const [sessionId, sessionData] of this.transports.sse.entries()) { // Check if response is still writable if (sessionData.response.destroyed || sessionData.response.writableEnded) { console.log(`[MCPSuperAssistantProxy] Cleaning up disconnected SSE session: ${sessionId}`); // Clear heartbeat timer if it exists if (sessionData.heartbeatTimer) { this.clearTrackedTimeout(sessionData.heartbeatTimer); } try { if (sessionData.transport && typeof sessionData.transport.close === 'function') { sessionData.transport.close(); } } catch (error) { console.error(`Error closing stale SSE session ${sessionId}:`, error); } this.transports.sse.delete(sessionId); cleanedCount++; } } if (cleanedCount > 0) { console.log(`[MCPSuperAssistantProxy] Cleaned up ${cleanedCount} stale SSE sessions`); } } /** * Clean up disconnected servers */ cleanupDisconnectedServers() { if (this.isShuttingDown) { return; // Skip cleanup during shutdown } let cleanedCount = 0; for (const [serverId, server] of this.connectedServers.entries()) { try { // Check if the child process is still running if (server.childProcess) { if (server.childProcess.killed || server.childProcess.exitCode !== null) { console.log(`[MCPSuperAssistantProxy] Cleaning up disconnected server: ${serverId}`); this.connectedServers.delete(serverId); cleanedCount++; } } // For servers without child processes, we could check the client connection // but that might be too aggressive, so we'll leave them for now } catch (error) { console.warn(`[MCPSuperAssistantProxy] Error checking server ${serverId}:`, error); // If we can't check the server, assume it's disconnected this.connectedServers.delete(serverId); cleanedCount++; } } if (cleanedCount > 0) { console.log(`[MCPSuperAssistantProxy] Cleaned up ${cleanedCount} disconnected servers`); } } /** * Start heartbeat for SSE session to prevent TCP connection drops */ startSSEHeartbeat(sessionId) { const session = this.transports.sse.get(sessionId); if (!session) { return; } // Clear any existing heartbeat if (session.heartbeatTimer) { clearInterval(session.heartbeatTimer); } logger.debug(`Starting SSE heartbeat for session: ${sessionId}`); session.heartbeatTimer = this.createInterval(() => { try { if (session.response && !session.response.destroyed) { // Send SSE comment as heartbeat (comments are ignored by clients) session.response.write(': heartbeat\n\n'); logger.debug(`SSE heartbeat sent for session: ${sessionId}`); } else { // Response is destroyed, clean up the session logger.debug(`SSE response destroyed for session: ${sessionId}, cleaning up`); this.stopSSEHeartbeat(sessionId); this.cleanupSSESession(sessionId); } } catch (error) { logger.error(`Error sending SSE heartbeat for session ${sessionId}:`, error); this.stopSSEHeartbeat(sessionId); this.cleanupSSESession(sessionId); } }, this.options.heartbeatInterval); } /** * Stop heartbeat for SSE session */ stopSSEHeartbeat(sessionId) { const session = this.transports.sse.get(sessionId); if (session?.heartbeatTimer) { this.clearTrackedInterval(session.heartbeatTimer); session.heartbeatTimer = undefined; logger.debug(`Stopped SSE heartbeat for session: ${sessionId}`); } } /** * Start heartbeat for Streamable HTTP session */ startStreamableHeartbeat(sessionId) { const session = this.transports.streamable.get(sessionId); if (!session) { return; } // Clear any existing heartbeat if (session.heartbeatTimer) { clearInterval(session.heartbeatTimer); } logger.debug(`Starting Streamable HTTP heartbeat for session: ${sessionId}`); session.heartbeatTimer = this.createInterval(() => { try { // For Streamable HTTP, we keep the connection alive by updating the session timestamp // The transport itself handles keep-alive at the HTTP level session.createdAt = Date.now(); // Update timestamp to prevent stale cleanup logger.debug(`Streamable HTTP heartbeat updated for session: ${sessionId}`); } catch (error) { logger.error(`Error sending Streamable HTTP heartbeat for session ${sessionId}:`, error); this.stopStreamableHeartbeat(sessionId); } }, this.options.heartbeatInterval); } /** * Stop heartbeat for Streamable HTTP session */ stopStreamableHeartbeat(sessionId) { const session = this.transports.streamable.get(sessionId); if (session?.heartbeatTimer) { this.clearTrackedInterval(session.heartbeatTimer); session.heartbeatTimer = undefined; logger.debug(`Stopped Streamable HTTP heartbeat for session: ${sessionId}`); } } copyServerHandlers(sseServer) { // Copy all the request handlers from the main server to the SSE server // This ensures SSE clients get the same functionality // Set up tool handlers sseServer.setRequestHandler(ListToolsRequestSchema, async () => { const allTools = []; for (const [serverName, server] of this.connectedServers) { for (const tool of server.tools) { allTools.push({ name: `${serverName}.${tool.name}`, description: `[${serverName}] ${tool.description || tool.name}`, inputSchema: tool.inputSchema }); } } // Add management tools allTools.push({ name: "list_servers", description: "List all connected MCP servers and their capabilities", inputSchema: { type: "object", properties: {}, additionalProperties: false } }); allTools.push({ name: "get_server_info", description: "Get detailed information about a specific server", inputSchema: { type: "object", properties: { serverName: { type: "string", description: "Name of the server to get info for" } }, required: ["serverName"], additionalProperties: false } }); return { tools: allTools }; }); // Set up tool call handler sseServer.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "list_servers") { const serverInfo = Array.from(this.connectedServers.entries()).map(([name, server]) => ({ name, type: server.config.type, tools: server.tools.length, resources: server.resources.length, prompts: server.prompts.length, toolNames: server.tools.map(t => `${name}.${t.name}`), })); return { content: [{ type: "text", text: JSON.stringify(serverInfo, null, 2) }] }; } if (name === "get_server_info") { const serverName = args?.serverName; if (typeof serverName !== 'string') { return { content: [{ type: "text", text: `Invalid serverName parameter. Expected string, got ${typeof serverName}` }], isError: true }; } const server = this.connectedServers.get(serverName); if (!server) { return { content: [{ type: "text", text: `Server '${serverName}' not found` }], isError: true }; } return { content: [{ type: "text", text: JSON.stringify({ name: server.name, type: server.config.type, tools: server.tools, resources: server.resources, prompts: server.prompts, }, null, 2) }] }; } // Handle delegated tool calls const [serverName, toolName] = name.split('.', 2); if (!serverName || !toolName) { return { content: [{ type: "text", text: `Invalid tool name format. Use server_name.tool_name` }], isError: true }; } const server = this.connectedServers.get(serverName); if (!server) { return { content: [{ type: "text", text: `Server '${serverName}' not found` }], isError: true }; } try { const result = await server.client.callTool({ name: toolName, arguments: args || {}, }); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `Failed to execute ${name}: ${errorMessage}` }], isError: true }; } }); // Set up resource handlers sseServer.setRequestHandler(ListResourcesRequestSchema, async () => { const allResources = []; for (const [serverName, server] of this.connectedServers) { for (const resource of server.resources) { allResources.push({ uri: `${serverName}://${resource.uri}`, name: `[${serverName}] ${resource.name || resource.uri}`, description: resource.description, mimeType: resource.mimeType }); } } return { resources: allResources }; }); sseServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; const [serverName, ...uriParts] = uri.split('://'); const originalUri = uriParts.join('://'); const server = this.connectedServers.get(serverName); if (!server) { throw new Error(`Server '${serverName}' not found`); } try { const result = await server.client.readResource({ uri: originalUri, }); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read resource ${uri}: ${errorMessage}`); } }); // Set up prompt handlers sseServer.setRequestHandler(ListPromptsRequestSchema, async () => { const allPrompts = []; for (const [serverName, server] of this.connectedServers) { for (const prompt of server.prompts) { allPrompts.push({ name: `${serverName}.${prompt.name}`, description: `[${serverName}] ${prompt.description || prompt.name}`, arguments: prompt.arguments || [] }); } } return { prompts: allPrompts }; }); sseServer.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; const [serverName, promptName] = name.split('.', 2); if (!serverName || !promptName) { throw new Error(`Invalid prompt name format. Use server_name.prompt_name`); } const server = this.connectedServers.get(serverName); if (!server) { throw new Error(`Server '${serverName}' not found`); } try { const result = await server.client.getPrompt({ name: promptName, arguments: args || {}, }); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to get prompt ${name}: ${errorMessage}`); } }); } formatNetworkError(error) { if (!error) { return 'Unknown error'; } // Handle common network error codes if (error.code) { switch (error.code) { case 'ECONNRESET': return 'Connection reset by peer (client disconnected)'; case 'ECONNABORTED': return 'Connection aborted'; case 'ENOTFOUND': return 'Host not found'; case 'ECONNREFUSED': return 'Connection refused'; case 'ETIMEDOUT': return 'Connection timed out'; case 'EPIPE': return 'Broken pipe (client disconnected)'; default: return `Network error: ${error.code}`; } } // Handle error messages if (error.message) { return error.message; } // Fallback to string representation return String(error); } /** * Connect to an MCP server with backwards compatibility * Following the spec for client backward compatibility */ async connectWithBackwardsCompatibility(url, client) { console.log('1. Trying Streamable HTTP transport first...'); // Set up error handler client.onerror = (error) => { const errorMsg = this.formatNetworkError(error); if (this.options.logLevel === 'debug') { console.log(`Client connection error: ${errorMsg}`); } }; const baseUrl = new URL(url); const connectionTimeout = this.options.timeout || 30000; let streamableTransport = null; try { // Create modern transport with timeout streamableTransport = new StreamableHTTPClientTransport(baseUrl); await Promise.race([ client.connect(streamableTransport), new Promise((_, reject) => setTimeout(() => reject(new Error(`Streamable HTTP connection timeout after ${connectionTimeout}ms`)), connectionTimeout)) ]); console.log('Successfully connected using modern Streamable HTTP transport.'); return { transport: streamableTransport, transportType: 'streamable-http' }; } catch (error) { // Clean up failed transport if (streamableTransport) { try { if (typeof streamableTransport.close === 'function') { streamableTransport.close(); } } catch (cleanupError) { // Ignore cleanup errors } } // Step 2: If transport fails, try the older SSE transport const errorMsg = this.formatNetworkError(error); console.log(`Streamable HTTP transport connection failed: ${errorMsg}`); console.log('2. Falling back to deprecated HTTP+SSE transport...'); let sseTransport = null; try { // Create SSE transport pointing to /sse endpoint const sseUrl = new URL(baseUrl); sseUrl.pathname = '/sse'; sseTransport = new SSEClientTransport(sseUrl); await Promise.race([ client.connect(sseTransport), new Promise((_, reject) => setTimeout(() => rej