/** * Transport layer — connect to upstream MCP servers via stdio, Streamable HTTP, or SSE */ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { CustomStdioClientTransport } from '../customStdio.js'; import type { StdioServerEntry, StreamableServerEntry, SseServerEntry } from '../types.js'; import { logError, logInfo, logWarn } from '../logger.js'; import { ResilientTransportWrapper } from '../resilient.js'; // Re-export sub-modules export { buildBaseEnv } from './env.js'; export { buildRequestHeaders } from './headers.js'; export type { ConnectedClient } from './types.js'; const DEFAULT_STDIO_CONNECTION_TIMEOUT_MS = 60_000; // HTTP timeout reduced to 20s to avoid race with qimingcode's 30s session/new timeout // When a server fails (e.g., auth error), ResilientTransportWrapper retries indefinitely, // but this timeout ensures we fail fast and let other servers load. const DEFAULT_HTTP_CONNECTION_TIMEOUT_MS = 20_000; /** * Helper to wrap a promise with a timeout */ async function withTimeout(promise: Promise, ms: number, message: string): Promise { let timeoutId: ReturnType; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(message)), ms); }); try { return await Promise.race([promise, timeoutPromise]); } finally { clearTimeout(timeoutId!); } } /** * Connect to a stdio MCP server (spawn child process) */ export async function connectStdio( id: string, entry: StdioServerEntry, baseEnv: Record, ): Promise { logInfo(`Connecting to "${id}" (stdio): ${entry.command} ${(entry.args || []).join(' ')}`); const wrapper = new ResilientTransportWrapper({ name: id, connectParams: async () => { const t = new CustomStdioClientTransport({ command: entry.command, args: entry.args || [], env: { ...baseEnv, ...(entry.env || {}) }, stderr: 'pipe', }); if (t.stderr) { t.stderr.on('data', (chunk: Buffer) => { const text = chunk.toString().trim(); if (text) { process.stderr.write(`[child:${id}] ${text}\n`); } }); } return t; }, // No heartbeat for stdio — child process close/error events handle detection pingIntervalMs: 0, }); const client = new Client({ name: `proxy-${id}`, version: '1.0.0' }); // Set reconnect handler to re-establish MCP session via client.connect() // Note: We need to clear the SDK's internal _transport reference without closing it. // client.close() would close the wrapper transport, so we manually clear the reference. wrapper.onreconnect = async () => { logInfo(`[${id}] Reconnecting MCP session...`); // Clear SDK's transport reference without closing it (wrapper is already reconnected) (client as any)._transport = undefined; await client.connect(wrapper); logInfo(`[${id}] MCP session reconnected`); }; const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_STDIO_CONNECTION_TIMEOUT_MS; try { await withTimeout( (async () => { await wrapper.start(); await client.connect(wrapper); })(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s` ); } catch (err) { try { await wrapper.close(); } catch { /* ignore */ } throw err; } return { client, transport: wrapper, cleanup: async () => { try { await wrapper.close(); } catch { /* ignore */ } }, }; } /** * Connect to a Streamable HTTP MCP server * * Supports PersistentMcpBridge endpoints and any remote MCP service using * the Streamable HTTP transport protocol. */ export async function connectStreamable( id: string, entry: StreamableServerEntry, ): Promise { logInfo(`Connecting to "${id}" (streamable-http): ${entry.url}`); const { buildRequestHeaders } = await import('./headers.js'); const headers = buildRequestHeaders(entry); const url = new URL(entry.url); const wrapper = new ResilientTransportWrapper({ name: id, connectParams: async () => { return new StreamableHTTPClientTransport( url, headers ? { requestInit: { headers } } : undefined, ); }, // Use provided interval or default (30s) pingIntervalMs: entry.pingIntervalMs, pingTimeoutMs: entry.pingTimeoutMs, }); const client = new Client({ name: `proxy-${id}`, version: '1.0.0' }); // Set health check function to use ping() for HTTP-based transports // ping() is lighter than listTools() and sufficient for connection health wrapper.setHealthCheckFn(async () => { try { const { tools } = await client.listTools(); logInfo(`[proxy-${id}] Streamable HTTP health check OK: ${tools?.length ?? 0} tools`); return true; } catch(error) { logError(`[proxy-${id}] Streamable HTTP health check failed: ${error instanceof Error ? error.message : String(error)}`); return false; } }); // Set reconnect handler to re-establish MCP session via client.connect() // Note: We need to clear the SDK's internal _transport reference without closing it. // client.close() would close the wrapper transport, so we manually clear the reference. // Note: Do NOT call enableHeartbeat() here - performConnect will call startHeartbeat() // after flushQueue() completes, ensuring health checks use the correct session. wrapper.onreconnect = async () => { logInfo(`[${id}] Reconnecting MCP session...`); // Clear SDK's transport reference without closing it (wrapper is already reconnected) (client as any)._transport = undefined; await client.connect(wrapper); // Note: heartbeat is restarted by ResilientTransport.performConnect after flushQueue() logInfo(`[${id}] MCP session reconnected`); }; const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS; try { await withTimeout( (async () => { await wrapper.start(); await client.connect(wrapper); wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes })(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s` ); } catch (err) { try { await wrapper.close(); } catch { /* ignore */ } throw err; } return { client, transport: wrapper, cleanup: async () => { try { await wrapper.close(); } catch { /* ignore */ } }, }; } /** * Connect to an SSE MCP server * * Uses the legacy SSE (Server-Sent Events) transport for MCP servers * that don't support the newer Streamable HTTP protocol. */ export async function connectSse( id: string, entry: SseServerEntry, ): Promise { logInfo(`Connecting to "${id}" (sse): ${entry.url}`); const { buildRequestHeaders } = await import('./headers.js'); const headers = buildRequestHeaders(entry); const url = new URL(entry.url); const wrapper = new ResilientTransportWrapper({ name: id, connectParams: async () => { return new SSEClientTransport( url, headers ? { requestInit: { headers } } : undefined, ); }, // Use provided interval or default (30s) pingIntervalMs: entry.pingIntervalMs, pingTimeoutMs: entry.pingTimeoutMs, }); const client = new Client({ name: `proxy-${id}`, version: '1.0.0' }); // Set health check function to use ping() for HTTP-based transports // ping() is lighter than listTools() and sufficient for connection health wrapper.setHealthCheckFn(async () => { try { // Get current session_id from transport const innerTransport = (wrapper as any).activeTransport; const endpoint = innerTransport?._endpoint?.href || innerTransport?._endpoint || 'no-endpoint'; const { tools } = await client.listTools(); logInfo(`[proxy-${id}] SSE health check OK: ${Array.isArray(tools) ? tools.length : 0} tools (endpoint: ${endpoint})`); return true; } catch(error) { logError(`[proxy-${id}] SSE health check failed: ${error instanceof Error ? error.message : String(error)}`); return false; } }); // Set reconnect handler to re-establish MCP session via client.connect() // Note: We need to clear the SDK's internal _transport reference without closing it. // client.close() would close the wrapper transport, so we manually clear the reference. // Note: Do NOT call enableHeartbeat() here - performConnect will call startHeartbeat() // after flushQueue() completes, ensuring health checks use the correct session. wrapper.onreconnect = async () => { // Get new session_id from transport const innerTransport = (wrapper as any).activeTransport; const endpoint = innerTransport?._endpoint?.href || innerTransport?._endpoint || 'no-endpoint'; logInfo(`[${id}] Reconnecting MCP session (endpoint: ${endpoint})...`); // Clear SDK's transport reference without closing it (wrapper is already reconnected) (client as any)._transport = undefined; await client.connect(wrapper); // Note: heartbeat is restarted by ResilientTransport.performConnect after flushQueue() logInfo(`[${id}] MCP session reconnected (endpoint: ${endpoint})`); }; const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS; try { await withTimeout( (async () => { await wrapper.start(); await client.connect(wrapper); wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes })(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s` ); } catch (err) { try { await wrapper.close(); } catch { /* ignore */ } throw err; } return { client, transport: wrapper, cleanup: async () => { try { await wrapper.close(); } catch { /* ignore */ } }, }; } /** @deprecated Use connectStreamable instead */ export const connectBridge = connectStreamable; /** @deprecated Use connectStreamable instead */ export const connectHttp = connectStreamable;