UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

561 lines 26.4 kB
/** * Schema-Aware Entity Builder * * This class builds entities with intelligent defaults and validation before sending to the API. * It solves the jQuery 1.11.3 problem by applying modern defaults that override Optimizely's legacy settings. */ import { FIELDS } from '../generated/fields.generated.js'; import { PrescriptiveValidator } from '../validation/PrescriptiveValidator.js'; import { getLogger } from '../logging/Logger.js'; export class SchemaAwareEntityBuilder { defaultsManager; logger = getLogger(); prescriptiveValidator; constructor(defaultsManager) { this.defaultsManager = defaultsManager; this.prescriptiveValidator = new PrescriptiveValidator(); } /** * Detect platform based on entity type and context */ detectPlatform(entityType, context) { // Explicit platform override if (context.platform) { return context.platform === 'web' ? 'web' : 'feature'; } // Platform-specific entities const webOnlyEntities = ['page', 'campaign']; const featureOnlyEntities = ['flag', 'ruleset', 'rule', 'variable_definition', 'variable', 'fx_environment', 'feature']; if (webOnlyEntities.includes(entityType)) { return 'web'; } if (featureOnlyEntities.includes(entityType)) { return 'feature'; } // Default to feature for shared entities (safer defaults) return 'feature'; } /** * Build an entity with intelligent defaults and validation * This is the main method that solves the jQuery problem */ buildEntity(entityType, userInput, context = {}) { this.logger.info({ entityType, userInput, context }, 'SchemaAwareEntityBuilder: Building entity with modern defaults'); // 1. Detect platform and get merged defaults with platform awareness const platform = this.detectPlatform(entityType, context); // CRITICAL FIX: For project creation, check if user is creating a feature project // If user provides platform:"custom" or is_flags_enabled:true, use feature_project defaults let effectiveEntityType = entityType; if (entityType === 'project') { if (userInput.platform === 'custom' || userInput.is_flags_enabled === true) { effectiveEntityType = 'feature_project'; } else if (userInput.platform === 'web' || userInput.is_flags_enabled === false) { effectiveEntityType = 'web_project'; } // Otherwise use generic 'project' defaults } const defaults = this.defaultsManager.getDefaults(effectiveEntityType, { platform, projectId: context.projectId }); this.logger.info({ entityType, effectiveEntityType, detectedPlatform: platform, providedPlatform: context.platform, defaultsSource: this.defaultsManager.getConfigSource(), defaults_platform: defaults.platform, defaults_is_flags_enabled: defaults.is_flags_enabled }, 'SchemaAwareEntityBuilder: Applied platform-specific defaults'); // 2. Apply any template if specified let enrichedDefaults = defaults; if (userInput._template) { const template = this.defaultsManager.getTemplate(userInput._template); if (template) { enrichedDefaults = this.deepMerge(defaults, template); this.logger.info({ templateName: userInput._template, template }, 'Applied template to defaults'); } // Remove template from user input as it's not an API field const { _template, ...cleanUserInput } = userInput; userInput = cleanUserInput; } // 3. Merge with user input (user input always wins) const merged = this.deepMerge(enrichedDefaults, userInput); // 4. Apply context-aware adjustments const contextAdjusted = this.applyContextAdjustments(entityType, merged, context); // 5. Prescriptive validation with auto-generation const validation = this.prescriptiveValidator.validateEntity(entityType, contextAdjusted, context); // Apply auto-generated fields if (Object.keys(validation.autoGenerated).length > 0) { Object.assign(contextAdjusted, validation.autoGenerated); this.logger.info({ entityType, autoGenerated: validation.autoGenerated }, 'Auto-generated missing fields'); } // Log warnings if (validation.warnings.length > 0) { this.logger.warn({ entityType, warnings: validation.warnings }, 'Validation warnings'); } // Fail if errors remain if (!validation.isValid) { const error = new Error(`Entity validation failed: ${validation.errors.join(', ')}`); this.logger.error({ entityType, userInput, errors: validation.errors, warnings: validation.warnings }, 'Entity validation failed'); throw error; } // 6. Apply type conversions based on schema field types const typeConverted = this.convertTypes(entityType, contextAdjusted); // 7. Log what defaults were applied for debugging this.logDefaultsApplied(entityType, userInput, enrichedDefaults, typeConverted); return typeConverted; } /** * Apply context-aware adjustments (e.g., platform-specific defaults) */ applyContextAdjustments(entityType, data, context) { const adjusted = { ...data }; // Platform-specific adjustments if (entityType === 'project' && context.platform) { // Override platform if specified in context adjusted.platform = context.platform; } // CRITICAL FIX: Handle variation fields based on platform // Feature Experimentation variations: only accept key, name, description, variables // Web Experimentation variations: accept key, name, weight, feature_enabled, variable_values const detectedPlatform = this.detectPlatform(entityType, context); if (entityType === 'variation' && detectedPlatform === 'feature') { // Remove Web Experimentation specific fields for Feature Experimentation variations delete adjusted.weight; delete adjusted.feature_enabled; delete adjusted.variable_values; // Transform variable_values to variables if present if (data.variable_values && !adjusted.variables) { // Transform the structure and convert boolean values to strings as required by API const transformedVariables = {}; for (const [varKey, varValue] of Object.entries(data.variable_values)) { transformedVariables[varKey] = { value: typeof varValue === 'boolean' ? String(varValue) : varValue }; } adjusted.variables = transformedVariables; } this.logger.info({ entityType, platform: detectedPlatform, action: 'Removed Web Experimentation fields (weight, feature_enabled, variable_values) for Feature Experimentation variation', transformedVariableValues: !!data.variable_values }, 'Platform-specific adjustment'); } // Add project_id if missing and available in context if (context.projectId && !adjusted.project_id) { const schema = FIELDS[entityType]; if (schema && schema.required && Array.isArray(schema.required) && schema.required.includes('project_id')) { // Convert project_id to number as required by Optimizely API adjusted.project_id = parseInt(context.projectId, 10); } } // Apply type conversions based on schema const typeConverted = this.convertTypes(entityType, adjusted); return typeConverted; } /** * Convert field types based on schema field types */ convertTypes(entityType, data) { const schema = FIELDS[entityType]; if (!schema || !schema.fieldTypes) { return data; } const converted = { ...data }; for (const [fieldName, fieldType] of Object.entries(schema.fieldTypes)) { if (converted[fieldName] !== undefined && converted[fieldName] !== null) { const originalValue = converted[fieldName]; const originalType = typeof originalValue; switch (fieldType) { case 'integer': if (typeof converted[fieldName] === 'string') { const parsed = parseInt(converted[fieldName], 10); if (!isNaN(parsed)) { converted[fieldName] = parsed; } } break; case 'number': if (typeof converted[fieldName] === 'string') { const parsed = parseFloat(converted[fieldName]); if (!isNaN(parsed)) { converted[fieldName] = parsed; } } break; case 'boolean': // CRITICAL: Only convert if the field is actually meant to be boolean // Skip conversion for fields that should remain strings (like default_value) if (entityType === 'variable_definition' && fieldName === 'default_value') { // Never convert default_value to boolean for variable_definition // It must remain a string regardless of content break; } if (typeof converted[fieldName] === 'string') { converted[fieldName] = converted[fieldName].toLowerCase() === 'true'; } break; case 'string': // CRITICAL FIX: Convert non-string values TO strings when schema expects string // This fixes the issue where default_value boolean false needs to become string "false" // The API expects string values for default_value field in variable_definition if (typeof converted[fieldName] !== 'string') { converted[fieldName] = String(converted[fieldName]); } break; // Arrays and objects are handled as-is } // Log type conversions for debugging (only if value changed) if (converted[fieldName] !== originalValue) { this.logger.debug({ entityType, fieldName, fieldType, originalValue, originalType, convertedValue: converted[fieldName], convertedType: typeof converted[fieldName] }, 'Field type conversion applied'); } } } return converted; } /** * Add entity-specific validation rules not captured in the schema */ addEntitySpecificValidation(entityType, data, errors, warnings) { switch (entityType) { case 'audience': // Check for missing or empty conditions (empty means "everyone" - no targeting) if (!data.conditions || data.conditions === "[]") { // Try to build conditions from description if available const builtConditions = this.tryBuildConditionsFromDescription(data.description); if (builtConditions) { data.conditions = builtConditions; warnings.push(`Auto-generated conditions from description: ${builtConditions}`); } else { // Only error if no description available for parsing if (!data.description) { errors.push(`Missing required field 'conditions' for audience. ` + `Provide targeting rules as a JSON string or include targeting details in description. Examples:\n` + ` - For "everyone": "[]"\n` + ` - For mobile users: "[\\"and\\", {\\"type\\": \\"device_type\\", \\"value\\": \\"mobile\\"}]"\n` + ` - For custom attribute: "[\\"and\\", {\\"type\\": \\"attribute\\", \\"name\\": \\"region\\", \\"value\\": \\"North America\\"}]"\n` + ` - For location: "[\\"and\\", {\\"type\\": \\"location\\", \\"value\\": \\"US-CA-SANFRANCISCO\\"}]"\n` + `See: https://developers.optimizely.com/x/rest/guides/conditions/`); } else { warnings.push(`Could not parse targeting conditions from description: "${data.description}". Using default "everyone" audience.`); } } } break; case 'page': if (!data.edit_url) { errors.push(`Missing required field 'edit_url' for page.\n\n` + `❌ You provided 'editor_url' but the correct field name is 'edit_url'\n\n` + `✅ CORRECT template structure:\n` + `{\n` + ` "mode": "template",\n` + ` "template_data": {\n` + ` "name": "Products Page",\n` + ` "edit_url": "www.homepage.com/products/1/shoes", // <-- Use 'edit_url' not 'editor_url'\n` + ` "activation_type": "url_change", // <-- Also: use 'activation_type' not 'activation'\n` + ` "conditions": [...]\n` + ` }\n` + `}\n\n` + `💡 TIP: Always check the template first:\n` + `get_entity_templates(entity_type="page", complexity=1)\n\n` + `This will show you the exact field names and structure required.`); } break; case 'extension': if (!data.implementation) { errors.push(`Missing required field 'implementation' for extension. Provide the extension implementation object.`); } break; case 'webhook': if (!data.url) { errors.push(`Missing required field 'url' for webhook. Provide the webhook endpoint URL.`); } break; case 'variable': if (!data.type) { errors.push(`Missing required field 'type' for variable. Valid types: boolean, string, double, integer, json`); } if (!data.default_value) { errors.push(`Missing required field 'default_value' for variable.`); } break; case 'experiment': // Validate metrics scope for experiments if (data.metrics && Array.isArray(data.metrics)) { data.metrics.forEach((metric, index) => { if (metric.scope && metric.scope !== 'visitor') { errors.push(`Invalid metrics scope "${metric.scope}" for experiment metric[${index}]. ` + `Experiments require scope: "visitor". ` + `Campaigns use scope: "session". ` + `Please change metric[${index}].scope to "visitor".`); } }); } break; case 'campaign': // Validate required fields for campaigns if (!data.page_ids && !data.url_targeting) { errors.push(`Campaigns require either 'page_ids' or 'url_targeting' to be set. ` + `Provide one of:\n` + ` - page_ids: [123456] (array of page IDs)\n` + ` - url_targeting: { "edit_url": "https://example.com", "conditions": "..." }`); } // Validate metrics scope for campaigns if (data.metrics && Array.isArray(data.metrics)) { data.metrics.forEach((metric, index) => { if (metric.scope && metric.scope !== 'session') { warnings.push(`Metric[${index}] has scope "${metric.scope}". ` + `Campaigns typically use scope: "session" (not "visitor").`); } }); } break; } } /** * Try to build audience conditions from natural language description */ tryBuildConditionsFromDescription(description) { if (!description) return null; const desc = description.toLowerCase(); // Pattern matching for common targeting scenarios const patterns = [ // Custom attribute patterns { regex: /(\w+)\s+attribute\s+equals?\s+["']([^"']+)["']/i, builder: (matches) => { const attributeName = matches[1].toLowerCase(); const value = matches[2]; return `["and", {"type": "attribute", "name": "${attributeName}", "value": "${value}"}]`; } }, { regex: /(\w+)\s+attribute\s+equals?\s+([^'"]*(?:\s+[^'"]*)*)/i, builder: (matches) => { const attributeName = matches[1].toLowerCase(); const value = matches[2].trim(); return `["and", {"type": "attribute", "name": "${attributeName}", "value": "${value}"}]`; } }, { regex: /where\s+(\w+)\s*=\s*["']([^"']+)["']/i, builder: (matches) => { const attributeName = matches[1].toLowerCase(); const value = matches[2]; return `["and", {"type": "attribute", "name": "${attributeName}", "value": "${value}"}]`; } }, // Using attribute patterns with "using" keyword { regex: /using\s+(\w+)\s+attribute/i, builder: (matches) => { const attributeName = matches[1].toLowerCase(); return `["and", {"type": "attribute", "name": "${attributeName}", "value": "REPLACE_WITH_VALUE"}]`; } }, // Location patterns { regex: /location.*?['""]?([A-Z]{2}(?:-[A-Z]{2})?(?:-\w+)?)['""]?/i, builder: (matches) => { const location = matches[1]; return `["and", {"type": "location", "value": "${location}"}]`; } }, // Device type patterns { regex: /(mobile|desktop|tablet)\s+users?/i, builder: (matches) => { const deviceType = matches[1].toLowerCase(); return `["and", {"type": "device_type", "value": "${deviceType}"}]`; } }, // Browser patterns { regex: /(chrome|firefox|safari|edge)\s+users?/i, builder: (matches) => { const browser = matches[1].toLowerCase(); return `["and", {"type": "browser", "value": "${browser}"}]`; } } ]; // Try each pattern for (const pattern of patterns) { const matches = desc.match(pattern.regex); if (matches) { try { return pattern.builder(matches); } catch (error) { // Continue to next pattern if this one fails continue; } } } return null; } /** * Validate entity against schema requirements */ validateEntity(entityType, data) { const errors = []; const warnings = []; const schema = FIELDS[entityType]; if (!schema) { errors.push(`No schema found for entity type: ${entityType}`); return { valid: false, errors, warnings }; } // Check required fields for (const requiredField of schema.required) { if (!(requiredField in data) || data[requiredField] === undefined || data[requiredField] === null) { errors.push(`Required field missing: ${requiredField}`); } } // Add entity-specific validation rules that aren't captured in schema this.addEntitySpecificValidation(entityType, data, errors, warnings); // Validate enum fields with helpful error messages for (const [fieldName, enumValues] of Object.entries(schema.enums)) { if (fieldName in data && Array.isArray(enumValues)) { const value = data[fieldName]; if (value !== undefined && !enumValues.includes(value)) { const validOptionsFormatted = enumValues.map((v) => v).join(', '); errors.push(`Invalid value "${value}" for field '${fieldName}'. ` + `Valid options are: ${validOptionsFormatted}. ` + `Please use one of these exact values (case-sensitive).`); } } } // Check for legacy jQuery settings (warning only) if (entityType === 'project' && data.settings?.web?.snippet) { const snippet = data.settings.web.snippet; if (snippet.include_jquery === true || snippet.library?.includes('jquery')) { warnings.push('Using legacy jQuery settings. Consider using modern defaults.'); } } return { valid: errors.length === 0, errors, warnings }; } /** * Get current defaults for an entity type (for guidance/documentation) */ getCurrentDefaults(entityType, context) { const schema = FIELDS[entityType]; const platform = context?.platform || this.detectPlatform(entityType, context || {}); const defaults = this.defaultsManager.getDefaults(entityType, { platform, projectId: context?.projectId }); return { entityType, platform, defaults, defaultsSource: this.defaultsManager.getConfigSource(), configPath: this.defaultsManager.getConfigPath(), required: schema?.required || [], enums: schema?.enums || {}, fieldTypes: schema?.fieldTypes || {} }; } /** * Get available templates for an entity type */ getAvailableTemplates() { // This would be implemented once we add template support to ConfigurableDefaultsManager return []; } /** * Log what defaults were applied for debugging and transparency */ logDefaultsApplied(entityType, userInput, appliedDefaults, finalResult) { // Find which defaults were actually used const defaultsUsed = {}; const userOverrides = {}; for (const [key, value] of Object.entries(finalResult)) { if (key in userInput) { userOverrides[key] = value; } else if (key in appliedDefaults) { defaultsUsed[key] = value; } } this.logger.info({ entityType, defaultsSource: this.defaultsManager.getConfigSource(), defaultsUsed, userOverrides, totalFields: Object.keys(finalResult).length }, 'Applied defaults to entity creation'); // Special logging for the jQuery problem solution if (entityType === 'project' && defaultsUsed.platform) { this.logger.info({ oldDefault: 'web (leads to jQuery 1.11.3)', newDefault: `${defaultsUsed.platform} (modern platform)`, jquerySettings: finalResult.settings?.web?.snippet }, '🎯 jQuery problem solved: Applied modern project defaults'); } } /** * Deep merge multiple objects with proper array and object handling */ deepMerge(...objects) { const result = {}; for (const obj of objects) { if (!obj || typeof obj !== 'object') continue; for (const key in obj) { const value = obj[key]; if (value === null || value === undefined) { continue; // Skip null/undefined values } if (Array.isArray(value)) { // Arrays replace entirely (don't merge) result[key] = [...value]; } else if (typeof value === 'object' && !Array.isArray(value)) { // Objects merge recursively result[key] = this.deepMerge(result[key] || {}, value); } else { // Primitives replace result[key] = value; } } } return result; } } //# sourceMappingURL=SchemaAwareEntityBuilder.js.map