UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

1,047 lines (1,044 loc) • 209 kB
import fetch from 'node-fetch'; import { API_CONFIG } from '../config/apiConfig.js'; // Ensure .js for ES Modules import { getLogger } from '../logging/Logger.js'; import { safeLogObject } from '../logging/LoggingUtils.js'; /** * Rate limiter implementation based on actual Optimizely API limits * @description Manages request rate limiting using sliding window approach to prevent * API quota violations. Implements both per-minute and per-second limits as required * by Optimizely's API documentation. * @private */ class RateLimiter { /** Array of timestamps for requests within the current minute */ minuteRequests = []; /** Array of timestamps for requests within the current second */ secondRequests = []; /** Maximum requests allowed per minute */ minuteLimit; /** Maximum requests allowed per second */ secondLimit; /** * Creates a new rate limiter instance * @param requestsPerMinute - Maximum requests allowed per minute (default: 60) * @param requestsPerSecond - Maximum requests allowed per second (default: 10) * @description Uses Optimizely's standard rate limits by default, but can be customized * for different API endpoints or account-specific limits */ constructor(requestsPerMinute = API_CONFIG.rateLimits.optimizelyAPI.requestsPerMinute, requestsPerSecond = API_CONFIG.rateLimits.featureExperimentationAPI.requestsPerSecond) { this.minuteLimit = requestsPerMinute; this.secondLimit = requestsPerSecond; } /** * Waits if rate limits would be exceeded, then records the request * @returns Promise that resolves when it's safe to make a request * @description Implements exponential backoff and sliding window rate limiting. * Automatically waits if making a request would exceed either per-minute or per-second limits. * Uses a 100ms buffer to account for timing precision and avoid edge cases. */ async waitIfNeeded() { const now = Date.now(); this.minuteRequests = this.minuteRequests.filter(time => now - time < 60000); this.secondRequests = this.secondRequests.filter(time => now - time < 1000); if (this.minuteRequests.length >= this.minuteLimit) { const waitTime = 60000 - (now - this.minuteRequests[0]); // console.debug(`RateLimiter: Minute limit reached. Waiting ${waitTime + 100}ms`); await new Promise(resolve => setTimeout(resolve, waitTime + 100)); // +100ms buffer return this.waitIfNeeded(); // Re-evaluate after waiting } if (this.secondRequests.length >= this.secondLimit) { const waitTime = 1000 - (now - this.secondRequests[0]); // console.debug(`RateLimiter: Second limit reached. Waiting ${waitTime + 100}ms`); await new Promise(resolve => setTimeout(resolve, waitTime + 100)); // +100ms buffer return this.waitIfNeeded(); // Re-evaluate after waiting } this.minuteRequests.push(now); this.secondRequests.push(now); } } /** * Custom error class for Optimizely API-related errors * @extends Error * @description Provides structured error information for API failures including * HTTP status codes, response bodies, and contextual error messages for debugging */ export class OptimizelyAPIError extends Error { /** HTTP status code if the error originated from an HTTP response */ status; /** Response body or additional error details from the API */ response; /** * Creates a new OptimizelyAPIError * @param message - Descriptive error message * @param status - HTTP status code (e.g., 404, 401, 500) * @param response - Response body or additional error context * @example * ```typescript * throw new OptimizelyAPIError('Project not found', 404, { project_id: '12345' }); * ``` */ constructor(message, status, response) { super(message); this.name = 'OptimizelyAPIError'; this.status = status; this.response = response; Object.setPrototypeOf(this, OptimizelyAPIError.prototype); } } /** * Main API Helper Class for Optimizely REST API interactions * @description Provides comprehensive methods for interacting with Optimizely's REST API * with built-in rate limiting, retry logic, error handling, and data fetching utilities. * Supports both Feature Experimentation and Web Experimentation APIs. * * @example * ```typescript * const apiHelper = new OptimizelyAPIHelper('your-api-token'); * const projects = await apiHelper.listProjects(); * const flags = await apiHelper.listFlags('project-id'); * ``` */ export class OptimizelyAPIHelper { /** Optimizely API authentication token */ token; /** Base URL for Optimizely API endpoints */ baseUrl; /** URL for feature flag configuration endpoints */ flagsUrl; /** Rate limiter instance for managing API quotas */ rateLimiter; /** Maximum number of retry attempts for failed requests */ retryAttempts; /** Base delay between retries in milliseconds */ retryDelay; /** * Creates a new OptimizelyAPIHelper instance * @param token - Optimizely Personal Access Token for authentication * @param options - Configuration options for customizing API behavior * @throws {Error} When token is not provided * * @example * ```typescript * // Basic usage with defaults * const api = new OptimizelyAPIHelper('your-token'); * * // Custom configuration * const api = new OptimizelyAPIHelper('your-token', { * requestsPerMinute: 120, * retryAttempts: 5 * }); * ``` */ constructor(token, options = {}) { if (!token) { throw new Error('Optimizely API token is required'); } this.token = token; this.baseUrl = options.baseUrl || API_CONFIG.baseUrl; this.flagsUrl = options.flagsUrl || API_CONFIG.flagsUrl; this.rateLimiter = new RateLimiter(options.requestsPerMinute || API_CONFIG.rateLimits.featureExperimentationAPI.requestsPerMinute, options.requestsPerSecond || API_CONFIG.rateLimits.featureExperimentationAPI.requestsPerSecond); this.retryAttempts = options.retryAttempts || 3; this.retryDelay = options.retryDelay || 1000; } /** * Core HTTP request method with rate limiting and retry logic * @param url - The complete URL to make the request to * @param options - Fetch request options (method, headers, body, etc.) * @returns Promise resolving to the parsed response data * @throws {OptimizelyAPIError} When the request fails after all retries * * @description This is the foundation method used by all other API methods. * It automatically handles: * - Rate limiting (respects Optimizely's API quotas) * - Authentication (adds Bearer token) * - Retries with exponential backoff * - Error parsing and structured error responses * * @example * ```typescript * // GET request * const data = await api.makeRequest('https://api.optimizely.com/v2/projects'); * * // POST request * const result = await api.makeRequest('https://api.optimizely.com/v2/projects', { * method: 'POST', * body: JSON.stringify({ name: 'New Project' }) * }); * ``` */ async makeRequest(url, options = {}) { await this.rateLimiter.waitIfNeeded(); // CRITICAL FIX: Separate headers from options to prevent Authorization header loss const { headers: optionsHeaders, ...restOptions } = options; // Clean the body if it's a string (JSON) - remove __autoCorrect_applied if (restOptions.body && typeof restOptions.body === 'string') { try { const bodyObj = JSON.parse(restOptions.body); delete bodyObj.__autoCorrect_applied; restOptions.body = JSON.stringify(bodyObj); // LOG_POINT 27: HTTP request payload after cleanup if (url.includes('/experiments') && restOptions.method === 'POST') { getLogger().info({ timestamp: Date.now(), stage: 'http-request', description: 'Final HTTP request payload to experiments API', url: url, method: restOptions.method, bodyAfterCleanup: bodyObj, hasType: 'type' in bodyObj, typeValue: bodyObj.type }, 'LOG_POINT 27: HTTP POST to /experiments endpoint'); } } catch (e) { // If it's not valid JSON, leave it as is } } const config = { method: 'GET', // Default method headers: { 'Authorization': `Bearer ${this.token}`, 'Accept': 'application/json', ...(options.method !== 'GET' && options.method !== 'DELETE' ? { 'Content-Type': 'application/json' } : {}), ...(optionsHeaders || {}) // Allow overriding Content-Type while preserving Authorization }, ...restOptions // Spread other options EXCEPT headers }; // CRITICAL DEBUG: Log exact request details for ruleset updates if (url.includes('/ruleset')) { getLogger().info({ url, method: config.method, headers: config.headers, bodyLength: config.body ? config.body.toString().length : 0, tokenPreview: this.token.substring(0, 15) + '...' + this.token.substring(this.token.length - 10) }, 'CRITICAL DEBUG: Exact ruleset request details'); } let lastError; for (let attempt = 1; attempt <= this.retryAttempts; attempt++) { try { // console.debug(`Making request: ${config.method} ${url} (Attempt ${attempt})`); const response = await fetch(url, config); if (response.status === 429) { // Too Many Requests const retryAfterHeader = response.headers.get('retry-after'); const retryAfterSeconds = retryAfterHeader ? parseInt(retryAfterHeader, 10) : (this.retryDelay / 1000) * Math.pow(2, attempt - 1); getLogger().warn({ url, retryAfterSeconds }, 'Rate limit hit (429), retrying after delay'); await new Promise(resolve => setTimeout(resolve, retryAfterSeconds * 1000)); continue; // Retry the request } if (!response.ok) { let errorBody; try { const responseText = await response.text(); try { errorBody = JSON.parse(responseText); } catch (e) { errorBody = responseText; } } catch (e) { errorBody = 'Unable to read response body'; } // CRITICAL: Log full error details for debugging HTTP 400 errors if (response.status === 400) { getLogger().error({ url, method: config.method, requestBody: config.body ? JSON.parse(config.body.toString()) : null, responseStatus: response.status, responseBody: errorBody, responseHeaders: Object.fromEntries(response.headers.entries()) }, 'HTTP 400 Bad Request - Full Details'); } throw new OptimizelyAPIError(`HTTP ${response.status}: ${response.statusText} for URL ${url}`, response.status, errorBody); } if (response.status === 204) { // No Content return null; } const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { return await response.json(); } return await response.text(); // Fallback for non-JSON or unexpected content types } catch (error) { lastError = error; if (attempt < this.retryAttempts && this.isRetryableError(error)) { const delay = this.retryDelay * Math.pow(2, attempt - 1); getLogger().warn({ url, attempt, maxAttempts: this.retryAttempts, delay, error: error.message }, 'Request failed, retrying'); await new Promise(resolve => setTimeout(resolve, delay)); continue; } if (error instanceof OptimizelyAPIError) throw error; // For other errors (e.g., network issues not caught by isRetryableError's codes), wrap them getLogger().error({ url, error: error.message, stack: error.stack }, 'Unhandled error during request'); throw new OptimizelyAPIError(error.message || 'Unknown network error', error.status, error.response || error.cause || error.toString()); } } // console.error(`Request to ${url} failed after ${this.retryAttempts} attempts. Last error:`, lastError); throw lastError; // This will be an OptimizelyAPIError or a wrapped one } isRetryableError(error) { if (error instanceof OptimizelyAPIError) { return error.status ? error.status >= 500 : false; // 429 is handled above } if (error.code) { // System errors (network) return ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EHOSTUNREACH'].includes(error.code); } // Add more conditions if other specific errors are found to be retryable return false; } // ======================================================================== // PROJECT & ACCOUNT MANAGEMENT // ======================================================================== /** * Lists all projects accessible with the current API token * @param filters - Pagination options for the request * @param filters.per_page - Number of projects per page (default: 25, max: 100) * @param filters.page - Page number to retrieve (1-based) * @returns Promise resolving to array of project objects * * @example * ```typescript * // Get all projects (first page) * const projects = await api.listProjects(); * * // Get specific page with custom page size * const page2 = await api.listProjects({ per_page: 50, page: 2 }); * ``` */ async listProjects(filters = {}) { const params = new URLSearchParams(); if (filters.per_page) params.append('per_page', filters.per_page.toString()); if (filters.page) params.append('page', filters.page.toString()); const url = `${this.baseUrl}/projects${params.toString() ? '?' + params.toString() : ''}`; return this.makeRequest(url); } /** * Gets detailed information about a specific project * @param projectId - The project ID to retrieve (can be string or number) * @returns Promise resolving to project details including metadata and settings * @throws {OptimizelyAPIError} When project is not found or access is denied * * @example * ```typescript * const project = await api.getProject('12345'); * console.log(project.name, project.status); * ``` */ async getProject(projectId) { const url = `${this.baseUrl}/projects/${projectId}`; return this.makeRequest(url); } /** * Lists all environments within a project * @param projectId - The project ID to get environments for * @returns Promise resolving to array of environment objects with keys, names, and priorities * * @example * ```typescript * const environments = await api.listEnvironments('12345'); * environments.forEach(env => console.log(env.key, env.name)); * ``` */ async listEnvironments(projectId, params) { try { const project = await this.getProject(projectId); // Check if it's a Feature Experimentation project if (project?.is_flags_enabled) { // For Feature Experimentation, use the flags API endpoint const queryParams = new URLSearchParams(); if (params?.per_page) { queryParams.append('per_page', params.per_page.toString()); } if (params?.page_token) { queryParams.append('page_token', params.page_token); } if (params?.page_window) { queryParams.append('page_window', params.page_window.toString()); } if (params?.archived !== undefined) { queryParams.append('archived', params.archived.toString()); } if (params?.sort && params.sort.length > 0) { params.sort.forEach(sortField => queryParams.append('sort', sortField)); } const queryString = queryParams.toString(); const url = `${this.flagsUrl}/projects/${projectId}/environments${queryString ? `?${queryString}` : ''}`; getLogger().info({ projectId, params, url }, 'OptimizelyAPIHelper.listEnvironments: Fetching Feature Experimentation environments'); const response = await this.makeRequest(url); // Feature Experimentation API returns paginated response with items array if (response && typeof response === 'object' && Array.isArray(response.items)) { return response.items; } else if (Array.isArray(response)) { // Direct array response (fallback) return response; } else { getLogger().error({ projectId, response, responseType: typeof response }, 'OptimizelyAPIHelper.listEnvironments: Unexpected response format from Feature Experimentation API'); throw new OptimizelyAPIError(`Unexpected response format from environments API: ${typeof response}`, 500); } } else { // For Web Experimentation, use the v2 endpoint const url = `${this.baseUrl}/environments?project_id=${projectId}`; getLogger().info({ projectId, url }, 'OptimizelyAPIHelper.listEnvironments: Fetching Web Experimentation environments'); return this.makeRequest(url); } } catch (error) { getLogger().error({ projectId, error: error.message }, 'OptimizelyAPIHelper.listEnvironments: Failed to list environments'); throw error; } } /** * Creates a new environment in a Feature Experimentation project * @param projectId - The project ID to create the environment in * @param environmentData - Environment data containing name, key, description, etc. * @returns Promise resolving to the created environment object * * @description Creates a new environment in a Feature Experimentation project. * Only works with projects that have Feature Experimentation enabled. * * @example * ```typescript * const environment = await api.createEnvironment('12345', { * key: 'production', * name: 'Production Environment', * description: 'Production deployment environment' * }); * ``` */ async createEnvironment(projectId, environmentData) { try { const project = await this.getProject(projectId); // Only Feature Experimentation projects support environment CRUD if (!project?.is_flags_enabled) { throw new OptimizelyAPIError('Environment creation is only supported for Feature Experimentation projects', 400); } const url = `${this.baseUrl}/projects/${projectId}/environments`; getLogger().info({ projectId, environmentData, url }, 'OptimizelyAPIHelper.createEnvironment: Creating Feature Experimentation environment'); return this.makeRequest(url, { method: 'POST', body: JSON.stringify(environmentData) }); } catch (error) { getLogger().error({ projectId, environmentData, error: error.message }, 'OptimizelyAPIHelper.createEnvironment: Failed to create environment'); throw error; } } /** * Updates an existing environment in a Feature Experimentation project * @param projectId - The project ID containing the environment * @param environmentId - The environment ID to update * @param environmentData - Updated environment data * @returns Promise resolving to the updated environment object * * @description Updates an existing environment in a Feature Experimentation project. * Only works with projects that have Feature Experimentation enabled. * * @example * ```typescript * const environment = await api.updateEnvironment('12345', 'env_123', { * name: 'Updated Production Environment', * description: 'Updated production deployment environment' * }); * ``` */ async updateEnvironment(projectId, environmentId, environmentData) { try { const project = await this.getProject(projectId); // Only Feature Experimentation projects support environment CRUD if (!project?.is_flags_enabled) { throw new OptimizelyAPIError('Environment update is only supported for Feature Experimentation projects', 400); } const url = `${this.baseUrl}/projects/${projectId}/environments/${environmentId}`; getLogger().info({ projectId, environmentId, environmentData, url }, 'OptimizelyAPIHelper.updateEnvironment: Updating Feature Experimentation environment'); return this.makeRequest(url, { method: 'PATCH', body: JSON.stringify(environmentData) }); } catch (error) { getLogger().error({ projectId, environmentId, environmentData, error: error.message }, 'OptimizelyAPIHelper.updateEnvironment: Failed to update environment'); throw error; } } /** * Deletes/archives an environment in a Feature Experimentation project * @param projectId - The project ID containing the environment * @param environmentId - The environment ID to delete * @returns Promise resolving to the deleted environment object * * @description Deletes (archives) an environment in a Feature Experimentation project. * Only works with projects that have Feature Experimentation enabled. * Note: In Optimizely, deletion typically means archiving the resource. * * @example * ```typescript * const deletedEnvironment = await api.deleteEnvironment('12345', 'env_123'); * ``` */ async deleteEnvironment(projectId, environmentId) { try { const project = await this.getProject(projectId); // Only Feature Experimentation projects support environment CRUD if (!project?.is_flags_enabled) { throw new OptimizelyAPIError('Environment deletion is only supported for Feature Experimentation projects', 400); } const url = `${this.baseUrl}/projects/${projectId}/environments/${environmentId}`; getLogger().info({ projectId, environmentId, url }, 'OptimizelyAPIHelper.deleteEnvironment: Deleting Feature Experimentation environment'); return this.makeRequest(url, { method: 'DELETE' }); } catch (error) { getLogger().error({ projectId, environmentId, error: error.message }, 'OptimizelyAPIHelper.deleteEnvironment: Failed to delete environment'); throw error; } } // Note: Account management endpoints like /accounts/{account_id} are listed in TDD but not used in methods. // If needed, they can be added similarly. // ======================================================================== // FEATURE FLAGS MANAGEMENT (CORRECTED BASED ON RESEARCH) // ======================================================================== /** * Lists feature flags within a project with optional filtering * @param projectId - The project ID to get flags for * @param filters - Filtering and pagination options * @param filters.per_page - Number of flags per page (default: 25, max: 100) * @param filters.page - Page number to retrieve (1-based) * @param filters.archived - Filter by archived status (client-side filtering) * @returns Promise resolving to array of flag objects * * @description The Optimizely API doesn't support server-side archived filtering, * so this method applies client-side filtering when the archived parameter is provided. * For better performance with large datasets, consider using the CacheManager instead. * * @example * ```typescript * // Get all flags * const flags = await api.listFlags('12345'); * * // Get only active (non-archived) flags * const activeFlags = await api.listFlags('12345', { archived: false }); * ``` */ async listFlags(projectId, filters = {}) { const params = new URLSearchParams(); if (filters.per_page) params.append('per_page', filters.per_page.toString()); // Feature Experimentation API uses page_token, not page if (filters.page_token) { params.append('page_token', filters.page_token); } else if (filters.page && filters.page > 1) { // If page is provided and > 1, we can't handle it without page_token throw new OptimizelyAPIError('Feature Experimentation API requires page_token for pagination', 400, null); } const url = `${this.flagsUrl}/projects/${projectId}/flags${params.toString() ? '?' + params.toString() : ''}`; const response = await this.makeRequest(url); // Feature Experimentation API returns paginated response with items array if (response && response.items !== undefined) { // If archived filter is specified, filter the items let items = response.items; if (filters.archived !== undefined && Array.isArray(items)) { items = items.filter(flag => flag.archived === filters.archived); response.items = items; response.count = items.length; } return response; // Return full response to preserve pagination info } // Legacy response format (direct array) if (filters.archived !== undefined && Array.isArray(response)) { return response.filter(flag => flag.archived === filters.archived); } return response; } /** * Gets detailed information about a specific feature flag * @param projectId - The project ID containing the flag * @param flagKey - The unique flag key to retrieve * @returns Promise resolving to flag details including variations, rules, and metadata * @throws {OptimizelyAPIError} When flag is not found or access is denied * * @example * ```typescript * const flag = await api.getFlag('12345', 'feature_rollout'); * console.log(flag.name, flag.variations); * ``` */ async getFlag(projectId, flagKey) { const url = `${this.flagsUrl}/projects/${projectId}/flags/${flagKey}`; return this.makeRequest(url); } /** * Gets entities for a specific flag (for change history tracking) * @param projectId - The project ID containing the flag * @param flagId - The flag ID (integer) to get entities for * @returns Promise resolving to array of entity groups with entity_ids and entity_type * @throws {OptimizelyAPIError} When entities retrieval fails * * @description This endpoint returns the entity parameters needed for calling the change history API * for Feature Experimentation projects. It's part of the two-step process for getting flag changes. * * @example * ```typescript * const entities = await api.getFlagEntities('12345', 45633994); * // Returns: [{ entity_ids: [123, 456], entity_type: "flag" }] * * // Use entities in change history call * for (const entityGroup of entities) { * const changes = await api.getChangeHistory(projectId, { * entity_ids: entityGroup.entity_ids, * entity_type: entityGroup.entity_type, * start_time: since.toISOString() * }); * } * ``` */ async getFlagEntities(projectId, flagId) { const url = `${this.flagsUrl}/projects/${projectId}/flags/${flagId}/entities`; return this.makeRequest(url); } /** * Creates a new feature flag in a project * @param projectId - The project ID to create the flag in * @param flagData - The flag configuration data including name, key, variations, etc. * @returns Promise resolving to created flag details * @throws {OptimizelyAPIError} When flag creation fails (e.g., duplicate key, invalid data) * * @example * ```typescript * const newFlag = await api.createFlag('12345', { * key: 'new_feature', * name: 'New Feature Flag', * variable_definitions: [ * { key: 'enabled', type: 'boolean', default_value: 'false' } * ] * }); * ``` */ async createFlag(projectId, flagData) { const url = `${this.flagsUrl}/projects/${projectId}/flags`; // Remove fields that are not accepted during flag creation const cleanedData = { ...flagData }; delete cleanedData.archived; // API doesn't accept this field during creation getLogger().info({ projectId, flagData: JSON.stringify(cleanedData, null, 2), url }, 'OptimizelyAPIHelper.createFlag: Creating flag with payload'); try { const result = await this.makeRequest(url, { method: 'POST', body: JSON.stringify(cleanedData) }); getLogger().info({ projectId, flagKey: flagData.key, flagId: result.id }, 'OptimizelyAPIHelper.createFlag: Successfully created flag'); return result; } catch (error) { getLogger().error({ projectId, flagData: JSON.stringify(flagData, null, 2), error: error.message, status: error.status, url }, 'OptimizelyAPIHelper.createFlag: Failed to create flag'); // Enhanced error messaging for HTTP 400 Bad Request if (error.status === 400 || error.message.includes('Bad Request')) { const enhancedMessage = `Flag creation failed with HTTP 400 Bad Request for '${flagData.name}' (key: ${flagData.key}). šŸ”§ COMMON FLAG CREATION ISSUES: • Missing required fields (key, name) • Invalid flag key format - use alphanumeric characters, underscores, hyphens only • Duplicate flag key - key '${flagData.key}' may already exist in project • Invalid variable definitions or environment configurations šŸ› ļø RECOMMENDED SOLUTIONS: 1. šŸŽÆ Get a template: Call get_entity_templates with entity_type="flag" to see working examples 2. šŸ” Check existing flags: Use list_entities with entity_type="flag" to see existing keys 3. šŸ”„ Use template mode: Set "mode": "template" with "template_data" instead of "entity_data" Original error: ${error.message}`; throw new Error(enhancedMessage); } throw error; } } /** * Updates an existing feature flag * @param projectId - The project ID containing the flag * @param flagKey - The flag key to update * @param updates - The updates to apply (partial flag data) * @returns Promise resolving to updated flag details * @throws {OptimizelyAPIError} When update fails (e.g., flag not found, invalid data) * * @example * ```typescript * const updatedFlag = await api.updateFlag('12345', 'feature_key', { * name: 'Updated Feature Name', * description: 'New description' * }); * ``` */ async updateFlag(projectId, flagKey, updates) { const url = `${this.flagsUrl}/projects/${projectId}/flags/${flagKey}`; return this.makeRequest(url, { method: 'PATCH', body: JSON.stringify(updates) }); } // Note: TDD lists Flag Variations endpoints. These are part of flag details or managed via flag object. // Dedicated methods can be added if direct variation manipulation is needed outside full flag context. // ======================================================================== // FLAG ENVIRONMENT & RULES (CRITICAL FOR MCP TOOLS) // ======================================================================== async getFlagEnvironmentRuleset(projectId, flagKey, environmentKey) { const url = `${this.flagsUrl}/projects/${projectId}/flags/${flagKey}/environments/${environmentKey}/ruleset`; return this.makeRequest(url); } async updateFlagEnvironmentRuleset(projectId, flagKey, environmentKey, rulesetData) { const url = `${this.flagsUrl}/projects/${projectId}/flags/${flagKey}/environments/${environmentKey}/ruleset`; // CRITICAL DEBUG: Log the EXACT request body to identify the problematic field getLogger().error('=== RULESET UPDATE API REQUEST DEBUG ==='); getLogger().error(`URL: ${url}`); getLogger().error(`Method: PATCH`); getLogger().error(`Content-Type: application/json-patch+json`); getLogger().error(`Ruleset Data Type: ${typeof rulesetData}`); getLogger().error(`Is Array: ${Array.isArray(rulesetData)}`); // Log the full request body to identify the exact field causing the error if (Array.isArray(rulesetData)) { getLogger().error('JSON Patch Operations:'); rulesetData.forEach((op, index) => { getLogger().error(safeLogObject(op), `Operation ${index}`); }); } else { getLogger().error(safeLogObject(rulesetData), 'Full Ruleset Data'); } getLogger().error('========================================'); return this.makeRequest(url, { method: 'PATCH', body: JSON.stringify(rulesetData), headers: { 'Content-Type': 'application/json-patch+json', 'Accept': 'application/json' } }); } async enableFlag(projectId, flagKey, environmentKey) { const url = `${this.flagsUrl}/projects/${projectId}/flags/${flagKey}/environments/${environmentKey}/ruleset/enabled`; return this.makeRequest(url, { method: 'POST' }); // Expects 204 No Content on success } async disableFlag(projectId, flagKey, environmentKey) { const url = `${this.flagsUrl}/projects/${projectId}/flags/${flagKey}/environments/${environmentKey}/ruleset/disabled`; return this.makeRequest(url, { method: 'POST' }); // Expects 204 No Content on success } // ======================================================================== // EXPERIMENTS (CORRECTED ENDPOINT USAGE) // ======================================================================== async listExperiments(projectId, filters = {}) { const params = new URLSearchParams(); if (filters.per_page) params.append('per_page', filters.per_page.toString()); if (filters.page) params.append('page', filters.page.toString()); params.append('project_id', projectId.toString()); // REQUIRED parameter const url = `${this.baseUrl}/experiments${params.toString() ? '?' + params.toString() : ''}`; const response = await this.makeRequest(url); // Client-side filtering for archived status, similar to listFlags if (filters.archived !== undefined && Array.isArray(response)) { return response.filter(experiment => experiment.archived === filters.archived); } return response; } async getExperiment(experimentId) { const url = `${this.baseUrl}/experiments/${experimentId}`; return this.makeRequest(url); } async getExperimentResults(experimentId, filters = {}) { const params = new URLSearchParams(); if (filters.start_date) params.append('start_date', filters.start_date); if (filters.end_date) params.append('end_date', filters.end_date); if (filters.stats_config) params.append('stats_config', filters.stats_config); // Note: This endpoint has a specific rate limit (20 req/min from API_CONFIG in TDD Section 1.1). // The current general RateLimiter might not be sufficient. A multi-bucket RateLimiter would be an enhancement. const url = `${this.baseUrl}/experiments/${experimentId}/results${params.toString() ? '?' + params.toString() : ''}`; return this.makeRequest(url); } // ======================================================================== // AUDIENCES & TARGETING // ======================================================================== async listAudiences(projectId, filters = {}) { const params = new URLSearchParams(); params.append('project_id', projectId.toString()); if (filters.per_page) params.append('per_page', filters.per_page.toString()); if (filters.page) params.append('page', filters.page.toString()); // API does NOT support 'archived' query param for audiences. const url = `${this.baseUrl}/audiences${params.toString() ? '?' + params.toString() : ''}`; return this.makeRequest(url); } async getAudience(audienceId) { const url = `${this.baseUrl}/audiences/${audienceId}`; return this.makeRequest(url); } // ======================================================================== // ATTRIBUTES & EVENTS // ======================================================================== async listAttributes(projectId, filters = {}) { // EXTENSIVE LOGGING: Debug parameter construction for FX projects getLogger().info({ projectId, projectIdType: typeof projectId, projectIdValue: projectId, filters, baseUrl: this.baseUrl }, 'OptimizelyAPIHelper.listAttributes: DEBUG - Input parameters'); const params = new URLSearchParams(); params.append('project_id', projectId.toString()); if (filters.per_page) params.append('per_page', filters.per_page.toString()); if (filters.page) params.append('page', filters.page.toString()); const url = `${this.baseUrl}/attributes${params.toString() ? '?' + params.toString() : ''}`; // EXTENSIVE LOGGING: Debug exact request details getLogger().info({ projectId, finalUrl: url, queryParams: params.toString(), fullRequest: { url, method: 'GET', headers: { Authorization: 'Bearer [REDACTED]' } } }, 'OptimizelyAPIHelper.listAttributes: DEBUG - Exact request being sent'); try { const response = await this.makeRequest(url); getLogger().info({ projectId, attributeCount: Array.isArray(response) ? response.length : 0, responseType: typeof response }, 'OptimizelyAPIHelper.listAttributes: SUCCESS'); return response; } catch (error) { // EXTENSIVE LOGGING: Debug exact error details getLogger().error({ projectId, url, status: error.status, statusText: error.statusText, message: error.message, responseBody: error.response, errorDetails: error, requestDetails: { projectIdSent: projectId.toString(), queryParamsSent: params.toString() } }, 'OptimizelyAPIHelper.listAttributes: DETAILED ERROR ANALYSIS'); throw error; } } async getAttribute(attributeId) { const url = `${this.baseUrl}/attributes/${attributeId}`; return this.makeRequest(url); } async listEvents(projectId, filters = {}) { const params = new URLSearchParams(); params.append('project_id', projectId.toString()); if (filters.per_page) params.append('per_page', filters.per_page.toString()); if (filters.page) params.append('page', filters.page.toString()); const url = `${this.baseUrl}/events${params.toString() ? '?' + params.toString() : ''}`; return this.makeRequest(url); } async getEvent(eventId) { const url = `${this.baseUrl}/events/${eventId}`; return this.makeRequest(url); } // ======================================================================== // CHANGE HISTORY (ESSENTIAL FOR INCREMENTAL SYNC) // ======================================================================== async getChangeHistory(projectId, filters = {}) { const params = new URLSearchParams(); params.append('project_id', projectId.toString()); // Project ID is a query parameter if (filters.since) params.append('start_time', filters.since); // API uses start_time, not since if (filters.until) params.append('end_time', filters.until); // API uses end_time, not until if (filters.per_page) params.append('per_page', filters.per_page.toString()); if (filters.page) params.append('page', filters.page.toString()); // Handle both Web Experimentation (target_type) and Feature Experimentation (entity_type) parameters if (filters.target_type) params.append('entity_type', filters.target_type); // API uses entity_type if (filters.entity_type && !filters.entity_ids) { // Only add entity_type if we're not using entity_ids (which requires colon format) params.append('entity_type', filters.entity_type); } // Feature Experimentation specific: entity parameter with colon-delimited format if (filters.entity_ids && filters.entity_ids.length > 0 && filters.entity_type) { // Format as colon-delimited strings: "entity_type:entity_id" for (const entityId of filters.entity_ids) { params.append('entity', `${filters.entity_type}:${entityId}`); } } const url = `${this.baseUrl}/changes${params.toString() ? '?' + params.toString() : ''}`; return this.makeRequest(url); } // ======================================================================== // RECURSIVE PAGINATION HELPER (CRITICAL FOR COMPLETE DATA FETCH) // ======================================================================== async getAllPages(fetchFunction, options = {}) { const { maxPages = 1000, // Max pages to fetch to prevent infinite loops perPage = 100, // Items per page to request startPage = 1, // Starting page number delay = 100, // Milliseconds delay between paginated requests retryAttempts = 3 // Retry attempts for each page fetch } = options; const allResults = []; let currentPage = startPage; let hasMoreData = true; let totalItemsFetched = 0; while (hasMoreData && currentPage <= maxPages) { let currentAttempt = 1; let pageFetchedSuccessfully = false; while (currentAttempt <= retryAttempts && !pageFetchedSuccessfully) { try { // console.debug(`getAllPages: Fetching page ${currentPage}, perPage: ${perPage}, attempt: ${currentAttempt}`); const response = await fetchFunction(currentPage, perPage); let itemsOnPage = []; if (Array.isArray(response)) { itemsOnPage = response; } else if (response && Array.isArray(response.data)) { itemsOnPage = response.data; } else if (response && Array.isArray(response.results)) { itemsOnPage = response.results; } else if (response && response.items) { itemsOnPage = Array.isArray(response.items) ? response.items : [response.items]; } else if (response === null || response === undefined) { // Explicitly handle null/undefined as empty itemsOnPage = []; } // It's possible an API returns an object without these common array keys for an empty list. // If response is an object but not an array and doesn't match above, assume empty or error handled by makeRequest. if (itemsOnPage.length === 0) { hasMoreData = false; } else { allResults.push(...itemsOnPage); totalItemsFetched += itemsOnPage.length; if (itemsOnPage.length < perPage) { hasMoreData = false; // Last page likely fetched } } pageFetchedSuccessfully = true; // console.debug(`getAllPages: Page ${currentPage} fetched ${itemsOnPage.length} items. Total: ${totalItemsFetched}. HasMore: ${hasMoreData}`); } catch (error) { getLogger().error({ currentPage, currentAttempt, error: error.message }, 'getAllPages: Error fetching page'); if (error.status === 404 || error.status === 400) { // Non-existent page or bad request for page hasMoreData = false; pageFetchedSuccessfully = true; // Stop trying for this page, effectively ends pagination for this path. } else if (currentAttempt < retryAttempts) { const currentDelay = delay * Math.pow(2, currentAttempt - 1); // Exponential backoff for retries getLogger().warn({ currentPage, delay: currentDelay }, 'getAllPages: Retrying page after delay'); await new Promise(resolve => setTimeout(resolve, currentDelay)); currentAttempt++; } else { getLogger().error({ currentPage, retryAttempts }, 'getAllPages: Failed to fetch page after all retry attempts'); throw error; // Propagate error after max retries for a page } } } if (!pageFetchedSuccessfully && !hasMoreData) { // If retries failed and it led to hasMoreData=false (e.g. 404) break; // Exit main while loop } if (!hasMoreData) break; currentPage++; if (hasMoreData && delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } } // console.info(`getAllPages: Completed. Fetched ${totalItemsFetched} items in ${currentPage - startPage} page requests.`); return allResults; } // ======================================================================== // BULK PROJECT DATA FETCH (OPTIMIZED FOR MCP SERVER) // ======================================================================== async getProjectData(projectId, options = {}) { const data = { project: null, environments: [], flags: [], experiments: [], audiences: [], attributes: [], events: [] }; const defaultOptions = { includeFlags: true, includeExperiments: true, includeAudiences: true, inc