@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
JavaScript
#!/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