@lanonasis/cli
Version:
LanOnasis Enterprise CLI - Memory as a Service, API Key Management, and Infrastructure Orchestration
374 lines (373 loc) • 14.7 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 path from 'path';
import { EventSource } from 'eventsource';
import { fileURLToPath } from 'url';
import WebSocket from 'ws';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export class MCPClient {
client = null;
config;
isConnected = false;
sseConnection = null;
wsConnection = null;
constructor() {
this.config = new CLIConfig();
}
/**
* Connect to MCP server (local or remote)
*/
async connect(options = {}) {
try {
// Determine connection mode with priority to explicit mode option
const connectionMode = options.connectionMode ??
(options.useWebSocket ? 'websocket' :
options.useRemote ? 'remote' :
this.config.get('mcpConnectionMode') ??
this.config.get('mcpUseRemote') ? 'remote' : 'local');
let wsUrl;
let serverUrl;
let serverPath;
switch (connectionMode) {
case 'websocket':
// WebSocket connection mode for enterprise users
wsUrl = options.serverUrl ??
this.config.get('mcpWebSocketUrl') ??
'ws://localhost:8081/mcp/ws';
console.log(chalk.cyan(`Connecting to WebSocket MCP server at ${wsUrl}...`));
// Initialize WebSocket connection
await this.initializeWebSocket(wsUrl);
this.isConnected = true;
return true;
case 'remote':
// For remote MCP, we'll use the REST API with MCP-style interface
serverUrl = options.serverUrl ??
this.config.get('mcpServerUrl') ??
'https://api.lanonasis.com';
console.log(chalk.cyan(`Connecting to remote MCP server at ${serverUrl}...`));
// Initialize SSE connection for real-time updates
await this.initializeSSE(serverUrl);
this.isConnected = true;
return true;
case 'local':
default:
{
// Local MCP server connection
serverPath = options.serverPath ??
this.config.get('mcpServerPath') ??
path.join(__dirname, '../../../../onasis-gateway/mcp-server/server.js');
console.log(chalk.cyan(`Connecting to local MCP server at ${serverPath}...`));
const localTransport = new StdioClientTransport({
command: 'node',
args: [serverPath]
});
this.client = new Client({
name: '@lanonasis/cli',
version: '1.0.0'
}, {
capabilities: {}
});
await this.client.connect(localTransport);
}
this.isConnected = true;
console.log(chalk.green('✓ Connected to MCP server'));
return true;
}
}
catch (error) {
console.error(chalk.red('Failed to connect to MCP server:'), error);
this.isConnected = false;
return false;
}
}
/**
* Initialize SSE connection for real-time updates
*/
async initializeSSE(serverUrl) {
const sseUrl = `${serverUrl}/sse`;
const token = this.config.get('token');
if (token) {
// EventSource doesn't support headers directly, append token to URL
this.sseConnection = new EventSource(`${sseUrl}?token=${encodeURIComponent(token)}`);
this.sseConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log(chalk.blue('📡 Real-time update:'), data.type);
}
catch {
// Ignore parse errors
}
};
this.sseConnection.onerror = () => {
console.error(chalk.yellow('⚠️ SSE connection error (will retry)'));
};
}
}
/**
* Initialize WebSocket connection for enterprise MCP server
*/
async initializeWebSocket(wsUrl) {
const token = this.config.get('token');
if (!token) {
throw new Error('API key required for WebSocket mode. Set LANONASIS_API_KEY or login first.');
}
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: {
'Authorization': `Bearer ${token}`,
'X-API-Key': token
}
});
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());
console.log(chalk.blue('📡 MCP message:'), message.id, message.method || 'response');
}
catch (error) {
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
setTimeout(() => {
if (this.isConnected) {
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));
}
/**
* Disconnect from MCP server
*/
async disconnect() {
if (this.client) {
await this.client.close();
this.client = null;
}
if (this.sseConnection) {
this.sseConnection.close();
this.sseConnection = null;
}
this.isConnected = false;
}
/**
* 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.config.get('mcpUseRemote') ?? false;
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.get('apiUrl') ?? 'https://api.lanonasis.com';
const token = this.config.get('token');
if (!token) {
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: '/api/v1/memory',
transform: (args) => args
},
'memory_search_memories': {
method: 'POST',
endpoint: '/api/v1/memory/search',
transform: (args) => args
},
'memory_get_memory': {
method: 'GET',
endpoint: '/api/v1/memory/{id}',
transform: () => undefined
},
'memory_update_memory': {
method: 'PUT',
endpoint: '/api/v1/memory/{id}',
transform: (args) => {
const data = { ...args };
delete data.memory_id;
return data;
}
},
'memory_delete_memory': {
method: 'DELETE',
endpoint: '/api/v1/memory/{id}',
transform: () => undefined
},
'memory_list_memories': {
method: 'GET',
endpoint: '/api/v1/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;
if (endpoint.includes('{id}') && args.memory_id) {
// Ensure memory_id is treated as a string for replacement
endpoint = endpoint.replace('{id}', String(args.memory_id));
}
const response = await axios({
method: mapping.method,
url: `${apiUrl}${endpoint}`,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: mapping.transform ? mapping.transform(args) : undefined,
params: mapping.method === 'GET' ? args : undefined
});
return response.data;
}
catch (error) {
// Safely handle errors with type checking
const errorObj = error;
const errorMsg = errorObj.response?.data?.error ||
(errorObj.message ? 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.config.get('mcpUseRemote') ?? false;
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;
}
/**
* Get connection status details
*/
getConnectionStatus() {
const useRemote = this.config.get('mcpUseRemote') ?? false;
return {
connected: this.isConnected,
mode: useRemote ? 'remote' : 'local',
server: useRemote
? (this.config.get('mcpServerUrl') ?? 'https://api.lanonasis.com')
: (this.config.get('mcpServerPath') ?? 'local MCP server')
};
}
}
// Singleton instance
let mcpClientInstance = null;
export function getMCPClient() {
if (!mcpClientInstance) {
mcpClientInstance = new MCPClient();
}
return mcpClientInstance;
}