UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

748 lines (747 loc) • 31.1 kB
/** * MCP Client Factory * Creates and manages MCP clients for external servers * Supports stdio, SSE, WebSocket, and HTTP transports * Enhanced with retry, rate limiting, and OAuth 2.1 support */ import { mcpLogger } from "../utils/logger.js"; import { globalCircuitBreakerManager } from "./mcpCircuitBreaker.js"; import { CircuitBreakerOpenError } from "../types/index.js"; import { withHTTPRetry, DEFAULT_HTTP_RETRY_CONFIG, } from "./httpRetryHandler.js"; import { globalRateLimiterManager } from "./httpRateLimiter.js"; import { NeuroLinkOAuthProvider, InMemoryTokenStorage } from "./auth/index.js"; import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../observability/index.js"; import { getActiveTraceContext } from "../telemetry/traceContext.js"; /** * Default timeout for MCP client creation in milliseconds. * Configurable via MCP_CLIENT_TIMEOUT env var. * Covers process spawn, transport setup, connection, and handshake. * Set to 60s to accommodate stdio servers that may be slow to start, * especially when multiple MCP servers are started concurrently. */ const DEFAULT_CLIENT_TIMEOUT = Math.max(5000, Number(process.env.MCP_CLIENT_TIMEOUT) || 60000); /** * How many stderr lines to keep per stdio server. Enough for a Python * traceback or an OOM message; small enough that a chatty server logging * every request to stderr costs nothing. */ const STDERR_TAIL_LINES = 20; /** * Bounded buffer of the most recent stderr lines a stdio server wrote. * * A crashing server explains itself on stderr and nowhere else. With the * stream ignored — as it was — a `Connection closed` said nothing about * why, and the ExternalServerManager could only report that a process it * never saw had gone. Keeping a short tail per transport lets the * disconnect log, the connect error and the `disconnected` event all carry * the server's last words. */ class StderrTail { lines = []; partial = ""; append(chunk) { this.partial += chunk.toString(); const pieces = this.partial.split(/\r?\n/); this.partial = pieces.pop() ?? ""; for (const piece of pieces) { if (piece.trim().length === 0) { continue; } this.lines.push(piece); if (this.lines.length > STDERR_TAIL_LINES) { this.lines.shift(); } } } snapshot() { const out = [...this.lines]; if (this.partial.trim().length > 0) { out.push(this.partial); } return out.slice(-STDERR_TAIL_LINES); } } const stderrTails = new WeakMap(); /** * MCPClientFactory * Factory class for creating MCP clients with different transports */ export class MCPClientFactory { static NEUROLINK_IMPLEMENTATION = { name: "neurolink-sdk", version: "1.0.0", }; static DEFAULT_CAPABILITIES = { sampling: {}, roots: { listChanged: false, }, }; /** * Create an MCP client for the given server configuration * Enhanced with retry logic, rate limiting, and circuit breaker protection */ static async createClient(config, timeout = DEFAULT_CLIENT_TIMEOUT) { const startTime = Date.now(); const { traceId, parentSpanId } = getActiveTraceContext(); const obsSpan = SpanSerializer.createSpan(SpanType.MCP_TRANSPORT, "mcp.connect", { "mcp.transport": config.transport, "mcp.operation": "connect", "mcp.server_id": config.id, }, parentSpanId, traceId); try { mcpLogger.info(`[MCPClientFactory] Creating client for ${config.id}`, { transport: config.transport, command: config.command, hasRetryConfig: !!config.retryConfig, hasRateLimiting: !!config.rateLimiting, hasAuth: !!config.auth, }); // Acquire rate limit token if rate limiting is configured for HTTP transport if ((config.transport === "http" || config.transport === "sse") && config.rateLimiting) { const rateLimiter = globalRateLimiterManager.getLimiter(config.id, { requestsPerWindow: config.rateLimiting.requestsPerMinute ?? 60, windowMs: 60000, maxBurst: config.rateLimiting.maxBurst ?? 10, useTokenBucket: config.rateLimiting.useTokenBucket ?? true, refillRate: (config.rateLimiting.requestsPerMinute ?? 60) / 60, }); await rateLimiter.acquire(); mcpLogger.debug(`[MCPClientFactory] Rate limit token acquired for ${config.id}`); } // Create circuit breaker for this server const circuitBreaker = globalCircuitBreakerManager.getBreaker(`mcp-client-${config.id}`, { failureThreshold: 3, resetTimeout: 30000, operationTimeout: timeout, }); // Define the client creation operation const createClientOperation = async () => { return await circuitBreaker.execute(async () => { return await this.createClientInternal(config, timeout); }); }; // Wrap with retry logic if retry config is provided for HTTP transport let result; if ((config.transport === "http" || config.transport === "sse") && config.retryConfig) { mcpLogger.debug(`[MCPClientFactory] Using retry logic for ${config.id}`, { maxAttempts: config.retryConfig.maxAttempts ?? DEFAULT_HTTP_RETRY_CONFIG.maxAttempts, }); result = await withHTTPRetry(createClientOperation, { maxAttempts: config.retryConfig.maxAttempts ?? DEFAULT_HTTP_RETRY_CONFIG.maxAttempts, initialDelay: config.retryConfig.initialDelay ?? DEFAULT_HTTP_RETRY_CONFIG.initialDelay, maxDelay: config.retryConfig.maxDelay ?? DEFAULT_HTTP_RETRY_CONFIG.maxDelay, backoffMultiplier: config.retryConfig.backoffMultiplier ?? DEFAULT_HTTP_RETRY_CONFIG.backoffMultiplier, }); } else { result = await createClientOperation(); } mcpLogger.info(`[MCPClientFactory] Client created successfully for ${config.id}`, { duration: Date.now() - startTime, capabilities: result.capabilities, }); obsSpan.durationMs = Date.now() - startTime; const endedObsSpan = SpanSerializer.endSpan(obsSpan, SpanStatus.OK); getMetricsAggregator().recordSpan(endedObsSpan); return { ...result, success: true, duration: Date.now() - startTime, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); // Circuit breaker open: log at warn (not error) since this is expected // protection behavior, and preserve the structured metadata. if (error instanceof CircuitBreakerOpenError) { mcpLogger.warn(`[MCPClientFactory] Client creation blocked by circuit breaker for ${config.id}`, { serverId: config.id, breakerState: error.breakerState, retryAfter: error.retryAfter, retryAfterMs: error.retryAfterMs, failureCount: error.failureCount, }); obsSpan.durationMs = Date.now() - startTime; const endedObsSpan = SpanSerializer.endSpan(obsSpan, SpanStatus.ERROR); endedObsSpan.statusMessage = `Circuit breaker open: ${errorMessage}`; getMetricsAggregator().recordSpan(endedObsSpan); return { success: false, error: errorMessage, duration: Date.now() - startTime, }; } // debug, not error: this failure propagates up through // ExternalServerManager to NeuroLink.addExternalMCPServer, which emits // the single ERROR record. Logging at every layer turned one failed // server registration into 5 ERROR lines in production. mcpLogger.debug(`[MCPClientFactory] Failed to create client for ${config.id}:`, error); obsSpan.durationMs = Date.now() - startTime; const endedObsSpan = SpanSerializer.endSpan(obsSpan, SpanStatus.ERROR); endedObsSpan.statusMessage = errorMessage; getMetricsAggregator().recordSpan(endedObsSpan); return { success: false, error: errorMessage, duration: Date.now() - startTime, }; } } /** * Internal client creation logic */ static async createClientInternal(config, timeout) { // Create transport const transportResult = await this.createTransport(config); // Note: Type assertion required due to TransportResult using 'unknown' to avoid circular imports const transport = transportResult.transport; try { // Create client const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); const client = new Client(this.NEUROLINK_IMPLEMENTATION, { capabilities: this.DEFAULT_CAPABILITIES, }); // Connect with timeout await this.raceWithTimeout(client.connect(transport), timeout, `Client connection timeout for ${config.id}`); // Perform handshake to get server capabilities const serverCapabilities = await this.performHandshake(client, timeout); mcpLogger.debug(`[MCPClientFactory] Handshake completed for ${config.id}`, { capabilities: serverCapabilities, }); return { client, transport, capabilities: serverCapabilities, }; } catch (error) { // Clean up on failure. For stdio, transport.close() ends stdin and // escalates SIGTERM → SIGKILL on the child it spawned. try { await transport.close(); } catch (closeError) { mcpLogger.debug(`[MCPClientFactory] Error closing transport during cleanup:`, closeError); } // A server that died during the handshake wrote its reason to stderr. // Attach it, so "Connection closed" arrives with the traceback that // explains it instead of leaving the operator to reproduce by hand. const stderrTail = this.getStderrTail(transport); if (error instanceof Error && stderrTail.length > 0) { throw new Error(`${error.message}\nServer stderr (last ${stderrTail.length} lines):\n${stderrTail.join("\n")}`, { cause: error }); } throw error; } } /** * The most recent stderr lines written by the stdio server behind * `transport`. Empty for network transports and for servers that have * written nothing. Lines are captured from before the process is even * started, so an early boot failure is included. */ static getStderrTail(transport) { return stderrTails.get(transport)?.snapshot() ?? []; } /** * Create transport based on configuration */ static async createTransport(config) { switch (config.transport) { case "stdio": return this.createStdioTransport(config); case "sse": return this.createSSETransport(config); case "websocket": return this.createWebSocketTransport(config); case "http": return this.createHTTPTransport(config); default: throw new Error(`Unsupported transport type: ${config.transport}`); } } /** * Create a stdio transport. * * The SDK's StdioClientTransport owns the server process: it spawns the * child inside `client.connect()` and reports the child's death through * `transport.onclose`, which the Client forwards as `client.onclose`. There * is deliberately no `spawn()` here. An earlier "startup probe" launched * the command a second time, and that duplicate — never spoken to, never * closed — was the process every lifecycle hook ended up watching while * the real server could die unnoticed. It also leaked as an orphan on * every shutdown. * * stderr is piped rather than ignored so a crashing server's last lines * survive to the disconnect log and the connect error. A pipe nobody reads * would back-pressure the child once the buffer fills, so the tail * listener is attached before `start()`; the SDK creates the stderr * PassThrough in its constructor for exactly this reason. */ static async createStdioTransport(config) { mcpLogger.debug(`[MCPClientFactory] Creating stdio transport for ${config.id}`, { command: config.command, args: config.args, }); if (!config.command) { throw new Error(`Command is required for stdio transport`); } const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js"); const transport = new StdioClientTransport({ command: config.command, args: config.args || [], env: Object.fromEntries(Object.entries({ ...process.env, ...config.env, }) .filter(([, value]) => value !== undefined) .map(([key, value]) => [key, String(value)])), cwd: config.cwd, stderr: "pipe", }); const tail = new StderrTail(); stderrTails.set(transport, tail); transport.stderr?.on("data", (chunk) => { tail.append(chunk); if (mcpLogger.shouldLog("debug")) { const text = chunk.toString().trim(); if (text.length > 0) { mcpLogger.debug(`[MCPClientFactory] ${config.id} stderr:`, text); } } }); return { transport }; } /** * Create SSE transport */ static async createSSETransport(config) { if (!config.url) { throw new Error("URL is required for SSE transport"); } mcpLogger.debug(`[MCPClientFactory] Creating SSE transport for ${config.id}`, { url: config.url, }); try { const url = new URL(config.url); const { SSEClientTransport } = await import("@modelcontextprotocol/sdk/client/sse.js"); const transport = new SSEClientTransport(url); return { transport }; } catch (error) { throw new Error(`Invalid SSE URL: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } /** * Create WebSocket transport */ static async createWebSocketTransport(config) { if (!config.url) { throw new Error("URL is required for WebSocket transport"); } mcpLogger.debug(`[MCPClientFactory] Creating WebSocket transport for ${config.id}`, { url: config.url, }); try { const url = new URL(config.url); const { WebSocketClientTransport } = await import("@modelcontextprotocol/sdk/client/websocket.js"); const transport = new WebSocketClientTransport(url); return { transport }; } catch (error) { throw new Error(`Invalid WebSocket URL: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } /** * Create HTTP transport (Streamable HTTP) * Enhanced with OAuth 2.1, rate limiting, and configurable timeouts */ static async createHTTPTransport(config) { if (!config.url) { throw new Error("URL is required for HTTP transport"); } // Extract HTTP options with defaults const httpOptions = { connectionTimeout: config.httpOptions?.connectionTimeout ?? 30000, requestTimeout: config.httpOptions?.requestTimeout ?? 60000, idleTimeout: config.httpOptions?.idleTimeout ?? 120000, keepAliveTimeout: config.httpOptions?.keepAliveTimeout ?? 30000, }; mcpLogger.debug(`[MCPClientFactory] Creating HTTP transport for ${config.id}`, { url: config.url, hasHeaders: !!config.headers, hasAuth: !!config.auth, authType: config.auth?.type, httpOptions, }); try { const url = new URL(config.url); // Set up OAuth provider if configured const oauthProvider = await this.setupAuthProvider(config); // Build headers including authentication const headers = { ...(config.headers ?? {}), }; // Add authentication headers based on auth type if (config.auth) { const authHeader = await this.getAuthorizationHeader(config, oauthProvider); if (authHeader) { headers["Authorization"] = authHeader; } } // Create custom fetch wrapper with timeout and rate limiting support const fetchWithEnhancements = this.createEnhancedFetch(config, httpOptions.requestTimeout, oauthProvider); // Create request init with custom headers const requestInit = { headers: Object.keys(headers).length > 0 ? headers : undefined, }; const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js"); const transport = new StreamableHTTPClientTransport(url, { requestInit, fetch: fetchWithEnhancements, }); return { transport }; } catch (error) { throw new Error(`Invalid HTTP URL: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } /** * Create a fetch wrapper with timeout support */ static createFetchWithTimeout(timeoutMs) { return async (input, init) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(input, { ...init, signal: controller.signal }); } finally { clearTimeout(timeoutId); } }; } /** * Create an enhanced fetch function with timeout and optional retry */ static createEnhancedFetch(config, timeoutMs, oauthProvider) { const fetchWithTimeout = this.createFetchWithTimeout(timeoutMs); return async (input, init) => { // If OAuth is configured, ensure we have valid tokens if (oauthProvider && config.auth?.type === "oauth2") { try { const authHeader = await oauthProvider.getAuthorizationHeader(config.id); if (authHeader) { const existingHeaders = init?.headers ?? {}; const headers = new Headers(existingHeaders); headers.set("Authorization", authHeader); init = { ...init, headers, }; } } catch (error) { mcpLogger.warn(`[MCPClientFactory] OAuth token refresh failed for ${config.id}:`, error instanceof Error ? error.message : String(error)); // Continue without auth - let the request fail naturally } } return fetchWithTimeout(input, init); }; } /** * Set up OAuth provider if configured */ static async setupAuthProvider(config) { if (config.auth?.type === "oauth2" && config.auth.oauth) { const tokenStorage = new InMemoryTokenStorage(); const oauthConfig = { clientId: config.auth.oauth.clientId, clientSecret: config.auth.oauth.clientSecret, authorizationUrl: config.auth.oauth.authorizationUrl, tokenUrl: config.auth.oauth.tokenUrl, redirectUrl: config.auth.oauth.redirectUrl, scope: config.auth.oauth.scope, usePKCE: config.auth.oauth.usePKCE ?? true, }; const provider = new NeuroLinkOAuthProvider(oauthConfig, tokenStorage); mcpLogger.debug(`[MCPClientFactory] OAuth provider created for ${config.id}`, { clientId: oauthConfig.clientId, usePKCE: oauthConfig.usePKCE, }); return provider; } return undefined; } /** * Get authorization header based on auth configuration */ static async getAuthorizationHeader(config, oauthProvider) { if (!config.auth) { return undefined; } switch (config.auth.type) { case "oauth2": if (oauthProvider) { const header = await oauthProvider.getAuthorizationHeader(config.id); return header ?? undefined; } return undefined; case "bearer": if (config.auth.token) { return `Bearer ${config.auth.token}`; } return undefined; case "api-key": // API key is typically sent as a custom header, not Authorization // But if needed, we can return it here return undefined; default: return undefined; } } /** * Perform MCP handshake and get server capabilities */ static async performHandshake(client, timeout) { try { // The MCP SDK handles the handshake automatically during connect() // We can request server info to verify the connection const serverInfo = await this.raceWithTimeout(this.getServerInfo(client), timeout, "Handshake timeout"); // Extract capabilities from server info return this.extractCapabilities(serverInfo); } catch (error) { mcpLogger.warn("[MCPClientFactory] Handshake failed, but connection may still be valid:", error); // Return default capabilities if handshake fails // The connection might still work for basic operations return this.DEFAULT_CAPABILITIES; } } /** * Get server information */ static async getServerInfo(client) { try { // Try to list tools to verify server is responding const toolsResult = await client.listTools(); return { tools: toolsResult.tools || [], capabilities: this.DEFAULT_CAPABILITIES, }; } catch { // If listing tools fails, try a simpler ping mcpLogger.debug("[MCPClientFactory] Tool listing failed, server may not support tools yet"); return { tools: [], capabilities: this.DEFAULT_CAPABILITIES, }; } } /** * Extract capabilities from server info */ static extractCapabilities(serverInfo) { // For now, return default capabilities // This can be enhanced when MCP servers provide more detailed capability info return { ...this.DEFAULT_CAPABILITIES, ...(serverInfo.tools ? { tools: {} } : {}), }; } /** * Race an operation against a timeout (with optional AbortController * support), clearing the timer on every outcome. A bare * `Promise.race([operation, timeoutPromise])` leaves the timeout's * `setTimeout` handle ref'd and pending until it fires even after * `operation` already won the race — on the (common) success path that * stray timer holds the event loop open for the full timeout duration for * no reason. */ static async raceWithTimeout(operation, timeout, message, abortSignal) { let timeoutId; let onAbort; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(message)); }, timeout); // Support abortion for better async cleanup if (abortSignal) { onAbort = () => { clearTimeout(timeoutId); reject(new Error(`Operation aborted: ${message}`)); }; abortSignal.addEventListener("abort", onAbort); } }); try { return await Promise.race([operation, timeoutPromise]); } finally { clearTimeout(timeoutId); if (abortSignal && onAbort) { abortSignal.removeEventListener("abort", onAbort); } } } /** * Close an MCP client and clean up resources */ static async closeClient(client, transport, process) { const errors = []; // Close client try { await client.close(); } catch (error) { errors.push(`Client close error: ${error instanceof Error ? error.message : String(error)}`); } // Close transport try { await transport.close(); } catch (error) { errors.push(`Transport close error: ${error instanceof Error ? error.message : String(error)}`); } // Kill process if exists with proper async cleanup if (process && !process.killed) { try { process.kill("SIGTERM"); // Use Promise-based approach for force kill timeout await new Promise((resolve) => { const forceKillTimeout = setTimeout(() => { if (!process.killed) { mcpLogger.warn("[MCPClientFactory] Force killing process"); try { process.kill("SIGKILL"); } catch (killError) { mcpLogger.debug("[MCPClientFactory] Error in force kill:", killError); } } resolve(); }, 5000); // If process exits gracefully before timeout, clear the force kill process.on("exit", () => { clearTimeout(forceKillTimeout); resolve(); }); }); } catch (error) { errors.push(`Process kill error: ${error instanceof Error ? error.message : String(error)}`); } } if (errors.length > 0) { mcpLogger.warn("[MCPClientFactory] Errors during client cleanup:", errors); } } /** * Test connection to an MCP server */ static async testConnection(config, timeout = 5000) { let client; let transport; let process; try { const result = await this.createClient(config, timeout); if (!result.success) { return { success: false, error: result.error }; } client = result.client; transport = result.transport; process = result.process; // Try to list tools as a connectivity test if (client) { try { await client.listTools(); } catch { // Tool listing failure doesn't necessarily mean connection failure mcpLogger.debug("[MCPClientFactory] Tool listing failed during test, but connection may be valid"); } } return { success: true, capabilities: result.capabilities, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), }; } finally { // Clean up test connection if (client && transport) { try { await this.closeClient(client, transport, process); } catch (error) { mcpLogger.debug("[MCPClientFactory] Error cleaning up test connection:", error); } } } } /** * Validate MCP server configuration for client creation */ static validateClientConfig(config) { const errors = []; // Basic validation if (!config.command) { errors.push("Command is required"); } if (!config.transport) { errors.push("Transport is required"); } if (!["stdio", "sse", "websocket", "http"].includes(config.transport)) { errors.push("Transport must be stdio, sse, websocket, or http"); } // Transport-specific validation if (config.transport === "sse" || config.transport === "websocket" || config.transport === "http") { if (!config.url) { errors.push(`URL is required for ${config.transport} transport`); } else { try { new URL(config.url); } catch { errors.push(`Invalid URL for ${config.transport} transport`); } } } if (config.transport === "stdio") { if (!Array.isArray(config.args)) { errors.push("Args array is required for stdio transport"); } } return { isValid: errors.length === 0, errors, }; } /** * Get supported transport types */ static getSupportedTransports() { return ["stdio", "sse", "websocket", "http"]; } /** * Get default client capabilities */ static getDefaultCapabilities() { return { ...this.DEFAULT_CAPABILITIES }; } }