@lanonasis/cli
Version:
Professional CLI for LanOnasis Memory as a Service (MaaS) with MCP support, seamless inline editing, and enterprise-grade security
993 lines (992 loc) • 42.4 kB
JavaScript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import chalk from 'chalk';
import { CLIConfig } from './config.js';
import * as fs from 'fs';
import { EventSource } from 'eventsource';
import WebSocket from 'ws';
function getMemoryToolId(args) {
const id = typeof args.id === 'string' ? args.id.trim() : '';
if (id)
return id;
const legacyId = typeof args.memory_id === 'string' ? args.memory_id.trim() : '';
return legacyId || undefined;
}
export class MCPClient {
client = null;
config;
isConnected = false;
sseConnection = null;
wsConnection = null;
retryAttempts = 0;
maxRetries = 3;
healthCheckInterval = null;
healthCheckTimeout = null;
wsReconnectTimeout = null;
connectionStartTime = 0;
lastHealthCheck = null;
activeConnectionMode = 'local'; // Track actual connection mode
constructor() {
this.config = new CLIConfig();
}
/**
* Overrides the configuration directory used by the underlying CLI config.
* Useful for tests that need isolated config state.
*/
setConfigDirectory(configDir) {
this.config.setConfigDirectory(configDir);
}
/**
* Returns the current config file path. Primarily used for test introspection.
*/
getConfigPath() {
return this.config.getConfigPath();
}
/**
* Helper for tests to seed authentication tokens without accessing internals.
*/
async setTokenForTesting(token) {
await this.config.setToken(token);
}
/**
* Helper for tests to seed vendor keys without accessing internals.
*/
async setVendorKeyForTesting(vendorKey) {
await this.config.setVendorKey(vendorKey);
}
/**
* Initialize the MCP client configuration
*/
async init() {
await this.config.init();
}
/**
* Connect to MCP server with retry logic
*/
async connect(options = {}) {
this.retryAttempts = 0;
return this.connectWithRetry(options);
}
/**
* Persist successful connection mode and URLs to config for future use
*/
async persistConnectionState(mode, url) {
try {
// Save the successful connection mode as preference
this.config.set('mcpConnectionMode', mode);
this.config.set('mcpPreference', mode);
this.config.set('mcpUseRemote', mode === 'remote' || mode === 'websocket');
// Save the specific URL that worked
if (url) {
if (mode === 'websocket') {
this.config.set('mcpWebSocketUrl', url);
}
else if (mode === 'remote') {
this.config.set('mcpServerUrl', url);
}
else if (mode === 'local') {
this.config.set('mcpServerPath', url);
}
}
// Save to disk
await this.config.save();
}
catch (error) {
// Don't fail connection if persistence fails, just log
if (process.env.CLI_VERBOSE === 'true') {
console.warn('⚠️ Failed to persist connection state:', error);
}
}
}
/**
* Connect to MCP server with retry logic and exponential backoff
*/
async connectWithRetry(options = {}) {
try {
this.connectionStartTime = Date.now();
// Initialize config if not already done
await this.init();
// Validate authentication before attempting connection
await this.validateAuthBeforeConnect();
// Determine connection mode with clear precedence and safe defaults
// 1) explicit option
// 2) explicit flags
// 3) configured preference
// 4) default to 'websocket' (production-ready pm2 mcp-core)
const configuredMode = this.config.get('mcpConnectionMode');
const mcpPreference = this.config.get('mcpPreference');
const preferRemote = this.config.get('mcpUseRemote');
const connectionMode = options.connectionMode
?? (options.useWebSocket ? 'websocket' : undefined)
?? (options.useRemote ? 'remote' : undefined)
?? configuredMode
?? mcpPreference
?? (preferRemote ? 'remote' : 'websocket');
let wsUrl;
let serverUrl;
let serverPath;
switch (connectionMode) {
case 'websocket': {
// WebSocket connection mode for enterprise users
const wsUrlValue = options.serverUrl ??
this.config.get('mcpWebSocketUrl') ??
this.config.getMCPServerUrl() ??
'wss://mcp.lanonasis.com/ws';
wsUrl = wsUrlValue;
if (this.retryAttempts === 0) {
console.log(chalk.cyan(`Connecting to WebSocket MCP server at ${wsUrl}...`));
}
else {
console.log(chalk.yellow(`Retry ${this.retryAttempts}/${this.maxRetries}: Connecting to WebSocket MCP server...`));
}
// Initialize WebSocket connection
await this.initializeWebSocket(wsUrl);
this.isConnected = true;
this.activeConnectionMode = 'websocket';
this.retryAttempts = 0;
// Persist successful connection state
await this.persistConnectionState('websocket', wsUrl);
this.startHealthMonitoring();
return true;
}
case 'remote': {
// For remote MCP, we'll use the REST API with MCP-style interface
const serverUrlValue = options.serverUrl ??
this.config.get('mcpServerUrl') ??
this.config.getMCPRestUrl() ??
'https://mcp.lanonasis.com/api/v1';
serverUrl = serverUrlValue;
if (this.retryAttempts === 0) {
console.log(chalk.cyan(`Connecting to remote MCP server at ${serverUrl}...`));
}
else {
console.log(chalk.yellow(`Retry ${this.retryAttempts}/${this.maxRetries}: Connecting to remote MCP server...`));
}
// Verify remote health before establishing SSE
await this.checkRemoteHealth(serverUrl);
// Initialize SSE connection for real-time updates
await this.initializeSSE(serverUrl);
this.isConnected = true;
this.activeConnectionMode = 'remote';
this.retryAttempts = 0;
// Persist successful connection state
await this.persistConnectionState('remote', serverUrl);
this.startHealthMonitoring();
return true;
}
case 'local': {
// Local MCP server connection requires explicit path via option or config
serverPath = options.serverPath ?? this.config.get('mcpServerPath');
if (!serverPath) {
console.log(chalk.yellow('⚠️ No local MCP server path configured.'));
console.log(chalk.cyan('💡 Prefer using WebSocket mode (default). Or configure a local path via:'));
console.log(chalk.cyan(' lanonasis config set mcpServerPath /absolute/path/to/server.js'));
throw new Error('Local MCP server path not provided');
}
// Check if the server file exists
if (!fs.existsSync(serverPath)) {
console.log(chalk.yellow(`⚠️ Local MCP server not found at ${serverPath}`));
console.log(chalk.cyan('💡 For remote connection, use: lanonasis mcp connect --mode websocket --url wss://mcp.lanonasis.com/ws'));
throw new Error(`MCP server not found at ${serverPath}`);
}
if (this.retryAttempts === 0) {
console.log(chalk.cyan(`Connecting to local MCP server at ${serverPath}...`));
}
else {
console.log(chalk.yellow(`Retry ${this.retryAttempts}/${this.maxRetries}: Connecting to local MCP server...`));
}
// Allow passing extra args to local server (e.g., --stdio) via options or env/config
// Precedence: options.localArgs -> env.MCP_LOCAL_SERVER_ARGS -> config.mcpLocalArgs -> none
const envArgs = (process.env.MCP_LOCAL_SERVER_ARGS || '')
.split(' ')
.map(s => s.trim())
.filter(Boolean);
const configArgs = (this.config.get('mcpLocalArgs') || []);
const extraArgs = (options.localArgs && options.localArgs.length > 0)
? options.localArgs
: (envArgs.length > 0 ? envArgs : configArgs);
const args = [serverPath, ...extraArgs];
const localTransport = new StdioClientTransport({
command: 'node',
args
});
this.client = new Client({
name: '@lanonasis/cli',
version: '3.0.1'
});
await this.client.connect(localTransport);
this.isConnected = true;
this.activeConnectionMode = 'local';
this.retryAttempts = 0;
// Persist successful connection state
await this.persistConnectionState('local', serverPath);
console.log(chalk.green('✓ Connected to MCP server'));
this.startHealthMonitoring();
return true;
}
default: {
// Safety: if we reach default, fall back to remote (HTTP) rather than brittle local
const serverUrlValue = options.serverUrl
?? this.config.get('mcpServerUrl')
?? this.config.getMCPRestUrl()
?? 'https://mcp.lanonasis.com/api/v1';
serverUrl = serverUrlValue;
console.log(chalk.yellow(`Unknown connection mode '${String(connectionMode)}', falling back to remote at ${serverUrl}`));
await this.initializeSSE(serverUrl);
this.isConnected = true;
this.activeConnectionMode = 'remote';
this.retryAttempts = 0;
// Persist successful connection state
await this.persistConnectionState('remote', serverUrl);
this.startHealthMonitoring();
return true;
}
}
}
catch (error) {
return this.handleConnectionFailure(error, options);
}
}
/**
* Handle connection failures with retry logic and specific error messages
*/
async handleConnectionFailure(error, options) {
// Check if this is an authentication error (don't retry these)
if (this.isAuthenticationError(error)) {
const authMsg = error?.message ?? '';
console.error(chalk.red('Authentication failed:'), authMsg);
this.provideAuthenticationGuidance(error);
this.isConnected = false;
return false;
}
this.retryAttempts++;
if (this.retryAttempts > this.maxRetries) {
console.error(chalk.red(`Failed to connect after ${this.maxRetries + 1} attempts`));
this.provideNetworkTroubleshootingGuidance(error);
this.isConnected = false;
return false;
}
// For network errors, retry with exponential backoff
const delay = await this.exponentialBackoff(this.retryAttempts);
console.log(chalk.yellow(`Network error, retrying in ${delay}ms... (${this.retryAttempts}/${this.maxRetries})`));
const message = error?.message ?? String(error);
console.log(chalk.gray(`Error: ${message}`));
await new Promise(resolve => setTimeout(resolve, delay));
return this.connectWithRetry(options);
}
/**
* Check if error is authentication-related
*/
isAuthenticationError(error) {
const errorMessage = error?.message?.toLowerCase() || '';
return errorMessage.includes('authentication_required') ||
errorMessage.includes('authentication_invalid') ||
errorMessage.includes('unauthorized') ||
errorMessage.includes('invalid token') ||
errorMessage.includes('token is invalid') ||
errorMessage.includes('401') ||
errorMessage.includes('403') ||
((error.response?.status ?? 0) >= 401 &&
(error.response?.status ?? 0) <= 403);
}
/**
* Provide authentication-specific guidance
*/
provideAuthenticationGuidance(error) {
console.log(chalk.yellow('\n🔐 Authentication Issue Detected:'));
const msg = error?.message ?? '';
if (msg.includes('AUTHENTICATION_REQUIRED')) {
console.log(chalk.cyan('• No credentials found. Run: lanonasis auth login'));
console.log(chalk.cyan('• Or set a vendor key: lanonasis auth login --vendor-key <your-key>'));
}
else if (msg.includes('AUTHENTICATION_INVALID')) {
console.log(chalk.cyan('• Invalid credentials. Confirm the vendor key matches your dashboard value'));
console.log(chalk.cyan('• Try: lanonasis auth logout && lanonasis auth login'));
}
else if (msg.includes('expired')) {
console.log(chalk.cyan('• Token expired. Re-authenticate: lanonasis auth login'));
console.log(chalk.cyan('• Or refresh: lanonasis auth refresh (if available)'));
}
else {
console.log(chalk.cyan('• Check authentication status: lanonasis auth status'));
console.log(chalk.cyan('• Re-authenticate: lanonasis auth login'));
console.log(chalk.cyan('• Verify vendor key: lanonasis auth login --vendor-key <your-key>'));
}
}
/**
* Provide network troubleshooting guidance
*/
provideNetworkTroubleshootingGuidance(_error) {
console.log(chalk.yellow('\n🌐 Network Issue Detected:'));
const msg = _error?.message ?? '';
if (msg.includes('ECONNREFUSED') || msg.includes('connect ECONNREFUSED')) {
console.log(chalk.cyan('• Connection refused. Service may be down:'));
console.log(chalk.cyan(' - For remote: Check https://mcp.lanonasis.com/health'));
console.log(chalk.cyan(' - For WebSocket: Check wss://mcp.lanonasis.com/ws'));
console.log(chalk.cyan(' - For local: Install local MCP server'));
}
else if (msg.includes('timeout') || msg.includes('ETIMEDOUT')) {
console.log(chalk.cyan('• Connection timeout. Check network:'));
console.log(chalk.cyan(' - Verify internet connectivity'));
console.log(chalk.cyan(' - Check firewall settings'));
console.log(chalk.cyan(' - Try different connection mode: --mode remote'));
}
else if (msg.includes('ENOTFOUND') || msg.includes('getaddrinfo')) {
console.log(chalk.cyan('• DNS resolution failed:'));
console.log(chalk.cyan(' - Check DNS settings'));
console.log(chalk.cyan(' - Verify server URL is correct'));
console.log(chalk.cyan(' - Try using IP address instead of hostname'));
}
else if (msg.includes('certificate') || msg.includes('SSL') || msg.includes('TLS')) {
console.log(chalk.cyan('• SSL/TLS certificate issue:'));
console.log(chalk.cyan(' - Check system time and date'));
console.log(chalk.cyan(' - Update CA certificates'));
console.log(chalk.cyan(' - Try different connection mode'));
}
else {
console.log(chalk.cyan('• General network error:'));
console.log(chalk.cyan(' - Check server status'));
console.log(chalk.cyan(' - Verify network connectivity'));
console.log(chalk.cyan(' - Try: lanonasis mcp diagnose (when available)'));
}
}
/**
* Calculate exponential backoff delay with jitter
*/
async exponentialBackoff(attempt) {
// Base delay of 1 second, exponentially increasing
const baseDelay = 1000;
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
// Cap at 10 seconds maximum
const cappedDelay = Math.min(exponentialDelay, 10000);
// Add jitter (±25% randomization) to avoid thundering herd
const jitter = cappedDelay * 0.25 * (Math.random() - 0.5);
return Math.round(cappedDelay + jitter);
}
async resolveAuthCredential() {
const authMethod = String(this.config.get('authMethod') || '').toLowerCase();
const token = this.config.get('token');
const vendorKey = await this.config.getVendorKeyAsync();
const isLikelyHashedCredential = typeof vendorKey === 'string' && /^[a-f0-9]{64}$/i.test(vendorKey.trim());
if (authMethod === 'vendor_key' && isLikelyHashedCredential) {
throw new Error('AUTHENTICATION_INVALID: Stored vendor key is in legacy hashed format. Run "lanonasis auth login --vendor-key <your-key>" to refresh secure storage.');
}
if (authMethod === 'vendor_key' && typeof vendorKey === 'string' && vendorKey.trim().length > 0) {
return { value: vendorKey.trim(), source: 'vendor_key' };
}
if ((authMethod === 'oauth' || authMethod === 'oauth2' || authMethod === 'jwt') &&
typeof token === 'string' &&
token.trim().length > 0) {
return { value: token.trim(), source: 'token' };
}
if (typeof token === 'string' && token.trim().length > 0) {
return { value: token.trim(), source: 'token' };
}
if (typeof vendorKey === 'string' && vendorKey.trim().length > 0) {
return { value: vendorKey.trim(), source: 'vendor_key' };
}
const envKey = process.env.LANONASIS_API_KEY;
if (typeof envKey === 'string' && envKey.trim().length > 0) {
return { value: envKey.trim(), source: 'env' };
}
return null;
}
buildAuthHeaders(auth) {
const headers = {};
const value = auth.value.trim();
if (!value) {
return headers;
}
if (auth.source === 'vendor_key' || value.startsWith('lano_') || value.startsWith('lms_')) {
headers['X-API-Key'] = value;
headers['X-Auth-Method'] = 'vendor_key';
headers['X-Project-Scope'] = 'lanonasis-maas';
return headers;
}
if (value.toLowerCase().startsWith('bearer ')) {
headers['Authorization'] = value;
headers['X-Auth-Method'] = 'jwt';
headers['X-Project-Scope'] = 'lanonasis-maas';
return headers;
}
headers['Authorization'] = `Bearer ${value}`;
headers['X-Auth-Method'] = 'jwt';
headers['X-Project-Scope'] = 'lanonasis-maas';
return headers;
}
/**
* Validate authentication credentials before attempting MCP connection
*/
async validateAuthBeforeConnect() {
const auth = await this.resolveAuthCredential();
if (!auth) {
throw new Error('AUTHENTICATION_REQUIRED: No authentication credentials found. Run "lanonasis auth login" first.');
}
await this.config.refreshTokenIfNeeded();
const verification = await this.config.verifyCurrentCredentialsWithServer();
if (!verification.valid) {
const reason = verification.reason || 'Credential verification failed';
if (verification.method === 'none') {
throw new Error(`AUTHENTICATION_REQUIRED: ${reason}`);
}
throw new Error(`AUTHENTICATION_INVALID: ${reason}`);
}
}
/**
* Validate and refresh token if needed
*/
async validateAndRefreshToken(token) {
try {
// Try to decode the JWT token to check expiration
const tokenParts = token.split('.');
if (tokenParts.length === 3) {
const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString());
const currentTime = Math.floor(Date.now() / 1000);
// Check if token is expired or expires within 5 minutes
if (payload.exp && payload.exp < currentTime + 300) {
console.log(chalk.yellow('Token is expired or expiring soon, attempting refresh...'));
await this.refreshTokenIfNeeded();
}
}
}
catch {
// If we can't decode the token, try to validate it with the server
await this.validateTokenWithServer(token);
}
}
/**
* Refresh token if needed
*/
async refreshTokenIfNeeded() {
try {
const previousToken = this.config.getToken();
await this.config.refreshTokenIfNeeded();
const currentToken = this.config.getToken();
if (currentToken && currentToken !== previousToken) {
console.log(chalk.green('✓ Token refreshed successfully'));
}
}
catch {
throw new Error('Failed to refresh token. Please re-authenticate.');
}
}
/**
* Validate token with server
*/
async validateTokenWithServer(token) {
try {
const axios = (await import('axios')).default;
const authUrl = this.config.get('authUrl') ?? 'https://api.lanonasis.com';
await axios.get(`${authUrl}/auth/validate`, {
headers: {
'Authorization': `Bearer ${token}`,
'x-api-key': String(token)
},
timeout: 10000
});
}
catch (error) {
const status = error.response?.status;
if (status === 401 || status === 403) {
throw new Error('Token is invalid or expired. Please re-authenticate.');
}
const msg = error?.message || 'Unknown error';
throw new Error(`Token validation failed: ${msg}`);
}
}
/**
* Initialize SSE connection for real-time updates
*/
async initializeSSE(serverUrl) {
// Use the proper SSE endpoint from config
const sseUrl = this.config.getMCPSSEUrl() ?? `${serverUrl}/events`;
const auth = await this.resolveAuthCredential();
if (auth) {
// EventSource doesn't support headers directly, append token to URL
this.sseConnection = new EventSource(`${sseUrl}?token=${encodeURIComponent(auth.value)}`);
this.sseConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Only show SSE updates in verbose mode to avoid interfering with interactive prompts
if (process.env.CLI_VERBOSE === 'true') {
console.log(chalk.blue('📡 Real-time update:'), data.type);
}
}
catch {
// Ignore parse errors
}
};
this.sseConnection.onerror = () => {
if (process.env.CLI_VERBOSE === 'true' || process.env.NODE_ENV === 'test') {
console.error(chalk.yellow('⚠️ SSE connection error (stream disabled for this session)'));
}
this.sseConnection?.close();
this.sseConnection = null;
};
}
}
/**
* Initialize WebSocket connection for enterprise MCP server
*/
async initializeWebSocket(wsUrl) {
const auth = await this.resolveAuthCredential();
if (!auth) {
throw new Error('API key required for WebSocket mode. Set LANONASIS_API_KEY or login first.');
}
const wsHeaders = this.buildAuthHeaders(auth);
return new Promise((resolve, reject) => {
try {
// Close existing connection if any
if (this.wsConnection) {
this.wsConnection.close();
this.wsConnection = null;
}
// Create new WebSocket connection with authentication
this.wsConnection = new WebSocket(wsUrl, [], {
headers: wsHeaders
});
this.wsConnection.on('open', () => {
console.log(chalk.green('✅ Connected to MCP WebSocket server'));
// Send initialization message
this.sendWebSocketMessage({
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {
tools: ['memory_management', 'workflow_orchestration']
},
clientInfo: {
name: '@lanonasis/cli',
version: '1.1.0'
}
}
});
resolve();
});
this.wsConnection.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
// Only show WebSocket messages in verbose mode to avoid interfering with interactive prompts
if (process.env.CLI_VERBOSE === 'true') {
const messageId = message.id ?? 'event';
const messageType = message.method
|| (message.error ? 'error' : undefined)
|| (message.result ? 'result' : undefined)
|| 'response';
console.log(chalk.blue('📡 MCP message:'), messageId, messageType);
}
}
catch (error) {
if (process.env.CLI_VERBOSE === 'true') {
console.error('Failed to parse WebSocket message:', error);
}
}
});
this.wsConnection.on('error', (error) => {
console.error(chalk.red('WebSocket error:'), error);
reject(error);
});
this.wsConnection.on('close', (code, reason) => {
console.log(chalk.yellow(`WebSocket connection closed (${code}): ${reason}`));
// Auto-reconnect after delay
if (this.wsReconnectTimeout) {
clearTimeout(this.wsReconnectTimeout);
}
this.wsReconnectTimeout = setTimeout(() => {
if (this.isConnected && process.env.NODE_ENV !== 'test') {
console.log(chalk.blue('🔄 Attempting to reconnect to WebSocket...'));
this.initializeWebSocket(wsUrl).catch(err => {
console.error('Failed to reconnect:', err);
});
}
}, 5000);
});
}
catch (error) {
reject(error);
}
});
}
/**
* Send a message over the WebSocket connection
*/
sendWebSocketMessage(message) {
if (!this.wsConnection) {
throw new Error('WebSocket not connected');
}
this.wsConnection.send(JSON.stringify(message));
}
/**
* Start health monitoring for the connection
*/
startHealthMonitoring() {
// Clear any existing health check interval
this.stopHealthMonitoring();
// Start health monitoring every 30 seconds
this.healthCheckInterval = setInterval(async () => {
await this.performHealthCheck();
}, 30000);
// Perform initial health check
const initialDelay = process.env.NODE_ENV === 'test' ? 50 : 5000;
this.healthCheckTimeout = setTimeout(() => this.performHealthCheck(), initialDelay);
}
/**
* Stop health monitoring
*/
stopHealthMonitoring() {
if (this.healthCheckInterval) {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
if (this.healthCheckTimeout) {
clearTimeout(this.healthCheckTimeout);
this.healthCheckTimeout = null;
}
}
/**
* Perform a health check on the current connection
*/
async performHealthCheck() {
if (!this.isConnected) {
return;
}
try {
this.lastHealthCheck = new Date();
const connectionMode = this.activeConnectionMode || 'remote';
switch (connectionMode) {
case 'websocket':
await this.checkWebSocketHealth();
break;
case 'remote':
await this.checkRemoteHealth();
break;
default:
await this.checkLocalHealth();
break;
}
}
catch {
const connectionMode = this.activeConnectionMode || 'remote';
console.log(chalk.yellow(`⚠️ ${connectionMode} connection health check failed, attempting reconnection...`));
await this.handleHealthCheckFailure();
}
}
/**
* Check WebSocket connection health
*/
async checkWebSocketHealth() {
if (!this.wsConnection || this.wsConnection.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket connection not open');
}
// Send a ping message to check connectivity
this.sendWebSocketMessage({
id: Date.now(),
method: 'ping',
params: {}
});
}
/**
* Check remote connection health
*/
async checkRemoteHealth(serverUrl) {
const apiUrl = serverUrl ?? this.config.getMCPRestUrl() ?? 'https://mcp.lanonasis.com/api/v1';
const auth = await this.resolveAuthCredential();
if (!auth) {
throw new Error('No authentication token available');
}
try {
const axios = (await import('axios')).default;
await axios.get(`${apiUrl}/health`, {
headers: this.buildAuthHeaders(auth),
timeout: 5000
});
}
catch (e) {
const msg = e?.message ?? String(e);
throw new Error(`Remote health check failed: ${msg}`);
}
}
/**
* Check local connection health
*/
async checkLocalHealth() {
if (!this.client) {
throw new Error('Local MCP client not initialized');
}
// Try to list tools as a health check
try {
await this.client.listTools();
}
catch (e) {
const msg = e?.message ?? String(e);
throw new Error(`Local health check failed: ${msg}`);
}
}
/**
* Handle health check failure by attempting reconnection
*/
async handleHealthCheckFailure() {
this.isConnected = false;
this.stopHealthMonitoring();
// Attempt to reconnect with current configuration
const connectionMode = (this.activeConnectionMode || 'remote');
const options = {
connectionMode
};
console.log(chalk.yellow(`↻ Attempting reconnection using ${connectionMode} mode...`));
// Add specific URLs if available
if (connectionMode === 'websocket') {
options.serverUrl = this.config.get('mcpWebSocketUrl');
}
else if (connectionMode === 'remote') {
options.serverUrl = this.config.get('mcpServerUrl');
}
else {
options.serverPath = this.config.get('mcpServerPath');
}
// Attempt reconnection
const reconnected = await this.connect(options);
if (reconnected) {
console.log(chalk.green('✓ Reconnected to MCP server'));
}
else {
console.log(chalk.red('✗ Failed to reconnect to MCP server'));
}
}
/**
* Disconnect from MCP server
*/
async disconnect() {
this.stopHealthMonitoring();
this.isConnected = false;
if (this.client) {
await this.client.close();
this.client = null;
}
if (this.sseConnection) {
this.sseConnection.onmessage = null;
this.sseConnection.onerror = null;
this.sseConnection.close();
this.sseConnection = null;
}
if (this.wsConnection) {
this.wsConnection.close();
this.wsConnection = null;
}
if (this.wsReconnectTimeout) {
clearTimeout(this.wsReconnectTimeout);
this.wsReconnectTimeout = null;
}
this.activeConnectionMode = 'websocket'; // Reset to default
}
/**
* Call an MCP tool
*/
async callTool(toolName, args) {
if (!this.isConnected) {
throw new Error('Not connected to MCP server. Run "lanonasis mcp connect" first.');
}
const useRemote = this.shouldUseRemoteToolBridge();
if (useRemote) {
// Remote MCP calls are translated to REST API calls
return await this.callRemoteTool(toolName, args);
}
else {
// Local MCP server call
if (!this.client) {
throw new Error('MCP client not initialized');
}
try {
const result = await this.client.callTool({
name: toolName,
arguments: args
});
// Convert the SDK result to our expected MCPToolResponse format
return {
result: result,
code: 200,
message: 'Success'
};
}
catch (error) {
throw new Error(`MCP tool call failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}
/**
* Call remote tool via REST API with MCP interface
*/
async callRemoteTool(toolName, args) {
const apiUrl = this.config.getMCPRestUrl() ?? 'https://mcp.lanonasis.com/api/v1';
const auth = await this.resolveAuthCredential();
if (!auth) {
throw new Error('Authentication required. Run "lanonasis auth login" first.');
}
// Map MCP tool names to REST API endpoints
const toolMappings = {
'memory_create_memory': {
method: 'POST',
endpoint: '/memory',
transform: (args) => args
},
'memory_search_memories': {
method: 'POST',
endpoint: '/memory/search',
transform: (args) => args
},
'memory_get_memory': {
method: 'GET',
endpoint: '/memory/{id}',
transform: () => undefined
},
'memory_update_memory': {
method: 'PUT',
endpoint: '/memory/{id}',
transform: (args) => {
const data = { ...args };
delete data.id;
delete data.memory_id;
return data;
}
},
'memory_delete_memory': {
method: 'DELETE',
endpoint: '/memory/{id}',
transform: () => undefined
},
'memory_list_memories': {
method: 'GET',
endpoint: '/memory',
transform: (args) => args
}
};
const mapping = toolMappings[toolName];
if (!mapping) {
throw new Error(`Unknown tool: ${toolName}`);
}
try {
const axios = (await import('axios')).default;
// Handle dynamic endpoint for memory operations that need ID
let endpoint = mapping.endpoint;
const memoryId = getMemoryToolId(args);
if (endpoint.includes('{id}') && memoryId) {
endpoint = endpoint.replace('{id}', encodeURIComponent(memoryId));
}
const response = await axios({
method: mapping.method,
url: `${apiUrl}${endpoint}`,
headers: {
...this.buildAuthHeaders(auth),
'Content-Type': 'application/json'
},
data: mapping.transform ? mapping.transform(args) : undefined,
params: mapping.method === 'GET' && !mapping.endpoint.includes('{id}') ? args : undefined
});
return response.data;
}
catch (error) {
// Safely handle errors with type checking
const errorObj = error;
const errorMsg = errorObj.response?.data?.error || (errorObj.message ?? 'Unknown error');
throw new Error(`Remote tool call failed: ${errorMsg}`);
}
}
/**
* List available tools
*/
async listTools() {
if (!this.isConnected) {
throw new Error('Not connected to MCP server');
}
const useRemote = this.shouldUseRemoteToolBridge();
if (useRemote) {
// Return hardcoded list for remote mode
return [
{ name: 'memory_create_memory', description: 'Create a new memory entry' },
{ name: 'memory_search_memories', description: 'Search memories using semantic search' },
{ name: 'memory_get_memory', description: 'Get a specific memory by ID' },
{ name: 'memory_update_memory', description: 'Update an existing memory' },
{ name: 'memory_delete_memory', description: 'Delete a memory' },
{ name: 'memory_list_memories', description: 'List all memories with pagination' }
];
}
else {
if (!this.client) {
throw new Error('MCP client not initialized');
}
const tools = await this.client.listTools();
return tools.tools.map(tool => ({
name: tool.name,
description: tool.description || 'No description available'
}));
}
}
/**
* Check if connected to MCP server
*/
isConnectedToServer() {
return this.isConnected;
}
/**
* Determine whether tool operations should use the remote REST bridge.
* WebSocket mode uses the same bridge for tool list/call operations.
*/
shouldUseRemoteToolBridge() {
if (this.activeConnectionMode === 'remote' || this.activeConnectionMode === 'websocket') {
return true;
}
return this.config.get('mcpUseRemote') ?? false;
}
/**
* Get connection status details with health information
*/
getConnectionStatus() {
// When disconnected, show the configured preference instead of the stale activeConnectionMode
let connectionMode = this.activeConnectionMode;
if (!this.isConnected) {
// Check configured preference
const mcpPreference = this.config.get('mcpPreference');
const mcpConnectionMode = this.config.get('mcpConnectionMode');
const preferRemote = this.config.get('mcpUseRemote');
connectionMode = mcpConnectionMode
?? mcpPreference
?? (preferRemote ? 'remote' : 'websocket');
// If preference is 'auto', resolve to default (websocket)
if (connectionMode === 'auto') {
connectionMode = 'websocket';
}
}
let server;
switch (connectionMode) {
case 'websocket':
server = this.config.get('mcpWebSocketUrl') ?? 'wss://mcp.lanonasis.com/ws';
break;
case 'remote':
server = this.config.get('mcpServerUrl') ?? 'https://mcp.lanonasis.com/api/v1';
break;
default:
server = this.config.get('mcpServerPath') ?? 'local MCP server';
break;
}
const connectionUptime = this.connectionStartTime > 0
? Math.max(Date.now() - this.connectionStartTime, this.isConnected ? 1 : 0)
: undefined;
return {
connected: this.isConnected,
mode: connectionMode,
server,
lastHealthCheck: this.lastHealthCheck ?? undefined,
connectionUptime,
failureCount: this.retryAttempts
};
}
}
// Singleton instance
let mcpClientInstance = null;
export function getMCPClient() {
if (!mcpClientInstance) {
mcpClientInstance = new MCPClient();
}
return mcpClientInstance;
}