@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, and professional CLI. Built-in tools operational, 58+ external MCP servers discoverable. Connect to filesystem, GitHub, database operations, and more. Build, test, and
469 lines (468 loc) • 17.2 kB
JavaScript
/**
* MCP Client Factory
* Creates and manages MCP clients for external servers
* Supports stdio, SSE, and WebSocket transports
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js";
import { spawn } from "child_process";
import { mcpLogger } from "../utils/logger.js";
import { globalCircuitBreakerManager } from "./mcpCircuitBreaker.js";
/**
* 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 = {
tools: {},
resources: {},
prompts: {},
sampling: {},
roots: {
listChanged: false,
},
};
/**
* Create an MCP client for the given server configuration
*/
static async createClient(config, timeout = 10000) {
const startTime = Date.now();
try {
mcpLogger.info(`[MCPClientFactory] Creating client for ${config.id}`, {
transport: config.transport,
command: config.command,
});
// Create circuit breaker for this server
const circuitBreaker = globalCircuitBreakerManager.getBreaker(`mcp-client-${config.id}`, {
failureThreshold: 3,
resetTimeout: 30000,
operationTimeout: timeout,
});
// Create client with circuit breaker protection
const result = await circuitBreaker.execute(async () => {
return await this.createClientInternal(config, timeout);
});
mcpLogger.info(`[MCPClientFactory] Client created successfully for ${config.id}`, {
duration: Date.now() - startTime,
capabilities: result.capabilities,
});
return {
...result,
success: true,
duration: Date.now() - startTime,
};
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
mcpLogger.error(`[MCPClientFactory] Failed to create client for ${config.id}:`, error);
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);
// Extract transport and process with necessary type assertions
// Note: Type assertions required due to TransportResult using 'unknown' to avoid circular imports
const transport = transportResult.transport;
const process = transportResult.process;
try {
// Create client
const client = new Client(this.NEUROLINK_IMPLEMENTATION, {
capabilities: this.DEFAULT_CAPABILITIES,
});
// Connect with timeout
await Promise.race([
client.connect(transport),
this.createTimeoutPromise(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,
process,
capabilities: serverCapabilities,
};
}
catch (error) {
// Clean up on failure
try {
await transport.close();
}
catch (closeError) {
mcpLogger.debug(`[MCPClientFactory] Error closing transport during cleanup:`, closeError);
}
if (process && !process.killed) {
process.kill("SIGTERM");
}
throw error;
}
}
/**
* 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);
default:
throw new Error(`Unsupported transport type: ${config.transport}`);
}
}
/**
* Create stdio transport with process spawning
*/
static async createStdioTransport(config) {
mcpLogger.debug(`[MCPClientFactory] Creating stdio transport for ${config.id}`, {
command: config.command,
args: config.args,
});
// Validate command is present
if (!config.command) {
throw new Error(`Command is required for stdio transport`);
}
// Spawn the process
const childProcess = spawn(config.command, config.args || [], {
stdio: ["pipe", "pipe", "pipe"],
env: Object.fromEntries(Object.entries({
...process.env,
...config.env,
})
.filter(([, value]) => value !== undefined)
.map(([k, v]) => [k, String(v)])),
cwd: config.cwd,
});
// Handle process errors
const processErrorPromise = new Promise((_, reject) => {
childProcess.on("error", (error) => {
reject(new Error(`Process spawn error: ${error.message}`));
});
childProcess.on("exit", (code, signal) => {
if (code !== 0) {
reject(new Error(`Process exited with code ${code}, signal ${signal}`));
}
});
});
// Wait for process to be ready or fail using AbortController for better async patterns
const processStartupController = new AbortController();
const processStartupTimeout = setTimeout(() => {
processStartupController.abort();
}, 1000);
try {
await Promise.race([
new Promise((resolve) => {
const checkReady = () => {
if (processStartupController.signal.aborted) {
resolve(); // Timeout reached, continue
}
else {
setTimeout(checkReady, 100);
}
};
checkReady();
}),
processErrorPromise,
]);
}
finally {
clearTimeout(processStartupTimeout);
}
// Check if process is still running
if (childProcess.killed || childProcess.exitCode !== null) {
throw new Error("Process failed to start or exited immediately");
}
// Create transport
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,
});
return { transport, process: childProcess };
}
/**
* 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 transport = new SSEClientTransport(url);
return { transport };
}
catch (error) {
throw new Error(`Invalid SSE URL: ${error instanceof Error ? error.message : String(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 transport = new WebSocketClientTransport(url);
return { transport };
}
catch (error) {
throw new Error(`Invalid WebSocket URL: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 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 Promise.race([
this.getServerInfo(client),
this.createTimeoutPromise(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: {} } : {}),
};
}
/**
* Create a timeout promise with AbortController support
* Provides consistent async timeout patterns across the factory
*/
static createTimeoutPromise(timeout, message, abortSignal) {
return new Promise((_, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(message));
}, timeout);
// Support abortion for better async cleanup
if (abortSignal) {
abortSignal.addEventListener("abort", () => {
clearTimeout(timeoutId);
reject(new Error(`Operation aborted: ${message}`));
});
}
});
}
/**
* 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"].includes(config.transport)) {
errors.push("Transport must be stdio, sse, or websocket");
}
// Transport-specific validation
if (config.transport === "sse" || config.transport === "websocket") {
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"];
}
/**
* Get default client capabilities
*/
static getDefaultCapabilities() {
return { ...this.DEFAULT_CAPABILITIES };
}
}