mcp-adr-analysis-server
Version:
MCP server for analyzing Architectural Decision Records and project architecture
317 lines • 9.9 kB
JavaScript
/**
* Resource-level caching infrastructure for MCP resources
* Provides efficient caching with TTL support, cache management,
* and conditional request support (ETags, Last-Modified)
*/
import { generateStrongETag } from '../utils/conditional-request.js';
/**
* Resource cache with TTL support and statistics tracking
*/
export class ResourceCache {
cache = new Map();
hits = 0;
misses = 0;
/**
* Get cached resource by key
* @param key Cache key
* @returns Cached data or null if not found/expired
*/
async get(key) {
const entry = this.cache.get(key);
if (!entry) {
this.misses++;
return null;
}
// Check if expired
if (entry.expiry <= Date.now()) {
this.cache.delete(key);
this.misses++;
return null;
}
// Update access stats
entry.accessCount++;
entry.lastAccessed = Date.now();
this.hits++;
return entry.data;
}
/**
* Get cached resource with conditional request support
* @param key Cache key
* @param conditionalHeaders Optional conditional headers
* @returns Result with data, metadata, and notModified flag
*/
async getWithConditional(key, conditionalHeaders) {
const entry = this.cache.get(key);
if (!entry) {
this.misses++;
return { data: null, notModified: false };
}
// Check if expired
if (entry.expiry <= Date.now()) {
this.cache.delete(key);
this.misses++;
return { data: null, notModified: false };
}
// Check conditional request headers
if (conditionalHeaders && entry.etag && entry.lastModified) {
const { ifNoneMatch, ifModifiedSince } = conditionalHeaders;
// Import conditional request utilities
const { evaluateConditionalRequest } = await import('../utils/conditional-request.js');
// Build params object without undefined values
const params = {
currentETag: entry.etag,
lastModified: entry.lastModified,
};
if (ifNoneMatch) {
params.ifNoneMatch = ifNoneMatch;
}
if (ifModifiedSince) {
params.ifModifiedSince = ifModifiedSince;
}
const result = evaluateConditionalRequest(params);
if (result.notModified) {
// Resource not modified, don't return data
entry.accessCount++;
entry.lastAccessed = Date.now();
this.hits++;
const response = {
data: null,
notModified: true,
etag: entry.etag,
lastModified: entry.lastModified,
};
if (entry.version) {
response.version = entry.version;
}
if (entry.metadata) {
response.metadata = entry.metadata;
}
return response;
}
}
// Update access stats
entry.accessCount++;
entry.lastAccessed = Date.now();
this.hits++;
// Build return object without undefined values
const result = {
data: entry.data,
notModified: false,
};
if (entry.etag) {
result.etag = entry.etag;
}
if (entry.lastModified) {
result.lastModified = entry.lastModified;
}
if (entry.version) {
result.version = entry.version;
}
if (entry.metadata) {
result.metadata = entry.metadata;
}
return result;
}
/**
* Set cached resource with TTL
* @param key Cache key
* @param data Data to cache
* @param ttl Time to live in seconds
* @param metadata Optional metadata (etag, lastModified, version, resourceMetadata)
*/
set(key, data, ttl, metadata) {
const now = Date.now();
const lastModified = metadata?.lastModified || new Date(now).toISOString();
const etag = metadata?.etag || generateStrongETag(data);
const version = metadata?.version;
const resourceMetadata = metadata?.resourceMetadata;
const entry = {
data,
expiry: now + ttl * 1000,
createdAt: now,
accessCount: 0,
lastAccessed: now,
etag,
lastModified,
};
if (version) {
entry.version = version;
}
if (resourceMetadata) {
entry.metadata = resourceMetadata;
}
this.cache.set(key, entry);
}
/**
* Check if key exists and is valid
* @param key Cache key
* @returns True if key exists and is not expired
*/
has(key) {
const entry = this.cache.get(key);
if (!entry)
return false;
if (entry.expiry <= Date.now()) {
this.cache.delete(key);
return false;
}
return true;
}
/**
* Delete cached resource
* @param key Cache key
* @returns True if key was deleted
*/
delete(key) {
return this.cache.delete(key);
}
/**
* Clear cache entries matching pattern
* @param pattern Optional pattern to match keys (substring match)
*/
clear(pattern) {
if (!pattern) {
this.cache.clear();
this.hits = 0;
this.misses = 0;
return;
}
const keysToDelete = [];
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.cache.delete(key));
}
/**
* Clean up expired entries
* @returns Number of entries removed
*/
cleanup() {
const now = Date.now();
let removed = 0;
for (const [key, entry] of this.cache.entries()) {
if (entry.expiry <= now) {
this.cache.delete(key);
removed++;
}
}
return removed;
}
/**
* Get cache statistics
* @returns Cache statistics object
*/
getStats() {
const entries = Array.from(this.cache.values());
const now = Date.now();
const validEntries = entries.filter(e => e.expiry > now);
const expiredEntries = entries.filter(e => e.expiry <= now);
const totalRequests = this.hits + this.misses;
const hitRate = totalRequests > 0 ? this.hits / totalRequests : 0;
return {
totalEntries: this.cache.size,
validEntries: validEntries.length,
expiredEntries: expiredEntries.length,
cacheSize: JSON.stringify(entries).length,
hitRate,
totalHits: this.hits,
totalMisses: this.misses,
};
}
/**
* Get all cache keys
* @returns Array of all cache keys
*/
keys() {
return Array.from(this.cache.keys());
}
/**
* Get cache entries sorted by access count
* @param limit Maximum number of entries to return
* @returns Top entries by access count
*/
getTopEntries(limit = 10) {
const entries = Array.from(this.cache.entries())
.map(([key, entry]) => ({
key,
accessCount: entry.accessCount,
}))
.sort((a, b) => b.accessCount - a.accessCount)
.slice(0, limit);
return entries;
}
/**
* Get least recently used entries
* @param limit Maximum number of entries to return
* @returns Least recently used entries
*/
getLRUEntries(limit = 10) {
const entries = Array.from(this.cache.entries())
.map(([key, entry]) => ({
key,
lastAccessed: entry.lastAccessed,
}))
.sort((a, b) => a.lastAccessed - b.lastAccessed)
.slice(0, limit);
return entries;
}
/**
* Evict least recently used entries to reduce cache size
* @param targetSize Target number of entries
* @returns Number of entries evicted
*/
evictLRU(targetSize) {
if (this.cache.size <= targetSize)
return 0;
const entriesToEvict = this.cache.size - targetSize;
const lruEntries = this.getLRUEntries(entriesToEvict);
lruEntries.forEach(({ key }) => this.cache.delete(key));
return entriesToEvict;
}
/**
* Reset cache statistics
*/
resetStats() {
this.hits = 0;
this.misses = 0;
}
}
/**
* Singleton resource cache instance
*/
export const resourceCache = new ResourceCache();
/**
* Start automatic cleanup interval
* Cleans up expired entries every 5 minutes
*/
let cleanupInterval = null;
export function startAutomaticCleanup(intervalMs = 5 * 60 * 1000) {
if (cleanupInterval) {
clearInterval(cleanupInterval);
}
cleanupInterval = setInterval(() => {
const removed = resourceCache.cleanup();
if (removed > 0) {
// NOTE: All console output goes to stderr to preserve stdout for MCP JSON-RPC
console.error(`[ResourceCache] Cleaned up ${removed} expired entries`);
}
}, intervalMs);
}
export function stopAutomaticCleanup() {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}
/**
* Generate ETag for resource data
* @deprecated Use generateStrongETag from conditional-request.ts instead
* @param data Resource data
* @returns ETag string
*/
export function generateETag(data) {
return generateStrongETag(data);
}
//# sourceMappingURL=resource-cache.js.map