@lanonasis/cli
Version:
Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security
374 lines (373 loc) • 13.5 kB
JavaScript
/**
* Enhanced MCP Client with advanced features
* Provides multi-server support, connection pooling, and better error handling
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import chalk from 'chalk';
import { EventEmitter } from 'events';
export class EnhancedMCPClient extends EventEmitter {
clients = new Map();
transports = new Map();
connectionStatus = new Map();
retryAttempts = new Map();
healthCheckIntervals = new Map();
spinner = null;
constructor() {
super();
this.setupEventHandlers();
}
/**
* Setup internal event handlers
*/
setupEventHandlers() {
this.on('connection:established', (server) => {
console.log(chalk.green(`✅ Connected to ${server}`));
});
this.on('connection:lost', (server) => {
console.log(chalk.yellow(`⚠️ Lost connection to ${server}`));
});
this.on('connection:error', (server, error) => {
console.log(chalk.red(`❌ Connection error for ${server}: ${error.message}`));
});
}
/**
* Connect to multiple MCP servers
*/
async connectMultiple(servers) {
const results = new Map();
// Sort servers by priority
const sortedServers = servers.sort((a, b) => (a.priority || 999) - (b.priority || 999));
// Connect in parallel with controlled concurrency
const connectionPromises = sortedServers.map(server => this.connectSingle(server).then(success => {
results.set(server.name, success);
return { server: server.name, success };
}));
await Promise.allSettled(connectionPromises);
// Start health monitoring for connected servers
results.forEach((success, name) => {
if (success) {
this.startHealthMonitoring(name);
}
});
return results;
}
/**
* Connect to a single MCP server with retry logic
*/
async connectSingle(config) {
const maxRetries = config.maxRetries || 3;
const timeout = config.timeout || 30000;
let attempts = 0;
while (true) {
try {
this.updateConnectionStatus(config.name, 'connecting');
const client = await this.createClientWithTimeout(config, timeout);
this.clients.set(config.name, client);
this.updateConnectionStatus(config.name, 'connected');
this.emit('connection:established', config.name);
return true;
}
catch (error) {
attempts++;
this.retryAttempts.set(config.name, attempts);
if (attempts >= maxRetries) {
this.updateConnectionStatus(config.name, 'error', error);
this.emit('connection:error', config.name, error);
return false;
}
// Exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempts), 10000);
console.log(chalk.yellow(`⏳ Retrying connection to ${config.name} in ${delay}ms...`));
await this.delay(delay);
}
}
}
/**
* Create client with timeout
*/
async createClientWithTimeout(config, timeout) {
return Promise.race([
this.createClient(config),
new Promise((_, reject) => setTimeout(() => reject(new Error(`Connection timeout after ${timeout}ms`)), timeout))
]);
}
/**
* Create MCP client based on config
*/
async createClient(config) {
let transport;
switch (config.type) {
case 'stdio':
if (!config.command) {
throw new Error('Command required for stdio transport');
}
transport = new StdioClientTransport({
command: config.command,
args: config.args || []
});
break;
case 'http':
if (!config.url) {
throw new Error('URL required for http transport');
}
// HTTP transport is not directly supported by MCP SDK
// Use stdio or websocket instead
throw new Error('HTTP transport not directly supported. Use websocket or stdio transport.');
case 'websocket':
if (!config.url) {
throw new Error('URL required for websocket transport');
}
transport = await this.createWebSocketTransport(config.url);
break;
default:
throw new Error(`Unsupported transport type: ${config.type}`);
}
this.transports.set(config.name, transport);
const client = new Client({
name: `lanonasis-cli-${config.name}`,
version: '3.0.1'
}, {
capabilities: {}
});
await client.connect(transport);
return client;
}
/**
* Create WebSocket transport
*/
async createWebSocketTransport(url) {
// Custom WebSocket transport implementation
const WebSocket = (await import('ws')).default;
return new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.on('open', () => {
resolve({
send: (data) => ws.send(JSON.stringify(data)),
on: (event, handler) => ws.on(event, handler),
close: () => ws.close()
});
});
ws.on('error', reject);
});
}
/**
* Execute a chain of tools
*/
async executeToolChain(chain) {
const results = [];
if (chain.mode === 'sequential') {
for (const tool of chain.tools) {
const result = await this.executeTool(tool.name, tool.args);
results.push(result);
if (tool.waitForCompletion) {
await this.waitForToolCompletion(tool.name, result);
}
}
}
else {
// Parallel execution
const promises = chain.tools.map(tool => this.executeTool(tool.name, tool.args));
const parallelResults = await Promise.allSettled(promises);
parallelResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
results.push(result.value);
}
else {
results.push({ error: result.reason, tool: chain.tools[index].name });
}
});
}
return results;
}
/**
* Execute a single tool with automatic server selection
*/
async executeTool(toolName, args) {
// Find the best available server for this tool
const server = await this.selectBestServer(toolName);
if (!server) {
throw new Error(`No available server for tool: ${toolName}`);
}
const client = this.clients.get(server);
if (!client) {
throw new Error(`Client not found for server: ${server}`);
}
try {
const result = await client.callTool({
name: toolName,
arguments: args
});
return result;
}
catch (error) {
// Try failover to another server
const failoverServer = await this.selectFailoverServer(server, toolName);
if (failoverServer) {
console.log(chalk.yellow(`⚠️ Failing over to ${failoverServer}...`));
const failoverClient = this.clients.get(failoverServer);
if (failoverClient) {
return failoverClient.callTool({
name: toolName,
arguments: args
});
}
}
throw error;
}
}
/**
* Select the best server for a tool based on availability and latency
*/
async selectBestServer(_toolName) {
const availableServers = Array.from(this.clients.keys()).filter(name => {
const status = this.connectionStatus.get(name);
return status?.status === 'connected';
});
if (availableServers.length === 0) {
return null;
}
// For now, return the first available server
// TODO: Implement smarter selection based on tool availability and latency
return availableServers[0];
}
/**
* Select a failover server
*/
async selectFailoverServer(excludeServer, _toolName) {
const availableServers = Array.from(this.clients.keys()).filter(name => {
const status = this.connectionStatus.get(name);
return name !== excludeServer && status?.status === 'connected';
});
return availableServers.length > 0 ? availableServers[0] : null;
}
/**
* Wait for tool completion (for async operations)
*/
async waitForToolCompletion(toolName, initialResult) {
// Implementation depends on the specific tool
// This is a placeholder for tools that return operation IDs
if (initialResult.operationId) {
let completed = false;
let attempts = 0;
const maxAttempts = 60; // 1 minute with 1 second intervals
while (!completed && attempts < maxAttempts) {
await this.delay(1000);
// Check operation status (implementation specific)
// completed = await this.checkOperationStatus(initialResult.operationId);
attempts++;
}
}
}
/**
* Start health monitoring for a server
*/
startHealthMonitoring(serverName) {
// Clear existing interval if any
const existingInterval = this.healthCheckIntervals.get(serverName);
if (existingInterval) {
clearInterval(existingInterval);
}
const interval = setInterval(async () => {
await this.performHealthCheck(serverName);
}, 30000); // Check every 30 seconds
this.healthCheckIntervals.set(serverName, interval);
}
/**
* Perform health check for a server
*/
async performHealthCheck(serverName) {
const client = this.clients.get(serverName);
if (!client)
return;
const startTime = Date.now();
try {
// Use a simple tool call as a health check
await client.listTools();
const latency = Date.now() - startTime;
this.updateConnectionStatus(serverName, 'connected', undefined, latency);
}
catch (error) {
this.updateConnectionStatus(serverName, 'error', error);
// Attempt reconnection
const config = this.getServerConfig(serverName);
if (config) {
console.log(chalk.yellow(`⚠️ Attempting to reconnect to ${serverName}...`));
await this.connectSingle(config);
}
}
}
/**
* Update connection status
*/
updateConnectionStatus(server, status, error, latency) {
this.connectionStatus.set(server, {
server,
status,
lastPing: new Date(),
latency,
error: error?.message
});
}
/**
* Get server configuration (placeholder - should be stored during connect)
*/
getServerConfig(_serverName) {
// TODO: Store and retrieve server configs
return null;
}
/**
* Get all connection statuses
*/
getConnectionStatuses() {
return Array.from(this.connectionStatus.values());
}
/**
* Disconnect from all servers
*/
async disconnectAll() {
// Stop all health monitoring
this.healthCheckIntervals.forEach(interval => {
clearInterval(interval);
});
this.healthCheckIntervals.clear();
// Disconnect all clients
await Promise.all(Array.from(this.clients.entries()).map(async ([name, client]) => {
try {
await client.close();
console.log(chalk.gray(`Disconnected from ${name}`));
}
catch {
console.log(chalk.yellow(`Warning: Error disconnecting from ${name}`));
}
}));
this.clients.clear();
this.transports.clear();
this.connectionStatus.clear();
}
/**
* Disconnect from a specific server
*/
async disconnect(serverName) {
const interval = this.healthCheckIntervals.get(serverName);
if (interval) {
clearInterval(interval);
this.healthCheckIntervals.delete(serverName);
}
const client = this.clients.get(serverName);
if (client) {
await client.close();
this.clients.delete(serverName);
}
this.transports.delete(serverName);
this.connectionStatus.delete(serverName);
}
/**
* Utility delay function
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Export singleton instance
export const enhancedMCPClient = new EnhancedMCPClient();