UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

653 lines 28.3 kB
/** * Configuration management system for Optimizely MCP Server * @description Handles loading and validation of configuration from environment variables, * config files, and default values with proper error handling and validation */ import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { getLogger } from '../logging/Logger.js'; import { MCPErrorMapper } from '../errors/MCPErrorMapping.js'; // Get project root directory (two levels up from src/config/) const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); /** * Configuration validation error with detailed context */ export class ConfigValidationError extends Error { /** Field that failed validation */ field; /** Expected value format or type */ expected; /** Actual value that was provided */ actual; /** * Creates a new configuration validation error * @param field - The configuration field that failed validation * @param expected - Description of what was expected * @param actual - The actual value that was provided */ constructor(field, expected, actual) { super(`Configuration validation failed for '${field}': expected ${expected}, got ${typeof actual === 'string' ? `"${actual}"` : actual}`); this.name = 'ConfigValidationError'; this.field = field; this.expected = expected; this.actual = actual; } } /** * Configuration manager for loading and validating MCP server settings * @description Provides centralized configuration management with support for * environment variables, JSON config files, and runtime validation */ export class ConfigManager { config; configFilePath; /** * Creates a new ConfigManager instance * @param configFilePath - Optional path to JSON configuration file */ constructor(configFilePath) { this.configFilePath = configFilePath; this.config = this.getDefaultConfig(); } /** * Gets the default configuration with sensible defaults * @returns Default configuration object * @private */ getDefaultConfig() { // Debug logging for path issues if (!PROJECT_ROOT) { getLogger().error('CRITICAL: PROJECT_ROOT is undefined!'); getLogger().error('__dirname equivalent:', path.dirname(fileURLToPath(import.meta.url))); getLogger().error('Calculated PROJECT_ROOT:', path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')); } return { optimizely: { apiToken: '', // Must be provided projects: { allowedIds: [], // Must be configured allowedNames: [], maxProjects: 0, // 0 means unlimited autoDiscoverAll: false }, baseUrl: 'https://api.optimizely.com/v2', flagsUrl: 'https://api.optimizely.com/flags/v1', rateLimits: { requestsPerMinute: 60, requestsPerSecond: 10 }, retries: { maxAttempts: 3, baseDelay: 1000 } }, storage: { databasePath: './data/optimizely-cache.db', backupDir: './data/backups', verbose: false }, cache: { syncIntervalMinutes: 60, autoSync: false, maxCacheAgeHours: 24, changeHistory: { days: 1, maxRecords: 10000, disable: false } }, logging: { level: 'info', consoleLogging: false, // Safe default for MCP StdioServerTransport logFile: './logs/optimizely-mcp.log', prettyPrint: false, maxFileSize: 10 * 1024 * 1024, // 10MB maxFiles: 5 }, server: { name: 'optimizely-mcp-server', version: '1.0.0', maxConcurrency: 10 }, mcp: { transport: 'stdio', requestTimeoutMs: 30000, // 30 seconds debugMode: false, tools: { maxExecutionTimeMs: 120000, // 2 minutes logInputOutput: false, validateResponses: true }, resources: { maxContentSize: 10 * 1024 * 1024, // 10MB enableCaching: true, cacheTtlSeconds: 300 // 5 minutes }, errors: { includeStackTraces: false, includeErrorContext: true, logAllErrors: true } } }; } /** * Loads configuration from all available sources in priority order * @returns Promise that resolves when configuration is loaded and validated * @throws {ConfigValidationError} When required configuration is missing or invalid * @description Sources (in priority order): Environment variables, config file, defaults */ async loadConfig() { try { // Start with defaults this.config = this.getDefaultConfig(); // Load from config file if specified if (this.configFilePath) { await this.loadFromFile(); } // Override with environment variables (highest priority) this.loadFromEnvironment(); // Validate the final configuration this.validateConfig(); getLogger().info('Configuration loaded successfully'); } catch (error) { if (error instanceof ConfigValidationError) { throw error; } throw MCPErrorMapper.toMCPError(error, 'Failed to load configuration'); } } /** * Loads configuration from a JSON file * @private * @throws {Error} When file cannot be read or contains invalid JSON */ async loadFromFile() { if (!this.configFilePath) return; try { const fileContent = await fs.readFile(this.configFilePath, 'utf-8'); const fileConfig = JSON.parse(fileContent); // Deep merge with existing config this.config = this.deepMerge(this.config, fileConfig); getLogger().info({ configFile: this.configFilePath }, 'Configuration loaded from file'); } catch (error) { if (error.code === 'ENOENT') { getLogger().warn({ configFile: this.configFilePath }, 'Configuration file not found, using defaults'); } else { throw MCPErrorMapper.toMCPError(error, `Failed to load config file ${this.configFilePath}`); } } } /** * Loads configuration from environment variables * @private * @description Supports nested configuration via underscore-separated variable names */ loadFromEnvironment() { const env = process.env; // Optimizely configuration if (env.OPTIMIZELY_API_TOKEN) { this.config.optimizely.apiToken = env.OPTIMIZELY_API_TOKEN; } // Project filtering configuration if (env.OPTIMIZELY_PROJECT_IDS) { this.config.optimizely.projects.allowedIds = env.OPTIMIZELY_PROJECT_IDS .split(',') .map(id => id.trim()) .filter(id => id.length > 0); // Remove empty strings from trailing commas } if (env.OPTIMIZELY_PROJECT_NAMES) { this.config.optimizely.projects.allowedNames = env.OPTIMIZELY_PROJECT_NAMES .split(',') .map(name => name.trim()) .filter(name => name.length > 0); // Remove empty strings from trailing commas } if (env.OPTIMIZELY_MAX_PROJECTS) { this.config.optimizely.projects.maxProjects = parseInt(env.OPTIMIZELY_MAX_PROJECTS, 10); } if (env.OPTIMIZELY_AUTO_DISCOVER_ALL) { this.config.optimizely.projects.autoDiscoverAll = env.OPTIMIZELY_AUTO_DISCOVER_ALL.toLowerCase() === 'true'; } if (env.OPTIMIZELY_BASE_URL) { this.config.optimizely.baseUrl = env.OPTIMIZELY_BASE_URL; } if (env.OPTIMIZELY_FLAGS_URL) { this.config.optimizely.flagsUrl = env.OPTIMIZELY_FLAGS_URL; } if (env.OPTIMIZELY_REQUESTS_PER_MINUTE) { this.config.optimizely.rateLimits.requestsPerMinute = parseInt(env.OPTIMIZELY_REQUESTS_PER_MINUTE, 10); } if (env.OPTIMIZELY_REQUESTS_PER_SECOND) { this.config.optimizely.rateLimits.requestsPerSecond = parseInt(env.OPTIMIZELY_REQUESTS_PER_SECOND, 10); } if (env.OPTIMIZELY_RETRY_MAX_ATTEMPTS) { this.config.optimizely.retries.maxAttempts = parseInt(env.OPTIMIZELY_RETRY_MAX_ATTEMPTS, 10); } if (env.OPTIMIZELY_RETRY_BASE_DELAY) { this.config.optimizely.retries.baseDelay = parseInt(env.OPTIMIZELY_RETRY_BASE_DELAY, 10); } // Storage configuration if (env.STORAGE_DATABASE_PATH) { this.config.storage.databasePath = env.STORAGE_DATABASE_PATH; } if (env.STORAGE_BACKUP_DIR) { this.config.storage.backupDir = env.STORAGE_BACKUP_DIR; } if (env.STORAGE_VERBOSE) { this.config.storage.verbose = env.STORAGE_VERBOSE.toLowerCase() === 'true'; } // Cache configuration if (env.CACHE_SYNC_INTERVAL_MINUTES) { this.config.cache.syncIntervalMinutes = parseInt(env.CACHE_SYNC_INTERVAL_MINUTES, 10); getLogger().debug({ CACHE_SYNC_INTERVAL_MINUTES: env.CACHE_SYNC_INTERVAL_MINUTES, parsed: this.config.cache.syncIntervalMinutes }, 'Loaded CACHE_SYNC_INTERVAL_MINUTES'); } if (env.CACHE_AUTO_SYNC) { this.config.cache.autoSync = env.CACHE_AUTO_SYNC.toLowerCase() === 'true'; getLogger().debug({ CACHE_AUTO_SYNC: env.CACHE_AUTO_SYNC, parsed: this.config.cache.autoSync }, 'Loaded CACHE_AUTO_SYNC'); } if (env.CACHE_MAX_AGE_HOURS) { this.config.cache.maxCacheAgeHours = parseInt(env.CACHE_MAX_AGE_HOURS, 10); } // Change history configuration if (env.CHANGE_HISTORY_DAYS) { this.config.cache.changeHistory.days = parseInt(env.CHANGE_HISTORY_DAYS, 10); getLogger().debug({ CHANGE_HISTORY_DAYS: env.CHANGE_HISTORY_DAYS, parsed: this.config.cache.changeHistory.days }, 'Loaded CHANGE_HISTORY_DAYS'); } if (env.CHANGE_HISTORY_MAX_RECORDS) { this.config.cache.changeHistory.maxRecords = parseInt(env.CHANGE_HISTORY_MAX_RECORDS, 10); getLogger().debug({ CHANGE_HISTORY_MAX_RECORDS: env.CHANGE_HISTORY_MAX_RECORDS, parsed: this.config.cache.changeHistory.maxRecords }, 'Loaded CHANGE_HISTORY_MAX_RECORDS'); } if (env.CHANGE_HISTORY_DISABLE) { this.config.cache.changeHistory.disable = env.CHANGE_HISTORY_DISABLE.toLowerCase() === 'true'; getLogger().debug({ CHANGE_HISTORY_DISABLE: env.CHANGE_HISTORY_DISABLE, parsed: this.config.cache.changeHistory.disable }, 'Loaded CHANGE_HISTORY_DISABLE'); } // MCP-safe logging configuration if (env.OPTIMIZELY_MCP_LOG_LEVEL) { const level = env.OPTIMIZELY_MCP_LOG_LEVEL.toLowerCase(); if (['fatal', 'error', 'warn', 'info', 'debug', 'trace'].includes(level)) { this.config.logging.level = level; } } if (env.OPTIMIZELY_MCP_CONSOLE_LOGGING) { this.config.logging.consoleLogging = env.OPTIMIZELY_MCP_CONSOLE_LOGGING.toLowerCase() === 'true'; } if (env.OPTIMIZELY_MCP_LOG_FILE) { this.config.logging.logFile = env.OPTIMIZELY_MCP_LOG_FILE; } if (env.OPTIMIZELY_MCP_PRETTY_PRINT) { this.config.logging.prettyPrint = env.OPTIMIZELY_MCP_PRETTY_PRINT.toLowerCase() === 'true'; } if (env.OPTIMIZELY_MCP_MAX_FILE_SIZE) { this.config.logging.maxFileSize = parseInt(env.OPTIMIZELY_MCP_MAX_FILE_SIZE, 10); } if (env.OPTIMIZELY_MCP_MAX_FILES) { this.config.logging.maxFiles = parseInt(env.OPTIMIZELY_MCP_MAX_FILES, 10); } // Server configuration if (env.SERVER_NAME) { this.config.server.name = env.SERVER_NAME; } if (env.SERVER_VERSION) { this.config.server.version = env.SERVER_VERSION; } if (env.SERVER_MAX_CONCURRENCY) { this.config.server.maxConcurrency = parseInt(env.SERVER_MAX_CONCURRENCY, 10); } // MCP protocol configuration if (env.MCP_TRANSPORT) { const transport = env.MCP_TRANSPORT.toLowerCase(); if (['stdio', 'sse', 'websocket'].includes(transport)) { this.config.mcp.transport = transport; } } if (env.MCP_REQUEST_TIMEOUT_MS) { this.config.mcp.requestTimeoutMs = parseInt(env.MCP_REQUEST_TIMEOUT_MS, 10); } if (env.MCP_DEBUG_MODE) { this.config.mcp.debugMode = env.MCP_DEBUG_MODE.toLowerCase() === 'true'; } // MCP tools configuration if (env.MCP_TOOLS_MAX_EXECUTION_TIME_MS) { this.config.mcp.tools.maxExecutionTimeMs = parseInt(env.MCP_TOOLS_MAX_EXECUTION_TIME_MS, 10); } if (env.MCP_TOOLS_LOG_INPUT_OUTPUT) { this.config.mcp.tools.logInputOutput = env.MCP_TOOLS_LOG_INPUT_OUTPUT.toLowerCase() === 'true'; } if (env.MCP_TOOLS_VALIDATE_RESPONSES) { this.config.mcp.tools.validateResponses = env.MCP_TOOLS_VALIDATE_RESPONSES.toLowerCase() === 'true'; } // MCP resources configuration if (env.MCP_RESOURCES_MAX_CONTENT_SIZE) { this.config.mcp.resources.maxContentSize = parseInt(env.MCP_RESOURCES_MAX_CONTENT_SIZE, 10); } if (env.MCP_RESOURCES_ENABLE_CACHING) { this.config.mcp.resources.enableCaching = env.MCP_RESOURCES_ENABLE_CACHING.toLowerCase() === 'true'; } if (env.MCP_RESOURCES_CACHE_TTL_SECONDS) { this.config.mcp.resources.cacheTtlSeconds = parseInt(env.MCP_RESOURCES_CACHE_TTL_SECONDS, 10); } // MCP errors configuration if (env.MCP_ERRORS_INCLUDE_STACK_TRACES) { this.config.mcp.errors.includeStackTraces = env.MCP_ERRORS_INCLUDE_STACK_TRACES.toLowerCase() === 'true'; } if (env.MCP_ERRORS_INCLUDE_ERROR_CONTEXT) { this.config.mcp.errors.includeErrorContext = env.MCP_ERRORS_INCLUDE_ERROR_CONTEXT.toLowerCase() === 'true'; } if (env.MCP_ERRORS_LOG_ALL_ERRORS) { this.config.mcp.errors.logAllErrors = env.MCP_ERRORS_LOG_ALL_ERRORS.toLowerCase() === 'true'; } getLogger().debug('Environment variables processed'); } /** * Validates the loaded configuration for required fields and value constraints * @private * @throws {ConfigValidationError} When validation fails */ validateConfig() { // Required fields if (!this.config.optimizely.apiToken) { throw new ConfigValidationError('optimizely.apiToken', 'non-empty string (set OPTIMIZELY_API_TOKEN environment variable)', this.config.optimizely.apiToken); } // Project filtering validation - CRITICAL for preventing accidental full sync const projects = this.config.optimizely.projects; const hasProjectIds = projects.allowedIds && projects.allowedIds.length > 0; const hasProjectNames = projects.allowedNames && projects.allowedNames.length > 0; const autoDiscover = projects.autoDiscoverAll; if (!hasProjectIds && !hasProjectNames && !autoDiscover) { throw new ConfigValidationError('optimizely.projects', 'at least one of: allowedIds, allowedNames, or autoDiscoverAll=true (set OPTIMIZELY_PROJECT_IDS, OPTIMIZELY_PROJECT_NAMES, or OPTIMIZELY_AUTO_DISCOVER_ALL)', 'no project filtering configured'); } // Safety check for auto-discover if (autoDiscover && !hasProjectIds && !hasProjectNames) { getLogger().warn('Auto-discover mode enabled without project filtering - will sync ALL accessible projects'); } // Validate max projects limit (0 means unlimited) if (projects.maxProjects !== undefined && (projects.maxProjects < 0 || projects.maxProjects > 50)) { throw new ConfigValidationError('optimizely.projects.maxProjects', 'number between 0 and 50 (0 for unlimited)', projects.maxProjects); } // URL validation if (this.config.optimizely.baseUrl && !this.isValidUrl(this.config.optimizely.baseUrl)) { throw new ConfigValidationError('optimizely.baseUrl', 'valid URL', this.config.optimizely.baseUrl); } if (this.config.optimizely.flagsUrl && !this.isValidUrl(this.config.optimizely.flagsUrl)) { throw new ConfigValidationError('optimizely.flagsUrl', 'valid URL', this.config.optimizely.flagsUrl); } // Numeric validations if (this.config.optimizely.rateLimits?.requestsPerMinute !== undefined) { if (!Number.isInteger(this.config.optimizely.rateLimits.requestsPerMinute) || this.config.optimizely.rateLimits.requestsPerMinute <= 0) { throw new ConfigValidationError('optimizely.rateLimits.requestsPerMinute', 'positive integer', this.config.optimizely.rateLimits.requestsPerMinute); } } if (this.config.optimizely.rateLimits?.requestsPerSecond !== undefined) { if (!Number.isInteger(this.config.optimizely.rateLimits.requestsPerSecond) || this.config.optimizely.rateLimits.requestsPerSecond <= 0) { throw new ConfigValidationError('optimizely.rateLimits.requestsPerSecond', 'positive integer', this.config.optimizely.rateLimits.requestsPerSecond); } } // Path validations (basic check for string) if (this.config.storage.databasePath && typeof this.config.storage.databasePath !== 'string') { throw new ConfigValidationError('storage.databasePath', 'string path', this.config.storage.databasePath); } // MCP configuration validations if (this.config.mcp.requestTimeoutMs !== undefined) { if (!Number.isInteger(this.config.mcp.requestTimeoutMs) || this.config.mcp.requestTimeoutMs <= 0 || this.config.mcp.requestTimeoutMs > 300000) { // 5 minutes max throw new ConfigValidationError('mcp.requestTimeoutMs', 'positive integer between 1 and 300000 (5 minutes)', this.config.mcp.requestTimeoutMs); } } if (this.config.mcp.tools?.maxExecutionTimeMs !== undefined) { if (!Number.isInteger(this.config.mcp.tools.maxExecutionTimeMs) || this.config.mcp.tools.maxExecutionTimeMs <= 0 || this.config.mcp.tools.maxExecutionTimeMs > 600000) { // 10 minutes max throw new ConfigValidationError('mcp.tools.maxExecutionTimeMs', 'positive integer between 1 and 600000 (10 minutes)', this.config.mcp.tools.maxExecutionTimeMs); } } if (this.config.mcp.resources?.maxContentSize !== undefined) { if (!Number.isInteger(this.config.mcp.resources.maxContentSize) || this.config.mcp.resources.maxContentSize <= 0 || this.config.mcp.resources.maxContentSize > 100 * 1024 * 1024) { // 100MB max throw new ConfigValidationError('mcp.resources.maxContentSize', 'positive integer between 1 and 104857600 (100MB)', this.config.mcp.resources.maxContentSize); } } if (this.config.mcp.resources?.cacheTtlSeconds !== undefined) { if (!Number.isInteger(this.config.mcp.resources.cacheTtlSeconds) || this.config.mcp.resources.cacheTtlSeconds <= 0) { throw new ConfigValidationError('mcp.resources.cacheTtlSeconds', 'positive integer', this.config.mcp.resources.cacheTtlSeconds); } } getLogger().debug('Configuration validation completed successfully'); } /** * Validates if a string is a proper URL * @param urlString - The string to validate * @returns True if the string is a valid URL * @private */ isValidUrl(urlString) { try { new URL(urlString); return true; } catch { return false; } } /** * Deep merges two configuration objects * @param target - The target object to merge into * @param source - The source object to merge from * @returns The merged configuration object * @private */ deepMerge(target, source) { const result = { ...target }; for (const key in source) { if (source[key] !== null && typeof source[key] === 'object' && !Array.isArray(source[key])) { result[key] = this.deepMerge(target[key] || {}, source[key]); } else { result[key] = source[key]; } } return result; } /** * Gets the current configuration * @returns The loaded and validated configuration */ getConfig() { return this.config; } /** * Gets the Optimizely API configuration specifically * @returns Optimizely-specific configuration */ getOptimizelyConfig() { return this.config.optimizely; } /** * Gets the storage configuration specifically * @returns Storage-specific configuration */ getStorageConfig() { return this.config.storage; } /** * Gets the cache configuration specifically * @returns Cache-specific configuration */ getCacheConfig() { return this.config.cache; } /** * Gets the logging configuration specifically * @returns Logging-specific configuration */ getLoggingConfig() { return this.config.logging; } /** * Gets the server configuration specifically * @returns Server-specific configuration */ getServerConfig() { return this.config.server; } /** * Gets the MCP protocol configuration specifically * @returns MCP-specific configuration */ getMcpConfig() { return this.config.mcp; } /** * Saves the current configuration to a JSON file * @param filePath - Path where to save the configuration * @param includeSecrets - Whether to include sensitive data like API tokens * @returns Promise that resolves when file is written */ async saveConfigToFile(filePath, includeSecrets = false) { try { const configToSave = { ...this.config }; if (!includeSecrets) { // Remove sensitive information configToSave.optimizely.apiToken = '[REDACTED]'; } // Ensure directory exists await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(configToSave, null, 2), 'utf-8'); getLogger().info({ filePath }, 'Configuration saved'); } catch (error) { throw MCPErrorMapper.toMCPError(error, 'Failed to save configuration'); } } /** * Creates a sample configuration file with documentation * @param filePath - Path where to create the sample file * @returns Promise that resolves when sample file is created */ async createSampleConfig(filePath) { const sampleConfig = { "_comment": "Optimizely MCP Server Configuration File", "_note": "Environment variables take precedence over file settings", "optimizely": { "_comment": "Optimizely API settings", "apiToken": "YOUR_OPTIMIZELY_API_TOKEN_HERE", "baseUrl": "https://api.optimizely.com/v2", "flagsUrl": "https://api.optimizely.com/flags/v1", "rateLimits": { "requestsPerMinute": 60, "requestsPerSecond": 10 }, "retries": { "maxAttempts": 3, "baseDelay": 1000 } }, "storage": { "_comment": "Database and storage settings", "databasePath": "./data/optimizely-cache.db", "backupDir": "./data/backups", "verbose": false }, "cache": { "_comment": "Cache and synchronization settings", "syncIntervalMinutes": 60, "autoSync": false, "maxCacheAgeHours": 24 }, "logging": { "_comment": "Logging configuration", "level": "info", "console": true, "file": "./logs/mcp-server.log" }, "server": { "_comment": "MCP server settings", "name": "optimizely-mcp-server", "version": "1.0.0", "maxConcurrency": 10 }, "mcp": { "_comment": "MCP protocol specific configuration", "transport": "stdio", "requestTimeoutMs": 30000, "debugMode": false, "tools": { "_comment": "Tool execution settings", "maxExecutionTimeMs": 120000, "logInputOutput": false, "validateResponses": true }, "resources": { "_comment": "Resource access settings", "maxContentSize": 10485760, "enableCaching": true, "cacheTtlSeconds": 300 }, "errors": { "_comment": "Error handling settings", "includeStackTraces": false, "includeErrorContext": true, "logAllErrors": true } } }; try { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, JSON.stringify(sampleConfig, null, 2), 'utf-8'); getLogger().info({ filePath }, 'Sample configuration created'); } catch (error) { throw MCPErrorMapper.toMCPError(error, 'Failed to create sample configuration'); } } } // Create singleton instance for global use let configManagerInstance = null; /** * Gets the global configuration manager instance * @param configFilePath - Optional path to configuration file (used only on first call) * @returns The global ConfigManager instance * @description Implements singleton pattern for consistent configuration access */ export function getConfigManager(configFilePath) { if (!configManagerInstance) { configManagerInstance = new ConfigManager(configFilePath); } return configManagerInstance; } /** * Initializes the global configuration manager with environment-based config file detection * @returns Promise that resolves when configuration is loaded * @description Automatically detects config file from MCP_CONFIG_FILE environment variable */ export async function initializeConfig() { const configFilePath = process.env.MCP_CONFIG_FILE; const manager = getConfigManager(configFilePath); await manager.loadConfig(); return manager; } //# sourceMappingURL=ConfigManager.js.map