UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

353 lines 16.3 kB
/** * OpenAPI Error Enhancer * * This class enhances API error messages with prescriptive guidance * by querying the OpenAPI specification to show exactly what the API expects. */ import path from 'path'; import { fileURLToPath } from 'url'; import { getLogger } from '../logging/Logger.js'; // ES module equivalent of __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export class OpenAPIErrorEnhancer { webParser; flagsParser; initialized = false; logger = getLogger(); constructor() { // We'll initialize lazily when needed } /** * Initialize both OpenAPI parsers (Web and Feature Experimentation) */ async initialize() { if (this.initialized) return; try { const fs = await import('fs/promises'); // Load Web Experimentation API spec (v2) - use absolute path from module location const webSpecPath = path.join(__dirname, '../../docs/api-reference/api-spec/optimizely-swagger.json'); const webContent = await fs.readFile(webSpecPath, 'utf8'); const webSpec = JSON.parse(webContent); // Load Feature Experimentation API spec (v1) const flagsSpecPath = path.join(__dirname, '../../docs/api-reference/api-spec/optimizely-swagger-fx.json'); const flagsContent = await fs.readFile(flagsSpecPath, 'utf8'); const flagsSpec = JSON.parse(flagsContent); // Create parsers for both APIs const createParser = (spec) => ({ getEndpointDetails: (method, path) => { const pathItem = spec.paths?.[path]; if (!pathItem) return null; const operation = pathItem[method.toLowerCase()]; if (!operation) return null; return { path, method: method.toUpperCase(), operationId: operation.operationId, summary: operation.summary, description: operation.description, tags: operation.tags || [], parameters: [ ...(pathItem.parameters || []), ...(operation.parameters || []) ], requestBody: operation.requestBody, responses: operation.responses || {}, security: operation.security || spec.security || [], deprecated: operation.deprecated || false }; } }); this.webParser = createParser(webSpec); this.flagsParser = createParser(flagsSpec); this.initialized = true; this.logger.info({ webSpecPath, flagsSpecPath }, 'OpenAPIErrorEnhancer initialized with both API specs'); } catch (error) { this.logger.error({ error: error.message }, 'Failed to initialize OpenAPIErrorEnhancer'); throw error; } } /** * Determine which parser to use based on the endpoint path */ getParserForEndpoint(apiPath) { // Feature Experimentation API endpoints start with /flags/v1 if (apiPath.startsWith('/flags/v1')) { return this.flagsParser; } // Web Experimentation API endpoints (v2) // These include: /experiments, /campaigns, /pages, /audiences, etc. return this.webParser; } /** * Enhance an API error with OpenAPI schema information */ async enhanceError(error, operation, entityType, apiMethod, apiPath) { try { await this.initialize(); // Extract error details const originalError = this.extractErrorMessage(error); // If we don't have the API method/path, try to infer it if (!apiMethod || !apiPath) { const inferred = this.inferEndpoint(operation, entityType); apiMethod = apiMethod || inferred.method; apiPath = apiPath || inferred.path; } if (!apiMethod || !apiPath) { return { originalError }; } // Determine which parser to use based on the endpoint const parser = this.getParserForEndpoint(apiPath); if (!parser) { return { originalError }; } // Get endpoint information from OpenAPI spec const endpointDetails = parser.getEndpointDetails(apiMethod, apiPath); if (!endpointDetails) { return { originalError }; } // Convert to our expected format const endpointInfo = { endpoint: `${endpointDetails.method} ${endpointDetails.path}`, description: endpointDetails.summary || endpointDetails.description || '', pathParams: endpointDetails.parameters .filter((p) => p.in === 'path') .map((p) => ({ name: p.name, required: true, // path params are always required type: p.schema?.type || 'string', description: p.description })), queryParams: endpointDetails.parameters .filter((p) => p.in === 'query') .map((p) => ({ name: p.name, required: p.required || false, type: p.schema?.type || 'string', description: p.description, example: p.example })), requestBody: endpointDetails.requestBody ? { required: endpointDetails.requestBody.required || false, contentTypes: Object.keys(endpointDetails.requestBody.content || {}), schema: endpointDetails.requestBody.content?.['application/json']?.schema } : undefined, responses: Object.entries(endpointDetails.responses || {}).map(([code, response]) => ({ statusCode: code, description: response.description || '', schema: response.content?.['application/json']?.schema })) }; // Generate enhanced guidance based on the error const guidance = this.generateGuidance(originalError, endpointInfo, entityType); return { originalError, enhancedGuidance: { message: guidance.message, expectedSchema: guidance.expectedSchema, requiredFields: guidance.requiredFields, examples: guidance.examples, apiDocumentation: endpointInfo } }; } catch (enhancementError) { this.logger.warn({ error: enhancementError.message, originalError: error.message }, 'Failed to enhance error with OpenAPI guidance'); // Return just the original error if enhancement fails return { originalError: this.extractErrorMessage(error) }; } } /** * Extract error message from various error formats */ extractErrorMessage(error) { if (error.response && typeof error.response === 'object') { if (error.response.message) { return error.response.message; } else if (error.response.error) { return error.response.error; } } return error.message || 'Unknown error'; } /** * Infer API endpoint from operation and entity type */ inferEndpoint(operation, entityType) { const endpoints = { experiment: { create: { method: 'POST', path: '/v2/experiments' }, update: { method: 'PATCH', path: '/v2/experiments/{experiment_id}' }, delete: { method: 'DELETE', path: '/v2/experiments/{experiment_id}' } }, flag: { create: { method: 'POST', path: '/flags/v1/projects/{project_id}/flags' }, update: { method: 'PATCH', path: '/flags/v1/projects/{project_id}/flags/{flag_key}' }, delete: { method: 'DELETE', path: '/flags/v1/projects/{project_id}/flags/{flag_key}' } }, page: { create: { method: 'POST', path: '/v2/pages' }, update: { method: 'PATCH', path: '/v2/pages/{page_id}' }, delete: { method: 'DELETE', path: '/v2/pages/{page_id}' } }, event: { create: { method: 'POST', path: '/v2/projects/{project_id}/custom_events' }, update: { method: 'PATCH', path: '/v2/projects/{project_id}/custom_events/{event_id}' }, delete: { method: 'DELETE', path: '/v2/projects/{project_id}/custom_events/{event_id}' } }, campaign: { create: { method: 'POST', path: '/v2/campaigns' }, update: { method: 'PATCH', path: '/v2/campaigns/{campaign_id}' }, delete: { method: 'DELETE', path: '/v2/campaigns/{campaign_id}' } }, audience: { create: { method: 'POST', path: '/v2/audiences' }, update: { method: 'PATCH', path: '/v2/audiences/{audience_id}' }, delete: { method: 'DELETE', path: '/v2/audiences/{audience_id}' } } }; return endpoints[entityType]?.[operation] || {}; } /** * Generate guidance based on the error and OpenAPI schema */ generateGuidance(errorMessage, endpointInfo, entityType) { const guidance = { message: '', requiredFields: [] }; // Handle specific error patterns if (errorMessage.includes("'edit_url' is a required property")) { guidance.message = `Web experiments require an 'edit_url' field that specifies the Optimizely editor URL. `; guidance.message += `This field is automatically generated when creating experiments through the Optimizely editor, `; guidance.message += `but must be provided when creating via API. Use template mode for easier experiment creation.`; if (endpointInfo.requestBody?.schema) { guidance.requiredFields = this.extractRequiredFields(endpointInfo.requestBody.schema); guidance.examples = { edit_url: "https://app.optimizely.com/v2/projects/123456789/experiments/edit/987654321", note: "Use template mode instead of direct creation for complex experiments" }; } } else if (errorMessage.includes("'page_id' is a required property")) { guidance.message = `Web experiment actions require a 'page_id' field in each action object. `; guidance.message += `The action structure should be: `; guidance.message += `actions: [{ page_id: 123456, changes: [{ type: "custom_code", selector: "body", value: "..." }] }]. `; guidance.message += `Each action targets a specific page and contains an array of changes to make on that page.`; guidance.examples = { correct_action_structure: { page_id: 5117198042136576, changes: [ { type: "custom_code", selector: "body", value: "console.log('Variation loaded');" } ] }, note: "Each action must have a page_id that matches one of the page_ids in the experiment" }; } else if (errorMessage.includes("'actions' must be specified for Variations")) { guidance.message = `Web experiments require 'actions' to be specified in each variation. `; guidance.message += `Actions define what changes to make on the page (HTML, CSS, JavaScript).`; // Extract variation schema from the request body if (endpointInfo.requestBody?.schema) { const variationSchema = this.findSchemaProperty(endpointInfo.requestBody.schema, 'variations'); if (variationSchema) { guidance.expectedSchema = variationSchema; guidance.message += `\n\nExpected variation structure:`; guidance.examples = { variations: [{ name: "Control", weight: 5000, actions: [] // Empty for control }, { name: "Treatment", weight: 5000, actions: [{ page_id: 123456, changes: [{ type: "attribute", selector: "#button", attribute: "style", value: "background: blue" }] }] }] }; } } } else if (errorMessage.includes("required")) { // Extract required fields from schema if (endpointInfo.requestBody?.schema) { guidance.requiredFields = this.extractRequiredFields(endpointInfo.requestBody.schema); guidance.message = `Missing required fields. The ${entityType} requires: ${guidance.requiredFields.join(', ')}`; } } else if (errorMessage.includes("Invalid value") || errorMessage.includes("Valid options")) { // Error already contains enum values, but we can add schema context guidance.message = errorMessage; if (endpointInfo.requestBody?.schema) { guidance.expectedSchema = endpointInfo.requestBody.schema; } } // Add general schema information if we haven't provided specific guidance if (!guidance.message && endpointInfo.requestBody?.schema) { guidance.message = `Review the expected request format for ${entityType} creation.`; guidance.expectedSchema = endpointInfo.requestBody.schema; guidance.requiredFields = this.extractRequiredFields(endpointInfo.requestBody.schema); } return guidance; } /** * Find a specific property in a nested schema */ findSchemaProperty(schema, propertyName) { if (!schema) return null; if (schema.properties && schema.properties[propertyName]) { return schema.properties[propertyName]; } // Check in nested schemas if (schema.allOf) { for (const subSchema of schema.allOf) { const found = this.findSchemaProperty(subSchema, propertyName); if (found) return found; } } return null; } /** * Extract required fields from a schema */ extractRequiredFields(schema) { const required = []; if (schema.required && Array.isArray(schema.required)) { required.push(...schema.required); } // Check in allOf schemas if (schema.allOf) { for (const subSchema of schema.allOf) { const subRequired = this.extractRequiredFields(subSchema); required.push(...subRequired); } } return [...new Set(required)]; // Remove duplicates } } // Singleton instance export const openAPIErrorEnhancer = new OpenAPIErrorEnhancer(); //# sourceMappingURL=OpenAPIErrorEnhancer.js.map