@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
561 lines • 26.4 kB
JavaScript
/**
* 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