@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
364 lines • 14.1 kB
JavaScript
/**
* Template Executor - Actually executes templates against Optimizely API
*/
import axios from 'axios';
import chalk from 'chalk';
import { TemplateValidator } from './TemplateValidator';
import { ReferenceResolver } from './ReferenceResolver';
export class TemplateExecutor {
apiClient;
validator;
resolver;
constructor() {
this.validator = new TemplateValidator();
this.resolver = new ReferenceResolver();
this.apiClient = axios.create({
baseURL: 'https://api.optimizely.com/v2',
headers: {
'Content-Type': 'application/json'
}
});
}
/**
* Execute a template
*/
async execute(template, context, options = {}) {
// Set API token
this.apiClient.defaults.headers.common['Authorization'] = `Bearer ${context.apiToken}`;
// Validate template first
const validation = await this.validator.validate(template);
if (!validation.valid && validation.errors.length > 0) {
throw new Error(`Template validation failed: ${validation.errors[0].message}`);
}
const result = {
success: true,
steps: [],
summary: { total: 0, successful: 0, failed: 0, skipped: 0 },
errors: [],
createdEntities: []
};
// Execute each step
for (const step of template.steps) {
result.summary.total++;
if (options.dryRun) {
console.log(chalk['blue'](`[DRY RUN] Would execute step: ${step.id}`));
}
const stepResult = await this.executeStep(step, context, options);
result.steps.push(stepResult);
if (stepResult.status === 'success') {
result.summary.successful++;
// Store result for reference
if (stepResult.result?.id) {
context.results[step.id] = stepResult.result;
result.createdEntities.push({
stepId: step.id,
entityType: stepResult.entityType || 'unknown',
entityId: stepResult.result.id,
name: stepResult.result.name
});
}
}
else if (stepResult.status === 'failed') {
result.summary.failed++;
result.success = false;
result.errors.push(`Step ${step.id}: ${stepResult.error}`);
if (options.stopOnError) {
console.error(chalk['red'](`Stopping execution due to error in step ${step.id}`));
break;
}
}
else {
result.summary.skipped++;
}
}
return result;
}
/**
* Execute a single step
*/
async executeStep(step, context, options = {}) {
const startTime = Date.now();
try {
if (step.type === 'template') {
const entityType = this.extractEntityType(step.template?.system_template_id);
const action = this.extractAction(step.template?.system_template_id);
if (!entityType || !action) {
throw new Error(`Invalid system_template_id: ${step.template?.system_template_id}`);
}
// Resolve references in inputs
const resolvedInputs = await this.resolveInputs(step.template?.inputs || {}, context);
if (options.dryRun) {
return {
stepId: step.id,
status: 'success',
action: `${action} ${entityType}`,
entityType,
result: { id: `dry-run-${step.id}`, ...resolvedInputs },
duration: Date.now() - startTime
};
}
// Execute API call
const result = await this.executeApiCall(entityType, action, resolvedInputs, context);
return {
stepId: step.id,
status: 'success',
action: `${action} ${entityType}`,
entityType,
result,
duration: Date.now() - startTime
};
}
else {
// Handle other step types
return {
stepId: step.id,
status: 'skipped',
action: 'skip',
error: `Unsupported step type: ${step.type}`,
duration: Date.now() - startTime
};
}
}
catch (error) {
return {
stepId: step.id,
status: 'failed',
action: 'error',
error: error instanceof Error ? error.message : String(error),
duration: Date.now() - startTime
};
}
}
/**
* Execute API call
*/
async executeApiCall(entityType, action, inputs, context) {
const endpoint = this.getApiEndpoint(entityType, action, inputs, context);
const method = this.getHttpMethod(action);
const body = this.prepareRequestBody(entityType, action, inputs);
console.log(chalk['gray'](`API ${method} ${endpoint}`));
try {
const response = await this.apiClient.request({
method,
url: endpoint,
data: body
});
return response.data;
}
catch (error) {
if (error.response) {
const status = error.response.status;
const message = error.response.data?.message || error.response.statusText;
const details = error.response.data?.errors ?
'\n' + error.response.data.errors.map((e) => ` - ${e.field}: ${e.message}`).join('\n') : '';
throw new Error(`API Error (${status}): ${message}${details}`);
}
throw error;
}
}
/**
* Get API endpoint for entity and action
*/
getApiEndpoint(entityType, action, inputs, context) {
const projectId = inputs.project_id || context.projectId;
// Map entity types to API endpoints
const endpoints = {
project: {
create: '/projects',
update: `/projects/${inputs.id}`,
get: `/projects/${inputs.id}`
},
experiment: {
create: `/projects/${projectId}/experiments`,
update: `/experiments/${inputs.id}`,
get: `/experiments/${inputs.id}`
},
campaign: {
create: `/projects/${projectId}/campaigns`,
update: `/campaigns/${inputs.id}`,
get: `/campaigns/${inputs.id}`
},
audience: {
create: `/projects/${projectId}/audiences`,
update: `/audiences/${inputs.id}`,
get: `/audiences/${inputs.id}`
},
event: {
create: `/projects/${projectId}/events`,
update: `/events/${inputs.id}`,
get: `/events/${inputs.id}`
},
page: {
create: `/projects/${projectId}/pages`,
update: `/pages/${inputs.id}`,
get: `/pages/${inputs.id}`
},
flag: {
create: `/projects/${projectId}/flags`,
update: `/flags/${inputs.flag_key}`,
get: `/flags/${inputs.flag_key}`
},
ruleset: {
create: `/flags/${inputs.flag_key}/environments/${inputs.environment_key || 'development'}/rulesets`,
update: `/flags/${inputs.flag_key}/environments/${inputs.environment_key || 'development'}/rulesets/${inputs.id}`,
get: `/flags/${inputs.flag_key}/environments/${inputs.environment_key || 'development'}/rulesets`
},
variation: {
create: this.getVariationEndpoint(inputs, projectId),
update: `/variations/${inputs.id}`,
get: `/variations/${inputs.id}`
}
};
const entityEndpoints = endpoints[entityType];
if (!entityEndpoints) {
throw new Error(`Unknown entity type: ${entityType}`);
}
const endpoint = entityEndpoints[action];
if (!endpoint) {
throw new Error(`Unknown action '${action}' for entity type '${entityType}'`);
}
return endpoint;
}
/**
* Get HTTP method for action
*/
getHttpMethod(action) {
const methods = {
create: 'POST',
update: 'PUT',
patch: 'PATCH',
delete: 'DELETE',
get: 'GET'
};
return methods[action] || 'POST';
}
/**
* Prepare request body
*/
prepareRequestBody(entityType, action, inputs) {
if (action === 'get' || action === 'delete') {
return undefined;
}
// Remove internal fields
const body = { ...inputs };
delete body.project_id; // Usually in URL, not body
delete body.id; // Usually in URL for updates
// Special handling for certain entities
if (entityType === 'audience' && typeof body.conditions === 'string') {
try {
body.conditions = JSON.parse(body.conditions);
}
catch (e) {
// Keep as string if not valid JSON
}
}
return body;
}
/**
* Resolve inputs with references
*/
async resolveInputs(inputs, context) {
const resolved = {};
for (const [key, value] of Object.entries(inputs)) {
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
// Resolve reference
const ref = value.slice(2, -1);
if (ref.includes('.')) {
// Step reference like ${step_id.field}
const [stepId, field] = ref.split('.');
const stepResult = context.results[stepId];
resolved[key] = stepResult?.[field] || value;
}
else {
// Parameter reference like ${project_id}
resolved[key] = context.parameters[ref] || value;
}
}
else if (Array.isArray(value)) {
// Recursively resolve arrays
resolved[key] = await Promise.all(value.map(item => typeof item === 'object' ? this.resolveInputs(item, context) : item));
}
else if (typeof value === 'object' && value !== null) {
// Recursively resolve objects
resolved[key] = await this.resolveInputs(value, context);
}
else {
resolved[key] = value;
}
}
return resolved;
}
/**
* Extract entity type from system_template_id
*/
extractEntityType(templateId) {
if (!templateId)
return null;
const parts = templateId.split('/');
return parts.length >= 3 ? parts[1] : null;
}
/**
* Extract action from system_template_id
*/
extractAction(templateId) {
if (!templateId)
return null;
const parts = templateId.split('/');
return parts.length >= 3 ? parts[2] : null;
}
/**
* Get variation endpoint (special case)
*/
getVariationEndpoint(inputs, projectId) {
if (inputs.experiment_id) {
return `/experiments/${inputs.experiment_id}/variations`;
}
else if (inputs.flag_key) {
return `/flags/${inputs.flag_key}/variations`;
}
throw new Error('Variation creation requires experiment_id or flag_key');
}
/**
* Format results for display
*/
formatResults(result) {
const lines = [];
lines.push(chalk['blue'](chalk['bold']('\nEXECUTION SUMMARY')));
lines.push('=================');
lines.push(`Total Steps: ${result.summary.total}`);
lines.push(`${chalk['green']('✓')} Successful: ${result.summary.successful}`);
if (result.summary.failed > 0) {
lines.push(`${chalk['red']('✗')} Failed: ${result.summary.failed}`);
}
if (result.summary.skipped > 0) {
lines.push(`${chalk['yellow']('⊘')} Skipped: ${result.summary.skipped}`);
}
if (result.createdEntities.length > 0) {
lines.push(chalk['green'](chalk['bold']('\n✅ CREATED ENTITIES:')));
for (const entity of result.createdEntities) {
lines.push(` • ${entity.entityType}: ${entity.name || entity.entityId} (ID: ${entity.entityId})`);
}
}
if (result.errors.length > 0) {
lines.push(chalk['red'](chalk['bold']('\n❌ ERRORS:')));
for (const error of result.errors) {
lines.push(` • ${error}`);
}
}
lines.push(chalk['blue'](chalk['bold']('\nSTEP DETAILS:')));
for (const step of result.steps) {
const statusIcon = step.status === 'success' ? chalk['green']('✓') :
step.status === 'failed' ? chalk['red']('✗') :
chalk['yellow']('⊘');
lines.push(` ${statusIcon} ${step.stepId}: ${step.action} (${step.duration}ms)`);
if (step.error) {
lines.push(chalk['red'](` Error: ${step.error}`));
}
else if (step.result?.id) {
lines.push(chalk['gray'](` Created: ${step.result.name || step.result.key || step.result.id}`));
}
}
return lines.join('\n');
}
}
//# sourceMappingURL=TemplateExecutor.js.map