@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
1,047 lines (1,044 loc) ⢠209 kB
JavaScript
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