UNPKG

openapi-directory-mcp

Version:

Model Context Protocol server for accessing enhanced triple-source OpenAPI directory (APIs.guru + additional APIs + custom imports)

477 lines 14 kB
import { join } from "path"; import { homedir } from "os"; import { mkdirSync, existsSync, readFileSync, writeFileSync, unlinkSync, } from "fs"; import { Logger } from "../utils/logger.js"; export class PersistentCacheManager { constructor(ttlMs = 86400000) { this.persistInterval = 5 * 60 * 1000; // 5 minutes // 24 hours default this.enabled = process.env.DISABLE_CACHE !== "true"; this.ttlMs = ttlMs; this.cacheData = new Map(); // Set up cache directory and file this.cacheDir = process.env.OPENAPI_DIRECTORY_CACHE_DIR || join(homedir(), ".cache", "openapi-directory-mcp"); this.cacheFile = join(this.cacheDir, "cache.json"); this.invalidateFlag = join(this.cacheDir, ".invalidate"); if (this.enabled) { this.initializeCache(); } } initializeCache() { try { // Create cache directory if it doesn't exist if (!existsSync(this.cacheDir)) { mkdirSync(this.cacheDir, { recursive: true }); Logger.info(`Created cache directory: ${this.cacheDir}`); } // Check for invalidation flag before loading cache this.checkInvalidationFlag(); // Load existing cache data if it exists this.loadFromDisk(); Logger.info(`Persistent cache initialized: ${this.cacheFile}`); // Set up automatic persistence interval (disabled in test environment) if (process.env.NODE_ENV !== "test") { this.persistTimer = setInterval(() => { this.persistToDisk(); }, this.persistInterval); } // Clean expired entries on startup this.cleanExpired(); } catch (error) { Logger.error("Failed to initialize persistent cache:", error); this.enabled = false; } } /** * Check for invalidation flag and clear cache if present */ checkInvalidationFlag() { if (existsSync(this.invalidateFlag)) { Logger.info("Cache invalidation flag detected, clearing cache..."); this.cacheData.clear(); try { unlinkSync(this.invalidateFlag); Logger.info("Cache invalidation flag removed"); } catch (error) { Logger.error("Failed to remove invalidation flag:", error); } } } /** * Get value from cache */ get(key) { if (!this.enabled) { return undefined; } // Check for invalidation flag before any cache operation this.checkInvalidationFlag(); try { const entry = this.cacheData.get(key); if (!entry) { return undefined; } // Check TTL expiration if (entry.expires && Date.now() > entry.expires) { this.cacheData.delete(key); return undefined; } Logger.cache("hit", key); return entry.value; } catch (error) { Logger.error(`Cache get error for key ${key}:`, error); return undefined; } } /** * Set value in cache */ set(key, value, ttlMs) { if (!this.enabled) { return false; } try { const effectiveTtl = ttlMs || this.ttlMs; const expires = effectiveTtl > 0 ? Date.now() + effectiveTtl : 0; const entry = { value, expires, created: Date.now(), }; this.cacheData.set(key, entry); const ttlSeconds = effectiveTtl > 0 ? Math.floor(effectiveTtl / 1000) : "never"; Logger.cache("set", key, { ttl: `${ttlSeconds}s` }); return true; } catch (error) { Logger.error(`Cache set error for key ${key}:`, error); return false; } } /** * Delete value from cache */ delete(key) { if (!this.enabled) { return 0; } try { const existed = this.cacheData.has(key); if (existed) { this.cacheData.delete(key); Logger.cache("delete", key); return 1; } return 0; } catch (error) { Logger.error(`Cache delete error for key ${key}:`, error); return 0; } } /** * Clear all cache entries */ clear() { if (!this.enabled) { return; } try { this.cacheData.clear(); // Also delete the cache file if (existsSync(this.cacheFile)) { try { unlinkSync(this.cacheFile); Logger.info("Cache file deleted"); } catch (error) { Logger.error("Failed to delete cache file:", error); } } Logger.cache("clear", "all"); } catch (error) { Logger.error("Cache clear error:", error); } } /** * Get cache statistics */ getStats() { if (!this.enabled) { return { keys: 0, hits: 0, misses: 0, ksize: 0, vsize: 0, }; } const keys = this.keys(); let ksize = 0; let vsize = 0; keys.forEach((key) => { const entry = this.cacheData.get(key); if (entry && entry.value !== undefined) { ksize += Buffer.byteLength(key, "utf8"); vsize += Buffer.byteLength(JSON.stringify(entry.value), "utf8"); } }); return { keys: keys.length, hits: 0, // we don't track hits/misses misses: 0, ksize, vsize, }; } /** * Get all cache keys */ keys() { if (!this.enabled) { return []; } try { return Array.from(this.cacheData.keys()); } catch (error) { console.error("Cache keys error:", error); return []; } } /** * Check if cache has a key */ has(key) { if (!this.enabled) { return false; } try { const entry = this.cacheData.get(key); if (!entry) { return false; } // Check TTL expiration if (entry.expires && Date.now() > entry.expires) { this.cacheData.delete(key); return false; } return true; } catch (error) { console.error(`Cache has error for key ${key}:`, error); return false; } } /** * Get TTL for a key (in milliseconds) */ getTtl(key) { if (!this.enabled) { return undefined; } try { const entry = this.cacheData.get(key); if (!entry || !entry.expires) { return undefined; } const remaining = entry.expires - Date.now(); return remaining > 0 ? remaining : undefined; } catch (error) { console.error(`Cache getTtl error for key ${key}:`, error); return undefined; } } /** * Get cache size information */ getSize() { return this.keys().length; } /** * Get cache memory usage (approximate) */ getMemoryUsage() { const stats = this.getStats(); return stats.vsize + stats.ksize; } /** * Prune expired entries manually */ prune() { this.cleanExpired(); } /** * Set cache enabled/disabled */ setEnabled(enabled) { this.enabled = enabled; if (!enabled && this.cacheData) { this.clear(); } else if (enabled && !this.cacheData) { this.initializeCache(); } } /** * Check if cache is enabled */ isEnabled() { return this.enabled; } /** * Get cache configuration */ getConfig() { return { enabled: this.enabled, ttlSeconds: Math.floor(this.ttlMs / 1000), maxKeys: 1000, // We don't limit keys by default }; } /** * Force persistence to disk */ save() { this.persistToDisk(); } /** * Create invalidation flag file to signal cache refresh needed */ createInvalidationFlag() { if (!this.enabled) { return; } try { writeFileSync(this.invalidateFlag, "", "utf-8"); console.error("Cache invalidation flag created"); } catch (error) { console.error("Failed to create invalidation flag:", error); } } /** * Get cache directory path */ getCacheDir() { return this.cacheDir; } /** * Clean expired entries */ cleanExpired() { if (!this.enabled) { return; } try { const keys = Array.from(this.cacheData.keys()); const now = Date.now(); let cleanedCount = 0; keys.forEach((key) => { const entry = this.cacheData.get(key); if (entry && entry.expires && now > entry.expires) { this.cacheData.delete(key); cleanedCount++; } }); if (cleanedCount > 0) { console.error(`Cleaned ${cleanedCount} expired cache entries`); } } catch (error) { console.error("Cache cleanup error:", error); } } /** * Persist cache to disk */ loadFromDisk() { if (!existsSync(this.cacheFile)) { return; // No cache file exists yet } try { const data = readFileSync(this.cacheFile, "utf8"); const cacheObject = JSON.parse(data); // Convert the object back to a Map this.cacheData = new Map(Object.entries(cacheObject)); console.error(`Loaded ${this.cacheData.size} cache entries from disk`); } catch (error) { console.error("Cache load error:", error); this.cacheData = new Map(); // Start with empty cache on error } } persistToDisk() { if (!this.enabled) { return; } try { // Clean expired entries before persisting this.cleanExpired(); const cacheObject = Object.fromEntries(this.cacheData.entries()); writeFileSync(this.cacheFile, JSON.stringify(cacheObject, null, 2), "utf8"); } catch (error) { console.error("Cache persistence error:", error); } } /** * Invalidate cache keys matching a pattern (supports * wildcard) */ invalidatePattern(pattern) { if (!this.enabled) { return 0; } try { const keys = this.keys(); const regex = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"); let deletedCount = 0; for (const key of keys) { if (regex.test(key)) { if (this.cacheData.delete(key)) { deletedCount++; } } } if (deletedCount > 0) { console.error(`Cache invalidated ${deletedCount} keys matching pattern: ${pattern}`); } return deletedCount; } catch (error) { console.error(`Cache invalidatePattern error for pattern ${pattern}:`, error); return 0; } } /** * Invalidate multiple specific cache keys */ invalidateKeys(keys) { if (!this.enabled) { return 0; } try { let deletedCount = 0; for (const key of keys) { if (this.cacheData.delete(key)) { deletedCount++; } } if (deletedCount > 0) { console.error(`Cache invalidated ${deletedCount} specific keys`); } return deletedCount; } catch (error) { console.error(`Cache invalidateKeys error:`, error); return 0; } } /** * Cache warming - fetch data and store it in cache */ async warmCache(key, fetchFn, ttlMs) { if (!this.enabled) { // If cache is disabled, just fetch and return the data return await fetchFn(); } try { // Check if we already have fresh data const cached = this.get(key); if (cached !== undefined) { return cached; } // Fetch fresh data console.error(`Cache warming: ${key}`); const data = await fetchFn(); // Store in cache this.set(key, data, ttlMs); return data; } catch (error) { console.error(`Cache warmCache error for key ${key}:`, error); // On error, try to fetch without caching return await fetchFn(); } } /** * Clean up and save on shutdown */ destroy() { if (this.enabled) { if (this.persistTimer) { clearInterval(this.persistTimer); } this.cleanExpired(); this.persistToDisk(); } } } //# sourceMappingURL=persistent-manager.js.map