@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
533 lines • 20.3 kB
JavaScript
/**
* UpdateAutoCorrector - Safe auto-corrections for UPDATE operations
*
* This class provides a subset of ComprehensiveAutoCorrector's capabilities
* that are safe for UPDATE operations. It focuses on fixing AI agent mistakes
* without applying defaults or making assumptions about missing fields.
*
* SAFE FOR UPDATES:
* - Field name corrections (flag_key → key)
* - Enum fuzzy matching (SUM → sum)
* - URL protocol fixes
* - Format validations
* - Typo corrections
*
* NOT SAFE FOR UPDATES:
* - Swagger defaults (could overwrite existing values)
* - Safety fallbacks (could change intended nulls)
* - Missing field additions (partial updates are valid)
*/
import { getLogger } from '../logging/Logger.js';
import { FIELDS } from '../generated/fields.generated.js';
// Field name mappings for common AI agent mistakes
const fieldMappings = {
common: {
flag_key: 'key',
experiment_key: 'key',
audience_key: 'key',
page_key: 'key',
campaign_key: 'key',
rule_key: 'key',
attribute_key: 'key',
desc: 'description',
desc_text: 'description',
audience_targeting: 'audience_conditions',
traffic_split: 'traffic_allocation',
abTest: 'ab_test',
ab_test_info: 'ab_test',
aggregator_type: 'aggregator',
event_Key: 'event_key',
Aggregator: 'aggregator'
},
flag: {
flag_key: 'key'
},
experiment: {
experiment_key: 'key'
},
audience: {
audience_key: 'key',
audience_conditions: 'conditions'
}
};
// Enum value mappings
const enumMappings = {
aggregator: {
sum: 'sum',
unique: 'unique',
total: 'sum',
count: 'count',
uniques: 'unique',
SUM: 'sum',
UNIQUE: 'unique',
TOTAL: 'sum',
COUNT: 'count'
},
audience_conditions: {
everyone: 'everyone',
'every one': 'everyone',
all: 'everyone',
none: 'everyone',
noone: 'everyone'
},
status: {
// Only map to 'running' for experiments, not variations
// active: 'running', // REMOVED - this breaks variation status
live: 'running',
paused: 'paused',
stopped: 'paused',
archived: 'archived'
}
};
// Audience condition type mappings
const conditionTypeMappings = {
cookies: 'cookies',
cookie: 'cookies',
code: 'code',
javascript: 'code',
js: 'code',
query: 'query',
queryparameter: 'query',
querystring: 'query',
referrer: 'referrer',
referer: 'referrer',
campaign: 'campaign',
utm: 'campaign',
browser: 'browser_version',
browserversion: 'browser_version',
event: 'event',
customevent: 'event',
customtag: 'custom_tag',
tag: 'custom_tag',
customdimension: 'custom_dimension',
dimension: 'custom_dimension',
ip: 'ip',
ipaddress: 'ip',
language: 'language',
lang: 'language',
location: 'location',
geo: 'location',
platform: 'platform',
os: 'platform',
firstsession: 'first_session',
newsession: 'first_session',
time: 'time_and_day',
timeandday: 'time_and_day',
sourcetype: 'source_type',
source: 'source_type'
};
export class UpdateAutoCorrector {
static corrections = [];
static entity = '';
/**
* Main entry point for UPDATE operation auto-correction
*/
static autoCorrectForUpdate(entityType, userInput, context) {
this.corrections = [];
this.entity = entityType;
// Skip if input is JSON Patch format (array)
if (Array.isArray(userInput)) {
getLogger().debug({
entityType,
reason: 'JSON Patch format detected, skipping auto-correction'
}, 'UpdateAutoCorrector: Skipped for JSON Patch');
return {
correctedData: userInput,
corrections: [],
userMessage: 'JSON Patch format detected, skipping auto-correction',
wasCorrected: false
};
}
let correctedData = JSON.parse(JSON.stringify(userInput));
// Apply UPDATE-safe corrections in order
correctedData = this.correctFieldNames(correctedData, entityType);
correctedData = this.applyStatusSemanticExpansion(correctedData, entityType);
correctedData = this.fixUrlProtocols(correctedData);
correctedData = this.correctEnumValues(correctedData);
correctedData = this.validateAndFixFormats(correctedData, entityType, context?.platform);
correctedData = this.removeInvalidFields(correctedData, entityType);
const userMessage = this.generateUserMessage();
return {
correctedData,
corrections: this.corrections,
userMessage,
wasCorrected: this.corrections.length > 0
};
}
/**
* Correct common field name mistakes
*/
static correctFieldNames(data, entityType) {
if (typeof data !== 'object' || data === null)
return data;
const corrected = Array.isArray(data) ? [] : {};
const mappings = fieldMappings[entityType] || fieldMappings['common'];
for (const [key, value] of Object.entries(data)) {
let correctedKey = key;
// Check entity-specific mappings first
if (fieldMappings[entityType] && fieldMappings[entityType][key]) {
correctedKey = fieldMappings[entityType][key];
if (correctedKey !== key) {
this.addCorrection({
field: key,
original: key,
corrected: correctedKey,
reason: `Field name corrected for ${entityType}`,
type: 'field_rename'
});
}
}
// Then check common mappings
else if (fieldMappings.common[key]) {
correctedKey = fieldMappings.common[key];
if (correctedKey !== key) {
this.addCorrection({
field: key,
original: key,
corrected: correctedKey,
reason: 'Common field name correction',
type: 'field_rename'
});
}
}
// Recursively correct nested objects
if (typeof value === 'object' && value !== null) {
corrected[correctedKey] = this.correctFieldNames(value, entityType);
}
else {
corrected[correctedKey] = value;
}
}
return corrected;
}
/**
* Fix URL protocols (add https:// if missing)
*/
static fixUrlProtocols(data) {
if (typeof data !== 'object' || data === null)
return data;
const urlFields = ['edit_url', 'webhook_url', 'target_url', 'click_url', 'url'];
const corrected = Array.isArray(data) ? [...data] : { ...data };
for (const [key, value] of Object.entries(corrected)) {
if (urlFields.includes(key) && typeof value === 'string' && value.length > 0) {
// Check if it looks like a URL but lacks protocol
if (!value.startsWith('http://') && !value.startsWith('https://') &&
(value.includes('.') || value.startsWith('localhost'))) {
corrected[key] = `https://${value}`;
this.addCorrection({
field: key,
original: value,
corrected: corrected[key],
reason: 'Added HTTPS protocol to URL',
type: 'url_fix'
});
}
// Upgrade HTTP to HTTPS
else if (value.startsWith('http://')) {
corrected[key] = value.replace('http://', 'https://');
this.addCorrection({
field: key,
original: value,
corrected: corrected[key],
reason: 'Upgraded HTTP to HTTPS',
type: 'url_fix'
});
}
}
// Recurse for nested objects
else if (typeof value === 'object' && value !== null) {
corrected[key] = this.fixUrlProtocols(value);
}
}
return corrected;
}
/**
* Correct enum values using fuzzy matching
*/
static correctEnumValues(data, parentKey) {
if (typeof data !== 'object' || data === null)
return data;
const corrected = Array.isArray(data) ? [...data] : { ...data };
for (const [field, value] of Object.entries(corrected)) {
// CRITICAL: Skip status mapping for variations to prevent 'active' -> 'running' conversion
const isVariationStatus = field === 'status' && parentKey === 'variations';
// Check if this field has enum mappings
if (enumMappings[field] && typeof value === 'string' && !isVariationStatus) {
const validValues = Object.keys(enumMappings[field]);
const normalizedValue = value.toLowerCase().trim();
// Exact match (case-insensitive)
const exactMatch = validValues.find(v => v.toLowerCase() === normalizedValue);
if (exactMatch && exactMatch !== value) {
corrected[field] = exactMatch;
this.addCorrection({
field,
original: value,
corrected: exactMatch,
reason: 'Enum value case correction',
type: 'enum_correction'
});
continue;
}
// Check mapping
const mapping = enumMappings[field];
if (mapping && mapping[normalizedValue]) {
corrected[field] = mapping[normalizedValue];
this.addCorrection({
field,
original: value,
corrected: corrected[field],
reason: 'Enum value corrected via mapping',
type: 'enum_correction'
});
}
}
// Special handling for audience condition types
if (field === 'type' && data.audience_id !== undefined) {
const normalized = value.toString().toLowerCase().replace(/[_\s-]+/g, '');
if (conditionTypeMappings[normalized]) {
corrected[field] = conditionTypeMappings[normalized];
this.addCorrection({
field: 'conditions.type',
original: value,
corrected: corrected[field],
reason: 'Audience condition type corrected',
type: 'enum_correction'
});
}
}
// Recurse for nested objects
if (typeof corrected[field] === 'object' && corrected[field] !== null) {
corrected[field] = this.correctEnumValues(corrected[field], field);
}
}
return corrected;
}
/**
* Validate and fix common format issues
*/
static validateAndFixFormats(data, entityType, platform) {
if (typeof data !== 'object' || data === null)
return data;
const corrected = Array.isArray(data) ? [...data] : { ...data };
// Fix weight format (percentage string to basis points)
if ('weight' in corrected) {
if (typeof corrected.weight === 'string' && corrected.weight.includes('%')) {
const percentage = parseFloat(corrected.weight.replace('%', ''));
if (!isNaN(percentage)) {
corrected.weight = Math.round(percentage * 100);
this.addCorrection({
field: 'weight',
original: data.weight,
corrected: corrected.weight,
reason: 'Converted percentage to basis points',
type: 'format_fix'
});
}
}
}
// Fix description if it's an array or object
if ('description' in corrected) {
if (Array.isArray(corrected.description)) {
corrected.description = corrected.description.join(' ');
this.addCorrection({
field: 'description',
original: '[Array]',
corrected: corrected.description,
reason: 'Converted array to string',
type: 'format_fix'
});
}
else if (typeof corrected.description === 'object' && corrected.description !== null) {
corrected.description = JSON.stringify(corrected.description);
this.addCorrection({
field: 'description',
original: '[Object]',
corrected: corrected.description,
reason: 'Converted object to string',
type: 'format_fix'
});
}
}
// Fix metrics with sum aggregator missing field
if (corrected.metrics && Array.isArray(corrected.metrics)) {
corrected.metrics = corrected.metrics.map((metric) => {
if (metric.aggregator === 'sum' && !metric.field) {
return {
...metric,
field: 'value'
};
}
return metric;
});
}
// Recursively process nested objects
for (const [key, value] of Object.entries(corrected)) {
if (typeof value === 'object' && value !== null && key !== 'metrics') {
corrected[key] = this.validateAndFixFormats(value, entityType, platform);
}
}
return corrected;
}
/**
* Generate user-friendly message
*/
static generateUserMessage() {
if (this.corrections.length === 0) {
return 'No corrections needed';
}
const messages = ['Applied UPDATE corrections:'];
for (const correction of this.corrections) {
messages.push(`- ${correction.field}: ${correction.reason}`);
}
return messages.join('\n');
}
/**
* Apply Status semantic expansion for entity-specific meanings
* Phase 1: Status semantic expansion (flags only)
*/
static applyStatusSemanticExpansion(data, entityType) {
if (typeof data !== 'object' || data === null || data.Status === undefined) {
return data;
}
const corrected = { ...data };
const statusValue = String(corrected.Status).toUpperCase();
// Entity-specific Status semantic expansion
if (entityType === 'flag') {
// For flags: Status maps to archived field (inverted logic)
if (statusValue === 'ACTIVE') {
corrected.archived = false;
this.addCorrection({
field: 'archived',
original: `Status: "${data.Status}"`,
corrected: 'archived: false',
reason: 'Status "ACTIVE" semantically expanded to archived: false',
type: 'field_rename'
});
}
else if (statusValue === 'INACTIVE') {
corrected.archived = true;
this.addCorrection({
field: 'archived',
original: `Status: "${data.Status}"`,
corrected: 'archived: true',
reason: 'Status "INACTIVE" semantically expanded to archived: true',
type: 'field_rename'
});
}
// Remove the original Status field
delete corrected.Status;
this.addCorrection({
field: 'Status',
original: 'Status',
corrected: 'removed',
reason: 'Status field removed after semantic expansion',
type: 'field_rename'
});
}
// TODO: Add other entity types (experiments, etc.) in future phases
return corrected;
}
/**
* Remove fields that don't exist in the entity schema
* Phase 2: Schema-based field cleanup
*/
static removeInvalidFields(data, entityType) {
if (typeof data !== 'object' || data === null) {
return data;
}
// CRITICAL: Skip field removal for template data
if (data._template_type) {
getLogger().debug({
entityType,
templateType: data._template_type,
reason: 'Template data detected, preserving all fields'
}, 'UpdateAutoCorrector: Skipping field cleanup for template');
return data;
}
const schema = FIELDS[entityType];
if (!schema) {
// No schema available, return as-is
getLogger().debug({
entityType,
reason: 'No schema found'
}, 'UpdateAutoCorrector: Skipping field cleanup - no schema');
return data;
}
const validFields = [
...(schema.required || []),
...(schema.optional || [])
];
const cleanedData = {};
const removedFields = [];
for (const [field, value] of Object.entries(data)) {
if (validFields.includes(field) || field === '_template_type') {
cleanedData[field] = value;
}
else {
removedFields.push(field);
}
}
// Log removed fields if any
if (removedFields.length > 0) {
this.addCorrection({
field: 'schema_cleanup',
original: removedFields.join(', '),
corrected: 'removed',
reason: `Removed invalid fields not in ${entityType} schema: ${removedFields.join(', ')}`,
type: 'field_rename'
});
getLogger().info({
entityType,
removedFields,
validFieldsCount: Object.keys(cleanedData).length,
removedFieldsCount: removedFields.length
}, 'UpdateAutoCorrector: Schema-based field cleanup applied');
}
return cleanedData;
}
/**
* Add a correction to the list
*/
static addCorrection(correction) {
this.corrections.push(correction);
getLogger().debug({
entity: this.entity,
field: correction.field,
original: correction.original,
corrected: correction.corrected,
reason: correction.reason
}, 'UpdateAutoCorrector: Applied correction');
}
}
/**
* Integration point for EntityRouter
*
* Add this to EntityRouter.handleUpdateOperation before line 2283:
*
* ```typescript
* // Apply UPDATE-safe auto-corrections
* if (data && !Array.isArray(data)) {
* const { UpdateAutoCorrector } = await import('../validation/UpdateAutoCorrector.js');
*
* const correctionResult = UpdateAutoCorrector.autoCorrectForUpdate(
* entityType,
* data,
* {
* platform: projectId ? await this.getProjectPlatformType(projectId) : undefined,
* projectId
* }
* );
*
* if (correctionResult.corrections.length > 0) {
* data = correctionResult.correctedData;
* getLogger().info({
* entityType,
* operation: 'update',
* corrections: correctionResult.corrections.length,
* details: correctionResult.corrections.map(c => `${c.field}: ${c.reason}`)
* }, 'Applied UPDATE auto-corrections');
* }
* }
* ```
*/
//# sourceMappingURL=UpdateAutoCorrector.js.map