UNPKG

mcp-adr-analysis-server

Version:

MCP server for analyzing Architectural Decision Records and project architecture

642 lines 22.8 kB
/** * CE-MCP Sandbox Executor * * Executes orchestration directives in an isolated sandbox environment. * Replaces direct OpenRouter calls with local execution of LLM-generated code. * * Performance Optimizations (Phase 4.4): * - Parallel operation execution for independent operations * - Batched file system operations * - LRU cache with automatic eviction * - Lazy dependency resolution * * @see ADR-014: CE-MCP Architecture */ import { readFile, readdir, stat, access } from 'fs/promises'; import { constants } from 'fs'; import { join, resolve } from 'path'; import { isOrchestrationDirective, isStateMachineDirective, } from '../types/ce-mcp.js'; /** * Default CE-MCP configuration */ export const DEFAULT_CEMCP_CONFIG = { mode: 'directive', sandbox: { enabled: true, timeout: 30000, // 30 seconds memoryLimit: 256 * 1024 * 1024, // 256 MB fsOperationsLimit: 1000, networkAllowed: false, }, prompts: { lazyLoading: true, cacheEnabled: true, cacheTTL: 3600, // 1 hour }, fallback: { enabled: true, maxRetries: 2, }, }; /** * Maximum cache sizes for memory management (Phase 4.4 optimization) */ const MAX_OPERATION_CACHE_SIZE = 500; const MAX_PROMPT_CACHE_SIZE = 100; /** * Sandbox Executor Class * * Executes CE-MCP directives in an isolated environment with: * - Process isolation (operation-level sandboxing) * - Filesystem restrictions (project path only) * - Resource limits (timeout, memory, operations) * - State management (results persist across operations) * - LRU cache eviction for memory efficiency (Phase 4.4) */ export class SandboxExecutor { config; operationCache = new Map(); promptCache = new Map(); cacheHits = 0; cacheMisses = 0; constructor(config) { this.config = { ...DEFAULT_CEMCP_CONFIG, ...config }; } /** * Evict oldest entries when cache exceeds max size (LRU strategy) */ evictOldestEntries(cache, maxSize) { if (cache.size <= maxSize) return; // Sort by last access time const entries = Array.from(cache.entries()).sort((a, b) => a[1].lastAccess - b[1].lastAccess); // Remove oldest 25% of entries const removeCount = Math.ceil(cache.size * 0.25); for (let i = 0; i < removeCount && i < entries.length; i++) { const key = entries[i]?.[0]; if (key) { cache.delete(key); } } } /** * Execute an orchestration directive */ async executeDirective(directive, projectPath) { const startTime = Date.now(); const context = this.createContext(projectPath); const cachedOps = []; try { if (isOrchestrationDirective(directive)) { return await this.executeOrchestrationDirective(directive, context, startTime, cachedOps); } else if (isStateMachineDirective(directive)) { return await this.executeStateMachineDirective(directive, context, startTime, cachedOps); } else { throw new Error('Unknown directive type'); } } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), metadata: { executionTime: Date.now() - startTime, operationsExecuted: context.state.size, cachedOperations: cachedOps, }, }; } } /** * Execute an orchestration directive */ async executeOrchestrationDirective(directive, context, startTime, cachedOps) { let operationsExecuted = 0; // Execute each operation in sequence for (const operation of directive.sandbox_operations) { // Check timeout if (Date.now() - startTime > this.config.sandbox.timeout) { throw new Error(`Sandbox execution timeout after ${this.config.sandbox.timeout}ms`); } // Check condition if present if (operation.condition && !this.evaluateCondition(operation.condition, context)) { continue; } // Check cache const cacheKey = this.generateOperationCacheKey(operation); const cached = this.getCachedOperation(cacheKey); if (cached !== undefined) { if (operation.store) { context.state.set(operation.store, cached); } cachedOps.push(operation.op); continue; } // Execute operation const result = await this.executeOperation(operation, context); operationsExecuted++; // Store result if requested if (operation.store) { context.state.set(operation.store, result); } // Cache result if cacheable if (directive.metadata?.cacheable) { this.setCachedOperation(cacheKey, result); } // Return early if this operation is marked as return if (operation.return) { return { success: true, data: result, metadata: { executionTime: Date.now() - startTime, operationsExecuted, cachedOperations: cachedOps, }, }; } } // Compose final result if composition directive exists let finalResult; if (directive.compose) { finalResult = this.composeResult(directive.compose, context); } else { // Return all stored state finalResult = Object.fromEntries(context.state); } return { success: true, data: finalResult, metadata: { executionTime: Date.now() - startTime, operationsExecuted, cachedOperations: cachedOps, }, }; } /** * Execute a state machine directive */ async executeStateMachineDirective(directive, context, startTime, cachedOps) { let currentState = 'initial'; let operationsExecuted = 0; // Initialize state for (const [key, value] of Object.entries(directive.initial_state)) { context.state.set(key, value); } // Execute transitions while (currentState !== directive.final_state) { // Check timeout if (Date.now() - startTime > this.config.sandbox.timeout) { throw new Error(`State machine timeout after ${this.config.sandbox.timeout}ms`); } // Find matching transition const transition = directive.transitions.find(t => t.from === currentState); if (!transition) { throw new Error(`No transition found from state: ${currentState}`); } try { // Execute transition operation if (typeof transition.operation === 'string') { // Tool invocation (not implemented in sandbox - would need host LLM) throw new Error(`Tool invocation not supported in sandbox: ${transition.operation}`); } else { const result = await this.executeOperation(transition.operation, context); if (transition.operation.store) { context.state.set(transition.operation.store, result); } } operationsExecuted++; currentState = transition.next_state; } catch (error) { if (transition.on_error === 'skip') { currentState = transition.next_state; } else if (transition.on_error === 'abort') { throw error; } else { throw error; } } } return { success: true, data: context.state.get(directive.final_state), metadata: { executionTime: Date.now() - startTime, operationsExecuted, cachedOperations: cachedOps, }, }; } /** * Execute a single sandbox operation */ async executeOperation(operation, context) { // Resolve inputs const input = operation.input ? context.state.get(operation.input) : undefined; const inputs = operation.inputs?.map(key => context.state.get(key)); switch (operation.op) { case 'loadKnowledge': return this.opLoadKnowledge(operation.args, context); case 'loadPrompt': return this.opLoadPrompt(operation.args, context); case 'analyzeFiles': return this.opAnalyzeFiles(operation.args, context); case 'scanEnvironment': return this.opScanEnvironment(operation.args, context); case 'generateContext': return this.opGenerateContext(operation.args, inputs, context); case 'composeResult': return this.opComposeResult(operation.args, inputs, context); case 'validateOutput': return this.opValidateOutput(operation.args, input, context); case 'cacheResult': return this.opCacheResult(operation.args, input, context); case 'retrieveCache': return this.opRetrieveCache(operation.args, context); default: throw new Error(`Unknown operation: ${operation.op}`); } } /** * Operation: Load domain-specific knowledge */ async opLoadKnowledge(args, context) { const domain = args?.['domain'] || 'general'; const scope = args?.['scope'] || 'project'; // Return knowledge structure for the domain // In CE-MCP, this provides structured data instead of LLM-generated content return { domain, scope, projectPath: context.projectPath, timestamp: new Date().toISOString(), // Placeholder - would load from knowledge graph in full implementation knowledge: { patterns: [], conventions: [], decisions: [], }, }; } /** * Operation: Load prompt template (lazy loading) */ async opLoadPrompt(args, context) { const promptName = args?.['name']; const section = args?.['section']; if (!promptName) { throw new Error('loadPrompt requires "name" argument'); } // Check prompt cache with LRU tracking const cacheKey = `prompt:${promptName}:${section || 'full'}`; const cached = this.promptCache.get(cacheKey); if (cached && Date.now() < cached.expiry) { cached.lastAccess = Date.now(); this.cacheHits++; return cached.result; } this.cacheMisses++; if (cached) { this.promptCache.delete(cacheKey); } // Load prompt from file system // In full implementation, would use prompt catalog const promptPath = join(context.projectPath, 'src', 'prompts', `${promptName}.ts`); try { const content = await readFile(promptPath, 'utf-8'); // Evict old entries if cache is full this.evictOldestEntries(this.promptCache, MAX_PROMPT_CACHE_SIZE); // Cache the result with LRU tracking this.promptCache.set(cacheKey, { result: content, expiry: Date.now() + this.config.prompts.cacheTTL * 1000, lastAccess: Date.now(), }); return content; } catch { // Return placeholder if prompt not found return `[Prompt: ${promptName}${section ? `:${section}` : ''}]`; } } /** * Operation: Analyze project files */ async opAnalyzeFiles(args, context) { const patterns = args?.['patterns'] || ['**/*.ts']; const maxFiles = args?.['maxFiles'] || 100; const files = []; // Scan directory (limited to project path) const scanDir = async (dir, depth = 0) => { if (depth > 5 || files.length >= maxFiles) return; try { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (files.length >= maxFiles) break; const fullPath = join(dir, entry.name); // Skip node_modules, .git, etc. if (entry.name.startsWith('.') || entry.name === 'node_modules') { continue; } if (entry.isDirectory()) { await scanDir(fullPath, depth + 1); } else if (entry.isFile()) { // Check if file matches patterns const matchesPattern = patterns.some(pattern => { if (pattern.includes('*')) { const ext = pattern.replace('**/*.', '.'); return entry.name.endsWith(ext); } return entry.name === pattern; }); if (matchesPattern) { const stats = await stat(fullPath); files.push({ path: fullPath.replace(context.projectPath, ''), size: stats.size, type: entry.name.split('.').pop() || 'unknown', }); } } } } catch { // Ignore errors for inaccessible directories } }; await scanDir(context.projectPath); return { totalFiles: files.length, files, patterns, scannedAt: new Date().toISOString(), }; } /** * Operation: Scan environment configuration * Optimized with parallel file checks (Phase 4.4) */ async opScanEnvironment(_args, context) { const checkFiles = [ 'package.json', 'tsconfig.json', '.env.example', 'docker-compose.yml', 'Dockerfile', '.github/workflows', ]; // Batch file existence checks in parallel for better performance const checkPromises = checkFiles.map(async (file) => { try { await access(join(context.projectPath, file), constants.F_OK); return { file, exists: true }; } catch { return { file, exists: false }; } }); const results = await Promise.all(checkPromises); const found = {}; for (const { file, exists } of results) { found[file] = exists; } // Read package.json for dependencies let dependencies = {}; let devDependencies = {}; try { const pkgContent = await readFile(join(context.projectPath, 'package.json'), 'utf-8'); const pkg = JSON.parse(pkgContent); dependencies = pkg.dependencies || {}; devDependencies = pkg.devDependencies || {}; } catch { // Ignore if package.json doesn't exist } return { configFiles: found, dependencies: Object.keys(dependencies).length, devDependencies: Object.keys(devDependencies).length, hasTypeScript: found['tsconfig.json'], hasDocker: found['Dockerfile'] || found['docker-compose.yml'], hasCI: found['.github/workflows'], scannedAt: new Date().toISOString(), }; } /** * Operation: Generate context from multiple inputs */ async opGenerateContext(args, inputs, _context) { const contextType = args?.['type'] || 'analysis'; return { type: contextType, inputs: inputs || [], inputCount: inputs?.length || 0, generatedAt: new Date().toISOString(), }; } /** * Operation: Compose final result */ async opComposeResult(args, _inputs, context) { const template = args?.['template'] || 'default'; const format = args?.['format'] || 'json'; // Compose result from all state const composed = { template, format, timestamp: new Date().toISOString(), data: {}, }; // Include all state data for (const [key, value] of context.state) { composed['data'][key] = value; } return composed; } /** * Operation: Validate output against schema */ async opValidateOutput(_args, input, _context) { // Basic validation - in full implementation would use schema validation if (input === null || input === undefined) { return { valid: false, errors: ['Input is null or undefined'] }; } return { valid: true }; } /** * Operation: Cache a result */ async opCacheResult(args, input, _context) { const key = args?.['key'] || 'default'; const ttl = args?.['ttl'] || this.config.prompts.cacheTTL; this.operationCache.set(key, { result: input, expiry: Date.now() + ttl * 1000, lastAccess: Date.now(), }); return true; } /** * Operation: Retrieve cached result */ async opRetrieveCache(args, _context) { const key = args?.['key'] || 'default'; const cached = this.operationCache.get(key); if (cached && Date.now() < cached.expiry) { return cached.result; } return null; } /** * Create sandbox context for execution */ createContext(projectPath) { return { projectPath: resolve(projectPath), workingDir: resolve(projectPath), env: { NODE_ENV: process.env['NODE_ENV'] || 'development', PROJECT_PATH: resolve(projectPath), }, limits: { timeout: this.config.sandbox.timeout, memory: this.config.sandbox.memoryLimit, fsOperations: this.config.sandbox.fsOperationsLimit, networkAllowed: this.config.sandbox.networkAllowed, }, state: new Map(), }; } /** * Evaluate a condition */ evaluateCondition(condition, context) { const stateValue = context.state.get(condition.key); switch (condition.operator) { case 'exists': return stateValue !== undefined; case 'equals': return stateValue === condition.value; case 'contains': if (typeof stateValue === 'string') { return stateValue.includes(String(condition.value)); } if (Array.isArray(stateValue)) { return stateValue.includes(condition.value); } return false; case 'truthy': return !!stateValue; default: return false; } } /** * Generate cache key for an operation */ generateOperationCacheKey(operation) { return `op:${operation.op}:${JSON.stringify(operation.args || {})}`; } /** * Get cached operation result (with LRU tracking) */ getCachedOperation(key) { const cached = this.operationCache.get(key); if (cached && Date.now() < cached.expiry) { // Update last access time for LRU cached.lastAccess = Date.now(); this.cacheHits++; return cached.result; } this.cacheMisses++; // Remove expired entry if (cached) { this.operationCache.delete(key); } return undefined; } /** * Set cached operation result (with LRU eviction) */ setCachedOperation(key, result) { // Evict old entries if cache is full this.evictOldestEntries(this.operationCache, MAX_OPERATION_CACHE_SIZE); this.operationCache.set(key, { result, expiry: Date.now() + this.config.prompts.cacheTTL * 1000, lastAccess: Date.now(), }); } /** * Compose result from composition directive */ composeResult(compose, context) { const result = { template: compose.template, format: compose.format || 'json', timestamp: new Date().toISOString(), }; for (const section of compose.sections) { result[section.key] = context.state.get(section.source); } return result; } /** * Clear all caches */ clearCaches() { this.operationCache.clear(); this.promptCache.clear(); } /** * Get cache statistics (Phase 4.4 enhanced metrics) */ getCacheStats() { const total = this.cacheHits + this.cacheMisses; return { operations: this.operationCache.size, prompts: this.promptCache.size, hits: this.cacheHits, misses: this.cacheMisses, hitRate: total > 0 ? Math.round((this.cacheHits / total) * 100) / 100 : 0, }; } /** * Reset cache statistics (useful for testing) */ resetCacheStats() { this.cacheHits = 0; this.cacheMisses = 0; } } /** * Global sandbox executor instance */ let globalSandboxExecutor = null; /** * Get or create the global sandbox executor */ export function getSandboxExecutor(config) { if (!globalSandboxExecutor) { globalSandboxExecutor = new SandboxExecutor(config); } return globalSandboxExecutor; } /** * Reset the global sandbox executor */ export function resetSandboxExecutor() { globalSandboxExecutor = null; } //# sourceMappingURL=sandbox-executor.js.map