UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

611 lines (540 loc) 24 kB
/** * Configuration Management System * * Comprehensive environment-based configuration system for the Converse MCP Server. * Loads, validates, and manages all configuration from environment variables only. * Follows functional architecture with explicit dependencies. */ import dotenv from 'dotenv'; import { createLogger, configureLogger } from './utils/logger.js'; import { ConfigurationError } from './utils/errorHandler.js'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { readFileSync } from 'fs'; // Load environment variables from appropriate .env file // Priority: .env.test (for test env) > .env (default) if (process.env.NODE_ENV === 'test') { // Load test environment first dotenv.config({ path: '.env.test', quiet: true }); // Fall back to .env for any missing variables dotenv.config({ override: false, quiet: true }); } else { // Load default .env file dotenv.config({ quiet: true }); } // Configure logger early configureLogger({ level: process.env.LOG_LEVEL || 'info', isDevelopment: process.env.NODE_ENV === 'development' }); const logger = createLogger('config'); /** * Configuration schema defining all supported environment variables */ const CONFIG_SCHEMA = { // Server configuration server: { PORT: { type: 'number', default: 3157, description: 'Server port' }, HOST: { type: 'string', default: 'localhost', description: 'Server host' }, NODE_ENV: { type: 'string', default: 'development', description: 'Environment mode' }, LOG_LEVEL: { type: 'string', default: 'info', description: 'Logging level' }, CLIENT_CWD: { type: 'string', default: null, description: 'Client working directory for relative paths' }, }, // Transport configuration transport: { MCP_TRANSPORT: { type: 'string', default: 'stdio', description: 'MCP transport type (stdio or http)' }, // HTTP server settings HTTP_PORT: { type: 'number', default: 3157, description: 'HTTP server port' }, HTTP_HOST: { type: 'string', default: 'localhost', description: 'HTTP server host' }, HTTP_REQUEST_TIMEOUT: { type: 'number', default: 300000, description: 'HTTP request timeout in milliseconds (5 minutes)' }, HTTP_MAX_REQUEST_SIZE: { type: 'string', default: '10mb', description: 'Maximum HTTP request body size' }, // Session management HTTP_SESSION_TIMEOUT: { type: 'number', default: 1800000, description: 'Session timeout in milliseconds (30 minutes)' }, HTTP_SESSION_CLEANUP_INTERVAL: { type: 'number', default: 300000, description: 'Session cleanup interval in milliseconds (5 minutes)' }, HTTP_MAX_CONCURRENT_SESSIONS: { type: 'number', default: 100, description: 'Maximum concurrent sessions' }, // CORS configuration HTTP_ENABLE_CORS: { type: 'boolean', default: true, description: 'Enable CORS for HTTP transport' }, HTTP_CORS_ORIGINS: { type: 'string', default: '*', description: 'CORS allowed origins (comma-separated)' }, HTTP_CORS_METHODS: { type: 'string', default: 'GET,POST,DELETE,OPTIONS', description: 'CORS allowed methods' }, HTTP_CORS_HEADERS: { type: 'string', default: 'Content-Type,mcp-session-id,Authorization', description: 'CORS allowed headers' }, HTTP_CORS_CREDENTIALS: { type: 'boolean', default: false, description: 'CORS allow credentials' }, // Security settings HTTP_DNS_REBINDING_PROTECTION: { type: 'boolean', default: false, description: 'Enable DNS rebinding protection' }, HTTP_ALLOWED_HOSTS: { type: 'string', default: '127.0.0.1,localhost', description: 'Allowed hosts for DNS rebinding protection (comma-separated)' }, HTTP_RATE_LIMIT_ENABLED: { type: 'boolean', default: false, description: 'Enable rate limiting' }, HTTP_RATE_LIMIT_WINDOW: { type: 'number', default: 900000, description: 'Rate limit window in milliseconds (15 minutes)' }, HTTP_RATE_LIMIT_MAX_REQUESTS: { type: 'number', default: 1000, description: 'Maximum requests per window' }, }, // API Keys (at least one required) apiKeys: { OPENAI_API_KEY: { type: 'string', required: false, secret: true, description: 'OpenAI API key' }, XAI_API_KEY: { type: 'string', required: false, secret: true, description: 'XAI API key' }, GOOGLE_API_KEY: { type: 'string', required: false, secret: true, description: 'Google API key' }, GEMINI_API_KEY: { type: 'string', required: false, secret: true, description: 'Gemini API key (alternative to GOOGLE_API_KEY)' }, ANTHROPIC_API_KEY: { type: 'string', required: false, secret: true, description: 'Anthropic API key' }, MISTRAL_API_KEY: { type: 'string', required: false, secret: true, description: 'Mistral API key' }, DEEPSEEK_API_KEY: { type: 'string', required: false, secret: true, description: 'DeepSeek API key' }, OPENROUTER_API_KEY: { type: 'string', required: false, secret: true, description: 'OpenRouter API key' }, }, // Provider-specific configuration providers: { OPENROUTER_REFERER: { type: 'string', required: false, description: 'OpenRouter referer header for compliance' }, OPENROUTER_TITLE: { type: 'string', required: false, description: 'OpenRouter X-Title header for request tracking' }, OPENROUTER_DYNAMIC_MODELS: { type: 'boolean', default: false, description: 'Enable dynamic model discovery via OpenRouter endpoints API' }, // Google Vertex AI configuration GOOGLE_GENAI_USE_VERTEXAI: { type: 'boolean', default: false, description: 'Use Google Vertex AI instead of Gemini Developer API' }, GOOGLE_CLOUD_PROJECT: { type: 'string', required: false, description: 'Google Cloud project ID for Vertex AI' }, GOOGLE_CLOUD_LOCATION: { type: 'string', required: false, description: 'Google Cloud location for Vertex AI (e.g., us-central1)' }, GOOGLE_API_VERSION: { type: 'string', default: 'v1beta', description: 'Google API version (v1, v1beta, v1alpha)' } }, // MCP configuration mcp: { MAX_MCP_OUTPUT_TOKENS: { type: 'number', default: 25000, description: 'Maximum tokens in MCP tool responses' }, }, // Summarization configuration summarization: { ENABLE_RESPONSE_SUMMARIZATION: { type: 'boolean', default: false, description: 'Enable AI-powered response summarization for async operations' }, SUMMARIZATION_MODEL: { type: 'string', default: 'gpt-5-nano', description: 'Model to use for summarization tasks (title generation, streaming summaries, final summaries)' }, }, }; // ConfigurationError now imported from errorHandler /** * Validates and parses environment variable value according to schema * @param {string} key - Environment variable key * @param {string|undefined} value - Environment variable value * @param {object} schema - Schema definition for the variable * @returns {any} Parsed and validated value */ function validateEnvVar(key, value, schema) { // Handle missing values if (value === undefined || value === '') { if (schema.required) { throw new ConfigurationError(`Required environment variable ${key} is missing`); } return schema.default; } // Type validation and conversion switch (schema.type) { case 'string': return value; case 'number': const num = parseInt(value, 10); if (isNaN(num)) { throw new ConfigurationError( `Environment variable ${key} must be a valid number, got: ${value}` ); } return num; case 'boolean': const lower = value.toLowerCase(); if (!['true', 'false', '1', '0', 'yes', 'no'].includes(lower)) { throw new ConfigurationError( `Environment variable ${key} must be a boolean value, got: ${value}` ); } return ['true', '1', 'yes'].includes(lower); default: return value; } } /** * Validates API key format and basic structure * @param {string} provider - Provider name * @param {string} apiKey - API key to validate * @returns {boolean} True if API key appears valid */ function validateApiKeyFormat(provider, apiKey) { if (!apiKey || typeof apiKey !== 'string') { return false; } // Basic format validation for each provider switch (provider) { case 'openai': return apiKey.startsWith('sk-') && apiKey.length > 20; case 'xai': return apiKey.startsWith('xai-') && apiKey.length > 20; case 'google': case 'gemini': // Special case for Vertex AI marker if (apiKey === 'VERTEX_AI') return true; return apiKey.length > 20; // Google/Gemini keys vary in format case 'anthropic': return apiKey.startsWith('sk-ant-') && apiKey.length >= 30; case 'mistral': return apiKey.length >= 32; // Mistral keys are typically 32+ chars case 'deepseek': return apiKey.length >= 32; // DeepSeek keys are typically 32+ chars case 'openrouter': return apiKey.startsWith('sk-or-') && apiKey.length >= 40; default: return apiKey.length >= 10; // Basic minimum length check } } /** * Loads and validates complete configuration from environment variables * @returns {Promise<object>} Validated configuration object * @throws {ConfigurationError} If configuration is invalid or incomplete */ export async function loadConfig() { const configLogger = logger.operation('loadConfig'); configLogger.debug('Starting configuration loading'); const config = { server: {}, transport: {}, apiKeys: {}, providers: {}, mcp: {}, summarization: {}, environment: { isDevelopment: false, isProduction: false, nodeEnv: '', }, }; const errors = []; try { // Load server configuration for (const [key, schema] of Object.entries(CONFIG_SCHEMA.server)) { try { // Special handling for CLIENT_CWD - auto-detect if not explicitly set if (key === 'CLIENT_CWD' && !process.env[key]) { // Try to detect the client's working directory from various sources // When run via npx, INIT_CWD contains the directory where npx was invoked // PWD is another common variable set to the working directory // npm_config_local_prefix is set when run via npm/npx const detectedCwd = process.env.INIT_CWD || process.env.PWD || process.env.npm_config_local_prefix || process.cwd(); config.server.client_cwd = detectedCwd; configLogger.debug(`Auto-detected client working directory: ${detectedCwd}`); } else { config.server[key.toLowerCase()] = validateEnvVar(key, process.env[key], schema); } } catch (error) { errors.push(error.message); } } // Load transport configuration for (const [key, schema] of Object.entries(CONFIG_SCHEMA.transport)) { try { const value = validateEnvVar(key, process.env[key], schema); if (key === 'MCP_TRANSPORT') { config.transport.mcptransport = value; } else if (key.startsWith('HTTP_')) { // Convert HTTP_PORT -> port, HTTP_CORS_ORIGINS -> corsorigins, etc. const configKey = key.replace('HTTP_', '').toLowerCase().replace(/_/g, ''); config.transport[configKey] = value; } } catch (error) { errors.push(error.message); } } // Load API keys for (const [key, schema] of Object.entries(CONFIG_SCHEMA.apiKeys)) { try { const value = validateEnvVar(key, process.env[key], schema); if (value) { const providerName = key.replace('_API_KEY', '').toLowerCase(); // Map GEMINI_API_KEY to google provider if (providerName === 'gemini') { // Only use GEMINI_API_KEY if GOOGLE_API_KEY is not already set if (!config.apiKeys.google) { config.apiKeys.google = value; } } else { config.apiKeys[providerName] = value; } } } catch (error) { errors.push(error.message); } } // Load provider-specific configuration config.providers = {}; for (const [key, schema] of Object.entries(CONFIG_SCHEMA.providers)) { try { const value = validateEnvVar(key, process.env[key], schema); if (value) { const configKey = key.toLowerCase().replace(/_/g, ''); config.providers[configKey] = value; } } catch (error) { errors.push(error.message); } } // Load MCP configuration for (const [key, schema] of Object.entries(CONFIG_SCHEMA.mcp)) { try { const value = validateEnvVar(key, process.env[key], schema); const configKey = key.replace('MAX_MCP_OUTPUT_TOKENS', 'max_mcp_output_tokens').toLowerCase(); config.mcp[configKey] = value; } catch (error) { errors.push(error.message); } } // Load Summarization configuration for (const [key, schema] of Object.entries(CONFIG_SCHEMA.summarization)) { try { const value = validateEnvVar(key, process.env[key], schema); if (key === 'ENABLE_RESPONSE_SUMMARIZATION') { config.summarization.enabled = value; } else if (key === 'SUMMARIZATION_MODEL') { config.summarization.model = value; } } catch (error) { errors.push(error.message); } } // Load name and version from package.json try { const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../package.json'); const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')); config.mcp.name = packageJson.name || 'converse-mcp-server'; config.mcp.version = packageJson.version || 'unknown'; } catch (error) { // Fallback values if package.json can't be read config.mcp.name = 'converse-mcp-server'; config.mcp.version = 'unknown'; configLogger.warn('Could not read package.json for name/version', { error: error.message }); } // Set environment flags const nodeEnv = config.server.node_env || 'development'; config.environment = { isDevelopment: nodeEnv === 'development', isProduction: nodeEnv === 'production', nodeEnv, }; // Validate that at least one API key is present OR Vertex AI is configured const availableKeys = Object.keys(config.apiKeys); const hasVertexAI = config.providers.googlegenaiusevertexai && config.providers.googlecloudproject && config.providers.googlecloudlocation; if (availableKeys.length === 0 && !hasVertexAI) { errors.push( 'At least one API key must be configured: OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY, DEEPSEEK_API_KEY, or OPENROUTER_API_KEY. Alternatively, configure Google Vertex AI with GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_CLOUD_PROJECT, and GOOGLE_CLOUD_LOCATION.' ); } // If Vertex AI is enabled, add it as a special google provider config if (hasVertexAI) { // Mark google as available even without API key when using Vertex AI if (!config.apiKeys.google) { config.apiKeys.google = 'VERTEX_AI'; // Special marker for Vertex AI mode } } // Validate API key formats for (const [provider, apiKey] of Object.entries(config.apiKeys)) { if (!validateApiKeyFormat(provider, apiKey)) { errors.push(`Invalid API key format for ${provider.toUpperCase()}_API_KEY`); } } // Throw accumulated errors if (errors.length > 0) { throw new ConfigurationError( `Configuration validation failed with ${errors.length} error(s):\n${errors.map(e => ` - ${e}`).join('\n')}`, { errors } ); } // Log configuration summary (without secrets) logConfigurationSummary(config); configLogger.info('Configuration loaded successfully'); return config; } catch (error) { configLogger.error('Configuration loading failed', { error }); if (error instanceof ConfigurationError) { throw error; } throw new ConfigurationError(`Failed to load configuration: ${error.message}`, { originalError: error }); } } /** * Gets HTTP transport configuration with proper structure * @param {object} config - Main configuration object * @returns {object} HTTP transport configuration */ export function getHttpTransportConfig(config) { const transport = config.transport; // Parse comma-separated values const corsOrigins = transport.corsorigins === '*' ? '*' : transport.corsorigins?.split(',').map(o => o.trim()) || ['*']; const corsMethods = transport.corsmethods?.split(',').map(m => m.trim()) || ['GET', 'POST', 'DELETE', 'OPTIONS']; const corsHeaders = transport.corsheaders?.split(',').map(h => h.trim()) || ['Content-Type', 'mcp-session-id', 'Authorization']; const allowedHosts = transport.allowedhosts?.split(',').map(h => h.trim()) || ['127.0.0.1', 'localhost']; return { // Server settings port: transport.port || 3157, host: transport.host || 'localhost', requestTimeout: transport.requesttimeout || 300000, maxRequestSize: transport.maxrequestsize || '10mb', // Session management sessionTimeout: transport.sessiontimeout || 1800000, sessionCleanupInterval: transport.sessioncleanupinterval || 300000, maxConcurrentSessions: transport.maxconcurrentsessions || 100, // CORS configuration enableCors: transport.enablecors !== false, corsOptions: { origin: corsOrigins, methods: corsMethods, allowedHeaders: corsHeaders, credentials: transport.corscredentials || false, exposedHeaders: ['Mcp-Session-Id'], }, // Security settings enableDnsRebindingProtection: transport.dnsrebindingprotection || false, allowedHosts, rateLimitEnabled: transport.ratelimitenabled || false, rateLimitWindow: transport.ratelimitwindow || 900000, rateLimitMaxRequests: transport.ratelimitmaxrequests || 1000, }; } /** * Gets configuration for a specific provider * @param {object} config - Main configuration object * @param {string} providerName - Name of the provider * @returns {object} Provider-specific configuration */ export function getProviderConfig(config, providerName) { const apiKey = config.apiKeys[providerName]; const providerConfig = {}; // Provider-specific configuration can be added here if needed return { apiKey, ...providerConfig, }; } /** * Checks if a provider is available (has valid API key) * @param {object} config - Main configuration object * @param {string} providerName - Name of the provider * @returns {boolean} True if provider is available */ export function isProviderAvailable(config, providerName) { const apiKey = config.apiKeys[providerName]; return apiKey && validateApiKeyFormat(providerName, apiKey); } /** * Gets list of available providers * @param {object} config - Main configuration object * @returns {string[]} Array of available provider names */ export function getAvailableProviders(config) { return Object.keys(config.apiKeys).filter(provider => isProviderAvailable(config, provider) ); } /** * Validates runtime configuration consistency * @param {object} config - Configuration object to validate * @returns {Promise<boolean>} True if configuration is valid * @throws {ConfigurationError} If configuration is invalid */ export async function validateRuntimeConfig(config) { try { // Validate server configuration if (config.server.port < 1 || config.server.port > 65535) { throw new ConfigurationError(`Invalid port number: ${config.server.port}`); } // Validate environment const validEnvs = ['development', 'production', 'test']; if (!validEnvs.includes(config.environment.nodeEnv)) { throw new ConfigurationError( `Invalid NODE_ENV: ${config.environment.nodeEnv}. Must be one of: ${validEnvs.join(', ')}` ); } // Validate log level const validLogLevels = ['silent', 'error', 'warn', 'info', 'debug']; if (!validLogLevels.includes(config.server.log_level)) { throw new ConfigurationError( `Invalid LOG_LEVEL: ${config.server.log_level}. Must be one of: ${validLogLevels.join(', ')}` ); } // Validate HTTP transport configuration if (config.transport.mcptransport === 'http') { const httpConfig = getHttpTransportConfig(config); // Validate HTTP port if (httpConfig.port < 1 || httpConfig.port > 65535) { throw new ConfigurationError(`Invalid HTTP_PORT: ${httpConfig.port}. Must be between 1 and 65535`); } // Validate timeouts if (httpConfig.requestTimeout < 1000) { throw new ConfigurationError(`Invalid HTTP_REQUEST_TIMEOUT: ${httpConfig.requestTimeout}. Must be at least 1000ms`); } if (httpConfig.sessionTimeout < 60000) { throw new ConfigurationError(`Invalid HTTP_SESSION_TIMEOUT: ${httpConfig.sessionTimeout}. Must be at least 60000ms (1 minute)`); } if (httpConfig.sessionCleanupInterval < 10000) { throw new ConfigurationError(`Invalid HTTP_SESSION_CLEANUP_INTERVAL: ${httpConfig.sessionCleanupInterval}. Must be at least 10000ms (10 seconds)`); } // Validate max concurrent sessions if (httpConfig.maxConcurrentSessions < 1 || httpConfig.maxConcurrentSessions > 10000) { throw new ConfigurationError(`Invalid HTTP_MAX_CONCURRENT_SESSIONS: ${httpConfig.maxConcurrentSessions}. Must be between 1 and 10000`); } // Validate rate limiting if (httpConfig.rateLimitEnabled) { if (httpConfig.rateLimitWindow < 1000) { throw new ConfigurationError(`Invalid HTTP_RATE_LIMIT_WINDOW: ${httpConfig.rateLimitWindow}. Must be at least 1000ms`); } if (httpConfig.rateLimitMaxRequests < 1) { throw new ConfigurationError(`Invalid HTTP_RATE_LIMIT_MAX_REQUESTS: ${httpConfig.rateLimitMaxRequests}. Must be at least 1`); } } } // Production-specific validations if (config.environment.isProduction) { // Require at least 2 providers in production for redundancy const availableProviders = getAvailableProviders(config); if (availableProviders.length < 1) { logger.warn('Only one provider configured in production environment'); } } return true; } catch (error) { if (error instanceof ConfigurationError) { throw error; } throw new ConfigurationError(`Runtime validation failed: ${error.message}`); } } /** * Logs configuration summary (masking sensitive information) * Only logs when NOT in MCP stdio mode to avoid interfering with JSON-RPC protocol * @param {object} config - Configuration object */ function logConfigurationSummary(config) { // Skip logging when running as MCP server to avoid interfering with JSON-RPC if (process.stdin.isTTY === false || process.env.NODE_ENV === 'test') { return; } const availableProviders = getAvailableProviders(config); // Log configuration summary logger.info('Configuration loaded successfully', { data: { environment: config.environment.nodeEnv, port: config.server.port, logLevel: config.server.log_level, availableProviders: availableProviders.join(', ') || 'none', mcpServer: `${config.mcp.name} v${config.mcp.version}`, apiKeys: Object.keys(config.apiKeys).map(key => { const value = config.apiKeys[key]; return `${key.toUpperCase()}: ${value ? `${value.substring(0, 8)}...` : 'not configured'}`; }).join(', ') } }); } /** * Creates MCP client configuration object * @param {object} config - Main configuration object * @returns {object} MCP client configuration */ export function getMcpClientConfig(config) { return { name: config.mcp.name, version: config.mcp.version, capabilities: { tools: {}, prompts: {}, resources: {}, }, environment: config.environment.nodeEnv, providers: getAvailableProviders(config), }; }