Skip to main content
Glama
RemoteConfigCacheService.ts8.18 kB
/** * RemoteConfigCacheService * * DESIGN PATTERNS: * - Service pattern for cache management * - Single responsibility principle * - File-based caching with TTL support * * CODING STANDARDS: * - Use async/await for asynchronous operations * - Handle file system errors gracefully * - Keep cache organized by URL hash * - Implement automatic cache expiration * * AVOID: * - Storing sensitive data in cache (headers with tokens) * - Unbounded cache growth * - Missing error handling for file operations */ import { createHash } from 'node:crypto'; import { readFile, writeFile, mkdir, readdir, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import type { RemoteMcpConfiguration } from '../types'; interface CacheEntry { data: RemoteMcpConfiguration; timestamp: number; expiresAt: number; url: string; } /** * Service for caching remote MCP configurations */ export class RemoteConfigCacheService { private cacheDir: string; private cacheTTL: number; // Time to live in milliseconds private readEnabled: boolean; // Whether to read from cache private writeEnabled: boolean; // Whether to write to cache constructor(options?: { ttl?: number; readEnabled?: boolean; writeEnabled?: boolean; }) { this.cacheDir = join(tmpdir(), 'one-mcp-cache', 'remote-configs'); this.cacheTTL = options?.ttl || 60 * 60 * 1000; // Default: 1 hour this.readEnabled = options?.readEnabled !== undefined ? options.readEnabled : true; this.writeEnabled = options?.writeEnabled !== undefined ? options.writeEnabled : true; } /** * Generate a hash key from remote config URL * Only uses URL for hashing to avoid caching credentials in the key */ private generateCacheKey(url: string): string { // Generate SHA-256 hash of the URL return createHash('sha256').update(url).digest('hex'); } /** * Get the cache file path for a given cache key */ private getCacheFilePath(cacheKey: string): string { return join(this.cacheDir, `${cacheKey}.json`); } /** * Initialize cache directory * Uses mkdir with recursive option which handles existing directories gracefully * (no TOCTOU race condition from existsSync check) */ private async ensureCacheDir(): Promise<void> { try { await mkdir(this.cacheDir, { recursive: true }); } catch (error: any) { // mkdir with recursive: true normally doesn't throw EEXIST, but handle it just in case if (error?.code !== 'EEXIST') { throw error; } } } /** * Get cached data for a remote config URL */ async get(url: string): Promise<RemoteMcpConfiguration | null> { if (!this.readEnabled) { return null; } try { await this.ensureCacheDir(); const cacheKey = this.generateCacheKey(url); const cacheFilePath = this.getCacheFilePath(cacheKey); if (!existsSync(cacheFilePath)) { return null; } const cacheContent = await readFile(cacheFilePath, 'utf-8'); const cacheEntry: CacheEntry = JSON.parse(cacheContent); // Check if cache has expired const now = Date.now(); if (now > cacheEntry.expiresAt) { // Cache expired, delete it await unlink(cacheFilePath).catch(() => { // Ignore errors }); return null; } const expiresInSeconds = Math.round((cacheEntry.expiresAt - now) / 1000); console.error( `Remote config cache hit for ${url} (expires in ${expiresInSeconds}s)` ); return cacheEntry.data; } catch (error) { console.error(`Failed to read remote config cache for ${url}:`, error); return null; } } /** * Set cached data for a remote config URL */ async set(url: string, data: RemoteMcpConfiguration): Promise<void> { if (!this.writeEnabled) { return; } try { await this.ensureCacheDir(); const cacheKey = this.generateCacheKey(url); const cacheFilePath = this.getCacheFilePath(cacheKey); const now = Date.now(); const cacheEntry: CacheEntry = { data, timestamp: now, expiresAt: now + this.cacheTTL, url, // Store URL for debugging/transparency }; await writeFile(cacheFilePath, JSON.stringify(cacheEntry, null, 2), 'utf-8'); console.error( `Cached remote config for ${url} (TTL: ${Math.round(this.cacheTTL / 1000)}s)` ); } catch (error) { console.error(`Failed to write remote config cache for ${url}:`, error); } } /** * Clear cache for a specific URL */ async clear(url: string): Promise<void> { try { const cacheKey = this.generateCacheKey(url); const cacheFilePath = this.getCacheFilePath(cacheKey); if (existsSync(cacheFilePath)) { await unlink(cacheFilePath); console.error(`Cleared remote config cache for ${url}`); } } catch (error) { console.error(`Failed to clear remote config cache for ${url}:`, error); } } /** * Clear all cached remote configs */ async clearAll(): Promise<void> { try { if (!existsSync(this.cacheDir)) { return; } const files = await readdir(this.cacheDir); const deletePromises = files .filter((file) => file.endsWith('.json')) .map((file) => unlink(join(this.cacheDir, file)).catch(() => {})); await Promise.all(deletePromises); console.error(`Cleared all remote config cache entries (${files.length} files)`); } catch (error) { console.error('Failed to clear all remote config cache:', error); } } /** * Clean up expired cache entries */ async cleanExpired(): Promise<void> { try { if (!existsSync(this.cacheDir)) { return; } const now = Date.now(); const files = await readdir(this.cacheDir); let expiredCount = 0; for (const file of files) { if (!file.endsWith('.json')) continue; const filePath = join(this.cacheDir, file); try { const content = await readFile(filePath, 'utf-8'); const entry: CacheEntry = JSON.parse(content); if (now > entry.expiresAt) { await unlink(filePath); expiredCount++; } } catch (error) { // If we can't read or parse the file, delete it await unlink(filePath).catch(() => {}); expiredCount++; } } if (expiredCount > 0) { console.error(`Cleaned up ${expiredCount} expired remote config cache entries`); } } catch (error) { console.error('Failed to clean expired remote config cache:', error); } } /** * Get cache statistics */ async getStats(): Promise<{ totalEntries: number; totalSize: number }> { try { if (!existsSync(this.cacheDir)) { return { totalEntries: 0, totalSize: 0 }; } const files = await readdir(this.cacheDir); const jsonFiles = files.filter((file) => file.endsWith('.json')); let totalSize = 0; for (const file of jsonFiles) { const filePath = join(this.cacheDir, file); try { const content = await readFile(filePath, 'utf-8'); totalSize += Buffer.byteLength(content, 'utf-8'); } catch { // Ignore errors for individual files } } return { totalEntries: jsonFiles.length, totalSize, }; } catch (error) { console.error('Failed to get remote config cache stats:', error); return { totalEntries: 0, totalSize: 0 }; } } /** * Check if read from cache is enabled */ isReadEnabled(): boolean { return this.readEnabled; } /** * Check if write to cache is enabled */ isWriteEnabled(): boolean { return this.writeEnabled; } /** * Set read enabled state */ setReadEnabled(enabled: boolean): void { this.readEnabled = enabled; } /** * Set write enabled state */ setWriteEnabled(enabled: boolean): void { this.writeEnabled = enabled; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/AgiFlow/aicode-toolkit'

If you have feedback or need assistance with the MCP directory API, please join our Discord server