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
JavaScript
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