jay-code
Version:
Streamlined AI CLI orchestration engine with mathematical rigor and enterprise-grade reliability
246 lines (206 loc) • 6.42 kB
text/typescript
/**
* MCP Client for Model Context Protocol
*/
import { EventEmitter } from 'node:events';
import type { ITransport } from './transports/base.js';
import { logger } from '../core/logger.js';
import type { MCPRequest, MCPResponse, MCPNotification, MCPConfig } from '../utils/types.js';
import { RecoveryManager, RecoveryConfig } from './recovery/index.js';
export interface MCPClientConfig {
transport: ITransport;
timeout?: number;
enableRecovery?: boolean;
recoveryConfig?: RecoveryConfig;
mcpConfig?: MCPConfig;
}
export class MCPClient extends EventEmitter {
private transport: ITransport;
private timeout: number;
private connected = false;
private recoveryManager?: RecoveryManager;
private pendingRequests = new Map<
string,
{ resolve: Function; reject: Function; timer: NodeJS.Timeout }
>();
constructor(config: MCPClientConfig) {
super();
this.transport = config.transport;
this.timeout = config.timeout || 30000;
// Initialize recovery manager if enabled
if (config.enableRecovery) {
this.recoveryManager = new RecoveryManager(
this,
config.mcpConfig || {},
logger,
config.recoveryConfig,
);
this.setupRecoveryHandlers();
}
}
async connect(): Promise<void> {
try {
await this.transport.connect();
this.connected = true;
logger.info('MCP Client connected');
// Start recovery manager if enabled
if (this.recoveryManager) {
await this.recoveryManager.start();
}
this.emit('connected');
} catch (error) {
logger.error('Failed to connect MCP client', error);
this.connected = false;
// Trigger recovery if enabled
if (this.recoveryManager) {
await this.recoveryManager.forceRecovery();
}
throw error;
}
}
async disconnect(): Promise<void> {
if (this.connected) {
// Stop recovery manager first
if (this.recoveryManager) {
await this.recoveryManager.stop();
}
await this.transport.disconnect();
this.connected = false;
logger.info('MCP Client disconnected');
this.emit('disconnected');
}
}
async request(method: string, params?: unknown): Promise<unknown> {
const request: MCPRequest = {
jsonrpc: '2.0' as const,
method,
params,
id: Math.random().toString(36).slice(2),
};
// If recovery manager is enabled, let it handle the request
if (this.recoveryManager && !this.connected) {
await this.recoveryManager.handleRequest(request);
}
if (!this.connected) {
throw new Error('Client not connected');
}
// Create promise for tracking the request
const requestPromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(request.id!);
reject(new Error(`Request timeout: ${method}`));
}, this.timeout);
this.pendingRequests.set(request.id!, { resolve, reject, timer });
});
try {
const response = await this.transport.sendRequest(request);
// Clear pending request
const pending = this.pendingRequests.get(request.id!);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(request.id!);
}
if ('error' in response) {
throw new Error(response.error);
}
return response.result;
} catch (error) {
// Clear pending request on error
const pending = this.pendingRequests.get(request.id!);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(request.id!);
}
throw error;
}
}
async notify(method: string, params?: unknown): Promise<void> {
// Special handling for heartbeat notifications
if (method === 'heartbeat') {
// Always allow heartbeat notifications for recovery
const notification: MCPNotification = {
jsonrpc: '2.0' as const,
method,
params,
};
if (this.transport.sendNotification) {
await this.transport.sendNotification(notification);
}
return;
}
if (!this.connected) {
throw new Error('Client not connected');
}
const notification: MCPNotification = {
jsonrpc: '2.0' as const,
method,
params,
};
if (this.transport.sendNotification) {
await this.transport.sendNotification(notification);
} else {
throw new Error('Transport does not support notifications');
}
}
isConnected(): boolean {
return this.connected;
}
/**
* Get recovery status if recovery is enabled
*/
getRecoveryStatus() {
return this.recoveryManager?.getStatus();
}
/**
* Force a recovery attempt
*/
async forceRecovery(): Promise<boolean> {
if (!this.recoveryManager) {
throw new Error('Recovery not enabled');
}
return this.recoveryManager.forceRecovery();
}
private setupRecoveryHandlers(): void {
if (!this.recoveryManager) {
return;
}
// Handle recovery events
this.recoveryManager.on('recoveryStart', ({ trigger }) => {
logger.info('Recovery started', { trigger });
this.emit('recoveryStart', { trigger });
});
this.recoveryManager.on('recoveryComplete', ({ success, duration }) => {
if (success) {
logger.info('Recovery completed successfully', { duration });
this.connected = true;
this.emit('recoverySuccess', { duration });
} else {
logger.error('Recovery failed');
this.emit('recoveryFailed', { duration });
}
});
this.recoveryManager.on('fallbackActivated', (state) => {
logger.warn('Fallback mode activated', state);
this.emit('fallbackActivated', state);
});
this.recoveryManager.on('healthChange', (newStatus, oldStatus) => {
this.emit('healthChange', newStatus, oldStatus);
});
}
/**
* Cleanup resources
*/
async cleanup(): Promise<void> {
// Clear all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Client cleanup'));
}
this.pendingRequests.clear();
// Cleanup recovery manager
if (this.recoveryManager) {
await this.recoveryManager.cleanup();
}
// Disconnect if connected
await this.disconnect();
}
}