UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

536 lines 19.1 kB
/** * Optimizely Adapter for Intelligent Query Engine * * This adapter provides discovery and execution capabilities for the Optimizely * data model stored in SQLite. It auto-discovers entities, fields, and relationships * from the database schema and cached data. */ import { getLogger } from '../../../logging/Logger.js'; import { FIELDS } from '../../../generated/fields.generated.js'; const logger = getLogger(); /** * Map SQLite types to our universal field types */ const SQLITE_TYPE_MAP = { 'INTEGER': 'number', 'REAL': 'number', 'TEXT': 'string', 'BLOB': 'binary', 'BOOLEAN': 'boolean', 'JSON': 'json', 'DATETIME': 'date' }; /** * Known entity type mappings */ const ENTITY_TABLE_MAP = { 'project': 'projects', 'projects': 'projects', 'flag': 'flags', 'flags': 'flags', 'flag_environment': 'flag_environments', 'flag_environments': 'flag_environments', 'environment': 'flag_environments', 'environments': 'flag_environments', 'experiment': 'experiments', 'experiments': 'experiments', 'audience': 'audiences', 'audiences': 'audiences', 'event': 'events', 'events': 'events', 'attribute': 'attributes', 'attributes': 'attributes', 'feature': 'features', 'features': 'features', 'variation': 'variations', 'variations': 'variations', 'campaign': 'campaigns', 'campaigns': 'campaigns', 'page': 'pages', 'pages': 'pages', 'extension': 'extensions', 'extensions': 'extensions', 'group': 'groups', 'groups': 'groups', 'webhook': 'webhooks', 'webhooks': 'webhooks', 'collaborator': 'collaborators', 'collaborators': 'collaborators' }; /** * Optimizely Adapter implementation */ export class OptimizelyAdapter { name = 'optimizely'; version = '1.0.0'; description = 'Adapter for Optimizely Feature & Web Experimentation data'; db; projectFilter; schemaCache = new Map(); constructor(config) { this.db = config.database; this.projectFilter = config.projectFilter; logger.info(`OptimizelyAdapter initialized with ${config.projectFilter?.length || 'all'} projects`); } /** * Discover all entities in the Optimizely data model */ async discoverEntities() { logger.debug('Discovering Optimizely entities'); const entities = []; try { // Get all tables from SQLite const tables = this.db.prepare(` SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'meta%' ORDER BY name `).all(); for (const table of tables) { // Skip sync state tables if (table.name.includes('_sync_state')) continue; // Get row count const countResult = this.db.prepare(`SELECT COUNT(*) as count FROM ${table.name}`).get(); // Map table name to entity name const entityName = this.tableToEntityName(table.name); entities.push({ name: entityName, type: 'table', description: `Optimizely ${entityName} data`, primaryKey: 'id', estimatedRowCount: countResult.count, metadata: { tableName: table.name, hasDataJson: this.hasDataJsonColumn(table.name) } }); } // Add virtual entities for relationships entities.push(...this.getVirtualEntities()); logger.info(`Discovered ${entities.length} Optimizely entities`); return entities; } catch (error) { logger.error(`Failed to discover entities: ${error}`); throw error; } } /** * Discover fields for a specific entity */ async discoverFields(entity) { logger.debug(`Discovering fields for entity: ${entity}`); const fields = []; const tableName = ENTITY_TABLE_MAP[entity] || entity; try { // Get table schema from SQLite const columns = this.db.prepare(`PRAGMA table_info(${tableName})`).all(); for (const column of columns) { const fieldDef = { name: column.name, type: this.mapSQLiteType(column.type), nullable: column.notnull === 0, metadata: { sqlType: column.type, isPrimaryKey: column.pk === 1, defaultValue: column.dflt_value } }; // Special handling for JSON columns if (column.name === 'data_json' || column.name.endsWith('_json')) { fieldDef.type = 'json'; fieldDef.jsonSchema = this.getJsonSchemaForEntity(entity); // Add nested fields from JSON const jsonFields = await this.discoverJsonFields(entity, column.name); fields.push(...jsonFields); } fields.push(fieldDef); } // Add special virtual fields for flags/experiments if (entity === 'flag' || entity === 'flags') { // Add environment field that maps to flag_environments table fields.push({ name: 'environment', type: 'string', nullable: true, computed: false, metadata: { isVirtual: true, requiresJoin: 'flag_environments', joinField: 'environment_key', description: 'Environment key from flag_environments relationship' } }); // Add enabled field - DO NOT mark as requiresJoin to prevent 700x inflation // The field resolution strategy will handle this intelligently fields.push({ name: 'enabled', type: 'boolean', nullable: true, computed: false, metadata: { isVirtual: false, // Changed to false to prevent auto-JOIN description: 'Enabled status - handled by field resolution strategy' } }); // Add environment_key field - DO NOT mark as requiresJoin to prevent 700x inflation fields.push({ name: 'environment_key', type: 'string', nullable: true, computed: false, metadata: { isVirtual: false, // Changed to false to prevent auto-JOIN description: 'Environment key - handled by field resolution strategy' } }); // Add status field mapping to archived fields.push({ name: 'status', type: 'string', nullable: true, computed: true, metadata: { isVirtual: true, mappedField: 'archived', description: 'Status computed from archived field (archived=true -> inactive, archived=false -> active)' } }); } // Add fields from FIELDS definition if available const schemaFields = this.getFieldsFromSchema(entity); for (const schemaField of schemaFields) { if (!fields.find(f => f.name === schemaField.name)) { fields.push(schemaField); } } logger.debug(`Discovered ${fields.length} fields for ${entity}`); return fields; } catch (error) { logger.error(`Failed to discover fields for ${entity}: ${error}`); return []; } } /** * Discover relationships between entities */ async discoverRelationships() { logger.debug('Discovering Optimizely relationships'); const relationships = {}; // Define known relationships in Optimizely data model const knownRelationships = [ // Flag relationships { from: 'flags', to: 'flag_environments', via: 'key', type: 'one-to-many' }, { from: 'flag_environments', to: 'flags', via: 'flag_key', type: 'one-to-one' }, // Experiment relationships { from: 'experiments', to: 'campaigns', via: 'campaign_id', type: 'one-to-one' }, { from: 'experiments', to: 'features', via: 'feature_id', type: 'one-to-one' }, // Project relationships { from: 'projects', to: 'flags', via: 'id', type: 'one-to-many' }, { from: 'projects', to: 'experiments', via: 'id', type: 'one-to-many' }, { from: 'projects', to: 'audiences', via: 'id', type: 'one-to-many' }, { from: 'projects', to: 'events', via: 'id', type: 'one-to-many' }, // Environment relationships { from: 'flags', to: 'environments', via: 'environments', type: 'many-to-many' }, { from: 'experiments', to: 'environments', via: 'environments', type: 'many-to-many' } ]; // Build relationship map for (const rel of knownRelationships) { const fromEntity = this.tableToEntityName(rel.from); const toEntity = this.tableToEntityName(rel.to); if (!relationships[fromEntity]) { relationships[fromEntity] = []; } relationships[fromEntity].push({ from: { entity: fromEntity, field: rel.via }, to: { entity: toEntity, field: rel.via === 'id' ? `${fromEntity}_id` : rel.via }, type: rel.type, nullable: true }); } logger.info('Discovered relationships for Optimizely data model'); return relationships; } /** * Execute a native SQL query */ async executeNativeQuery(query) { logger.debug('Executing native query'); try { let results; if (typeof query === 'string') { // Direct SQL execution logger.debug('EXECUTING SQL STRING:', query); // Track GROUP BY queries if (query.includes('GROUP BY')) { logger.debug('Executing GROUP BY query in OptimizelyAdapter'); logger.debug(`SQL: ${query}`); } results = this.db.prepare(query).all(); // Track GROUP BY results if (query.includes('GROUP BY')) { logger.debug(`GROUP BY query returned ${results.length} rows`); if (results.length > 0) { logger.debug(`Sample GROUP BY result: ${JSON.stringify(results[0])}`); } } } else if (query.sql) { // Parameterized query logger.debug('EXECUTING PARAMETERIZED SQL:', query.sql); logger.debug('WITH PARAMETERS:', query.params); results = this.db.prepare(query.sql).all(...(query.params || [])); } else { throw new Error('Invalid query format'); } return results; } catch (error) { logger.error(`Query execution failed: ${error}`); logger.error('SQL EXECUTION ERROR:', error.message); throw error; } } /** * Get the database connection */ getConnectionPool() { return this.db; } /** * Get adapter capabilities */ getCapabilities() { return { supportsSQL: true, supportsJSONPath: true, // Via JSON_EXTRACT supportsJSONata: false, // Would need external processor supportsAggregations: true, supportsJoins: true, supportsTransactions: true, maxQueryComplexity: 100, optimizedOperations: [ 'COUNT', 'SUM', 'AVG', 'GROUP BY', 'ORDER BY', 'JSON_EXTRACT' ] }; } /** * Convert table name to entity name */ tableToEntityName(tableName) { // Remove plural 's' for entity name if (tableName.endsWith('ies')) { return tableName.slice(0, -3) + 'y'; } else if (tableName.endsWith('s') && !tableName.endsWith('ss')) { return tableName.slice(0, -1); } return tableName; } /** * Check if table has data_json column */ hasDataJsonColumn(tableName) { try { const columns = this.db.prepare(`PRAGMA table_info(${tableName})`).all(); return columns.some(col => col.name === 'data_json'); } catch { return false; } } /** * Map SQLite type to universal field type */ mapSQLiteType(sqliteType) { const upperType = sqliteType.toUpperCase(); // Check for exact match first if (SQLITE_TYPE_MAP[upperType]) { return SQLITE_TYPE_MAP[upperType]; } // Check for partial matches for (const [key, value] of Object.entries(SQLITE_TYPE_MAP)) { if (upperType.includes(key)) { return value; } } return 'unknown'; } /** * Get JSON schema for entity from FIELDS */ getJsonSchemaForEntity(entity) { const entitySchema = FIELDS[entity]; if (!entitySchema) return null; // New FIELDS structure uses required/optional arrays const properties = {}; // Add required fields if (entitySchema.required) { for (const field of entitySchema.required) { properties[field] = { required: true }; } } // Add optional fields if (entitySchema.optional) { for (const field of entitySchema.optional) { properties[field] = { required: false }; } } return { type: 'object', properties, required: entitySchema.required || [] }; } /** * Discover fields within JSON columns */ async discoverJsonFields(entity, jsonColumn) { const fields = []; const entitySchema = FIELDS[entity]; if (!entitySchema) return fields; // Combine required and optional fields const allFields = [ ...(entitySchema.required || []), ...(entitySchema.optional || []) ]; // Add fields that might be in JSON for (const fieldName of allFields) { // Skip if it's a direct column if (this.isDirectColumn(entity, fieldName)) continue; fields.push({ name: fieldName, type: 'unknown', // We don't have type info in new structure nullable: !(entitySchema.required?.includes(fieldName)), jsonPath: `$.${fieldName}`, metadata: { jsonColumn, fromSchema: true } }); } return fields; } /** * Get fields from FIELDS schema */ getFieldsFromSchema(entity) { const fields = []; const entitySchema = FIELDS[entity]; if (!entitySchema) return fields; // Process required fields if (entitySchema.required) { for (const fieldName of entitySchema.required) { fields.push({ name: fieldName, type: 'unknown', nullable: false, computed: false, metadata: { required: true, fromSchema: true } }); } } // Process optional fields if (entitySchema.optional) { for (const fieldName of entitySchema.optional) { fields.push({ name: fieldName, type: 'unknown', nullable: true, computed: false, metadata: { required: false, fromSchema: true } }); } } return fields; } /** * Check if field is a direct column */ isDirectColumn(entity, fieldName) { const tableName = ENTITY_TABLE_MAP[entity] || entity; const cacheKey = `${tableName}_columns`; if (!this.schemaCache.has(cacheKey)) { try { const columns = this.db.prepare(`PRAGMA table_info(${tableName})`).all(); this.schemaCache.set(cacheKey, new Set(columns.map(c => c.name))); } catch { return false; } } const columnSet = this.schemaCache.get(cacheKey); return columnSet?.has(fieldName) || false; } /** * Map schema field type to universal type */ mapFieldType(schemaType) { switch (schemaType) { case 'string': return 'string'; case 'number': case 'integer': return 'number'; case 'boolean': return 'boolean'; case 'array': return 'array'; case 'object': return 'object'; default: return 'unknown'; } } /** * Get virtual entities (for special relationships) */ getVirtualEntities() { return [ { name: 'flag_environment', type: 'virtual', description: 'Flag-environment relationship data', metadata: { sourceTable: 'flag_environments', joinRequired: true } }, { name: 'environment', type: 'virtual', description: 'Environment data extracted from entities', metadata: { jsonPath: '$.environments', availableIn: ['flags', 'experiments'] } } ]; } } //# sourceMappingURL=OptimizelyAdapter.js.map