import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { ClientConfig, DEFAULT_CLIENT_CONFIG, ToolCallParams, ToolsListResponse } from '../types/client'; import { loadEnvConfig } from '../config/env'; import { logger } from '../utils/logger'; import { ClientError, toBaseError } from '../utils/error-utils'; import { healthCheckManager } from '../utils/health-check'; /** * Service for interacting with the MCP server */ export class ClientService { private client: Client; private transport?: StreamableHTTPClientTransport; private config: ClientConfig; private connected = false; /** * Initialize the MCP client service * @param config Optional client configuration */ constructor(config?: Partial) { const envConfig = loadEnvConfig(); // Merge passed config with defaults and environment this.config = { ...DEFAULT_CLIENT_CONFIG, serverUrl: envConfig.mcpServerUrl, connectionTimeout: envConfig.connectionTimeout, ...config }; // Create a new MCP client this.client = new Client( { name: 'aws-logs-mcp-client', version: '1.0.0', }, { capabilities: {}, } ); // Set up error handler this.client.onerror = (error) => { const clientError = new ClientError('MCP client error occurred', 'CLIENT_ERROR', error); logger.error('Client error:', { component: 'clientService', error: clientError.toJSON() }); }; // Register with health check manager healthCheckManager.registerClientService(this); } /** * Create a connection URL for the HTTP transport * @returns Formatted URL object */ private createConnectionUrl(): URL { return new URL(`${this.config.serverUrl}/mcp`); } /** * Create a promise that rejects after the specified timeout * @returns Promise that rejects after timeout */ private createTimeoutPromise(): Promise { return new Promise((_, reject) => { setTimeout(() => { reject(new Error(`Connection timed out after ${this.config.connectionTimeout}ms`)); }, this.config.connectionTimeout); }); } /** * Connect to the MCP server * @returns Promise that resolves when connected */ async connect(): Promise { try { logger.info('Connecting to MCP server...'); // Create and log the HTTP URL const mcpUrl = this.createConnectionUrl(); logger.info(`Using MCP URL: ${mcpUrl.toString()}`); // Create the StreamableHTTP transport this.transport = new StreamableHTTPClientTransport(mcpUrl, { // Optional configuration // reconnectionOptions: { // maxRetries: 3, // initialReconnectionDelay: 1000 // } }); // Connect with timeout using Promise.race await Promise.race([ this.client.connect(this.transport), this.createTimeoutPromise() ]); // Mark as connected if successful this.connected = true; logger.info('Client connected successfully to AWS Logs MCP server'); } catch (error) { // Reset connection state and transform error this.connected = false; const clientError = new ClientError( 'Failed to connect to MCP server', 'CONNECTION_ERROR', error ); logger.error('Connection failed:', clientError.toJSON()); throw clientError; } } /** * List available tools from the MCP server * @returns Promise that resolves with the list of tools */ async listTools(): Promise { this.ensureConnected(); logger.info('Listing available tools...'); const response = await this.client.listTools(); if (response && response.tools) { for (const tool of response.tools) { logger.info(`- ${tool.name}: ${tool.description}`); } } else { logger.warn('No tools found or unexpected response format:', response); } return response as ToolsListResponse; } /** * Format tool arguments for logging (hiding sensitive data) * @param args Tool arguments * @returns Safe-to-log version of arguments */ private formatToolArguments(args: Record): Record { // Create a copy of the arguments to avoid modifying the original const safeArgs = { ...args }; // List of potentially sensitive argument keys const sensitiveKeys = ['accessKey', 'secretKey', 'password', 'token', 'key']; // Mask sensitive values for (const key of Object.keys(safeArgs)) { if (sensitiveKeys.some(sensitiveKey => key.toLowerCase().includes(sensitiveKey))) { safeArgs[key] = '******'; } } return safeArgs; } /** * Call a tool on the MCP server * @param params Parameters for the tool call * @returns Promise that resolves with the tool response */ async callTool, R = unknown>(params: ToolCallParams): Promise { // Ensure we're connected before proceeding this.ensureConnected(); // Log tool call with safe arguments const safeArgs = this.formatToolArguments(params.arguments); logger.info(`Calling tool: ${params.name}`, safeArgs); try { // Make the actual tool call const result = await this.client.callTool({ name: params.name, arguments: params.arguments }); // Log result at debug level logger.debug('Raw tool result:', result); return result as R; } catch (error) { // Transform and log error const clientError = new ClientError( `Error calling tool ${params.name}`, 'TOOL_CALL_ERROR', error ); logger.error(`Error calling tool ${params.name}:`, clientError.toJSON()); throw clientError; } } /** * Disconnect from the MCP server */ async disconnect(): Promise { if (this.transport && typeof this.transport.close === 'function') { logger.info('Disconnecting from MCP server...'); await this.transport.close(); this.connected = false; logger.info('Disconnected from AWS Logs MCP server'); } } /** * Check if the client is connected * @returns True if connected */ isConnected(): boolean { return this.connected; } /** * Ensure the client is connected before performing operations * @throws Error if not connected */ private ensureConnected(): void { if (!this.connected) { throw new ClientError( 'Client is not connected to the MCP server. Call connect() first.', 'NOT_CONNECTED' ); } } }