Skip to main content
Glama

1MCP Server

fileStorageService.ts13.3 kB
import fs from 'fs'; import path from 'path'; import { ExpirableData } from '@src/auth/sessionTypes.js'; import { AUTH_CONFIG, FILE_PREFIX_MAPPING, getGlobalConfigDir, STORAGE_SUBDIRS } from '@src/constants.js'; import logger from '@src/logger/logger.js'; /** * Generic file storage service with unified cleanup for all expirable data types. * * This service provides a common foundation for storing sessions, auth codes, * auth requests, and client data with automatic cleanup of expired items. * * Features: * - Generic CRUD operations for any expirable data type * - Unified periodic cleanup every 5 minutes * - Path traversal protection * - Automatic directory creation * - Corruption handling (removes corrupted files) */ export class FileStorageService { private storageDir: string; private cleanupInterval: ReturnType<typeof setInterval> | null = null; constructor(baseDir?: string, subDir?: string) { const configDir = baseDir || getGlobalConfigDir(); const sessionsDir = AUTH_CONFIG.SERVER.STORAGE.DIR; // If subDir provided, use sessions/subDir/, otherwise just sessions/ this.storageDir = subDir ? path.join(configDir, sessionsDir, subDir) : path.join(configDir, sessionsDir); this.ensureDirectory(); this.migrateOldFilesIfNeeded(); this.startPeriodicCleanup(); } /** * Ensures the storage directory exists */ private ensureDirectory(): void { try { if (!fs.existsSync(this.storageDir)) { fs.mkdirSync(this.storageDir, { recursive: true }); logger.info(`Created storage directory: ${this.storageDir}`); } } catch (error) { logger.error(`Failed to create storage directory: ${error}`); throw error; } } /** * Extracts UUID part from an ID by removing the prefix */ private extractUuidPart(id: string, prefix: string): string { if (!id.startsWith(prefix)) { throw new Error(`Invalid ID prefix: expected ${prefix}, got ${id}`); } return id.substring(prefix.length); } /** * Migrates old file structure to new subdirectory structure * Handles two migration paths: * 1. Server sessions: sessions/ (flat) → sessions/server/ * 2. Client sessions: clientSessions/ → sessions/client/ * 3. Transport sessions: No migration (new feature) */ private migrateOldFilesIfNeeded(): void { // Determine current subdirectory const currentSubDir = this.getCurrentSubDir(); if (!currentSubDir) { return; // Not in subdirectory mode } // No migration needed for transport (new feature) if (currentSubDir === STORAGE_SUBDIRS.TRANSPORT) { return; } const configDir = path.dirname(path.dirname(this.storageDir)); // Get config root // Determine source directory based on subdirectory type let sourceDir: string; if (currentSubDir === STORAGE_SUBDIRS.CLIENT) { // Client sessions: migrate from clientSessions/ sourceDir = path.join(configDir, 'clientSessions'); } else { // Server sessions: migrate from sessions/ (flat) sourceDir = path.join(configDir, AUTH_CONFIG.SERVER.STORAGE.DIR); } if (!fs.existsSync(sourceDir)) { return; // No legacy directory to migrate from } // Check subdirectory-specific migration flag const migrationFlagPath = path.join(sourceDir, `.migrated-to-${currentSubDir}`); if (fs.existsSync(migrationFlagPath)) { logger.debug(`Migration from ${sourceDir} to ${currentSubDir} already completed`); return; } const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.json')); if (files.length === 0) { this.createMigrationFlag(sourceDir, currentSubDir); return; } let migrationCount = 0; // Migrate files matching current subdirectory's prefixes for (const file of files) { const shouldMigrate = this.shouldMigrateFile(file, currentSubDir); if (shouldMigrate) { const oldPath = path.join(sourceDir, file); const newPath = path.join(this.storageDir, file); try { fs.renameSync(oldPath, newPath); migrationCount++; logger.info(`Migrated ${file} from ${sourceDir} to ${this.storageDir}`); } catch (error) { logger.error(`Failed to migrate ${file}: ${error}`); } } } if (migrationCount > 0) { this.createMigrationFlag(sourceDir, currentSubDir); logger.info(`Migration completed: ${migrationCount} files migrated to ${currentSubDir}/`); } else { this.createMigrationFlag(sourceDir, currentSubDir); } } /** * Creates migration completion flag file */ private createMigrationFlag(sourceDir: string, targetSubDir: string): void { try { const migrationFlagPath = path.join(sourceDir, `.migrated-to-${targetSubDir}`); fs.writeFileSync( migrationFlagPath, JSON.stringify({ migrated: true, targetSubDir, timestamp: Date.now(), }), ); logger.debug(`Created migration flag: .migrated-to-${targetSubDir} in ${sourceDir}`); } catch (error) { logger.warn(`Failed to create migration flag: ${error}`); } } /** * Extract current subdirectory name from storage directory path */ private getCurrentSubDir(): string | null { const subdirValues = Object.values(STORAGE_SUBDIRS); for (const subdir of subdirValues) { if (this.storageDir.endsWith(path.sep + subdir)) { return subdir; } } return null; } /** * Check if file should be migrated to current subdirectory based on prefix */ private shouldMigrateFile(fileName: string, targetSubDir: string): boolean { // Get prefixes for target subdirectory const prefixMapping: Record<string, readonly string[]> = { [STORAGE_SUBDIRS.SERVER]: FILE_PREFIX_MAPPING.SERVER, [STORAGE_SUBDIRS.CLIENT]: FILE_PREFIX_MAPPING.CLIENT, [STORAGE_SUBDIRS.TRANSPORT]: FILE_PREFIX_MAPPING.TRANSPORT, }; const prefixes = prefixMapping[targetSubDir]; if (!prefixes) return false; return prefixes.some((prefix) => fileName.startsWith(prefix)); } /** * Gets the file path for a given prefix and ID */ public getFilePath(filePrefix: string, id: string): string { if (!this.isValidId(id)) { throw new Error(`Invalid ID format: ${id}`); } const fileName = `${filePrefix}${id}${AUTH_CONFIG.SERVER.STORAGE.FILE_EXTENSION}`; const filePath = path.resolve(this.storageDir, fileName); // Security check: ensure resolved path is within storage directory const normalizedStorageDir = path.resolve(this.storageDir); const normalizedFilePath = path.resolve(filePath); if (!normalizedFilePath.startsWith(normalizedStorageDir + path.sep)) { throw new Error('Invalid file path: outside storage directory'); } return filePath; } /** * Validates ID format for security */ private isValidId(id: string): boolean { // Check minimum length (prefix + content) if (!id || id.length < 8) { return false; } // Check for valid server-side prefix const serverPrefixes = [ AUTH_CONFIG.SERVER.SESSION.ID_PREFIX, AUTH_CONFIG.SERVER.AUTH_CODE.ID_PREFIX, AUTH_CONFIG.SERVER.AUTH_REQUEST.ID_PREFIX, AUTH_CONFIG.SERVER.STREAMABLE_SESSION.ID_PREFIX, ]; for (const prefix of serverPrefixes) { if (id.startsWith(prefix)) { try { const uuidPart = this.extractUuidPart(id, prefix); // UUID v4 format: 8-4-4-4-12 hexadecimal digits with hyphens const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(uuidPart); } catch { return false; } } } // Check for valid client-side OAuth prefix const clientPrefixes = [ AUTH_CONFIG.CLIENT.PREFIXES.CLIENT, AUTH_CONFIG.CLIENT.PREFIXES.TOKENS, AUTH_CONFIG.CLIENT.PREFIXES.VERIFIER, AUTH_CONFIG.CLIENT.PREFIXES.STATE, ]; for (const prefix of clientPrefixes) { if (id.startsWith(prefix)) { const contentPart = id.substring(prefix.length); return contentPart.length > 0 && /^[a-zA-Z0-9_-]+$/.test(contentPart); } } // Check for client session prefix if (id.startsWith(AUTH_CONFIG.CLIENT.SESSION.ID_PREFIX)) { const contentPart = id.substring(AUTH_CONFIG.CLIENT.SESSION.ID_PREFIX.length); return contentPart.length > 0 && /^[a-zA-Z0-9_-]+$/.test(contentPart); } return false; } /** * Writes data to a file with the specified prefix and ID */ writeData<T extends ExpirableData>(filePrefix: string, id: string, data: T): void { try { const filePath = this.getFilePath(filePrefix, id); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); logger.debug(`Wrote data to ${filePath}`); } catch (error) { logger.error(`Failed to write data for ${id}: ${error}`); throw error; } } /** * Reads data from a file with the specified prefix and ID * Returns null if file doesn't exist or data is expired */ readData<T extends ExpirableData>(filePrefix: string, id: string): T | null { if (!this.isValidId(id)) { logger.warn(`Rejected readData with invalid ID: ${id}`); return null; } try { const filePath = this.getFilePath(filePrefix, id); if (!fs.existsSync(filePath)) { return null; } const data = fs.readFileSync(filePath, 'utf8'); const parsedData: T = JSON.parse(data) as T; // Check if data is expired if (parsedData.expires < Date.now()) { this.deleteData(filePrefix, id); return null; } return parsedData; } catch (error) { logger.error(`Failed to read data for ${id}: ${error}`); return null; } } /** * Deletes data file with the specified prefix and ID */ deleteData(filePrefix: string, id: string): boolean { if (!this.isValidId(id)) { logger.warn(`Rejected deleteData with invalid ID: ${id}`); return false; } try { const filePath = this.getFilePath(filePrefix, id); if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); logger.debug(`Deleted data file: ${filePath}`); return true; } return false; } catch (error) { logger.error(`Failed to delete data for ${id}: ${error}`); return false; } } /** * Starts periodic cleanup of expired data files */ private startPeriodicCleanup(): void { // Clean up expired data every 5 minutes this.cleanupInterval = setInterval( () => { this.cleanupExpiredData(); }, 5 * 60 * 1000, ); } /** * Unified cleanup for all expired data types */ public cleanupExpiredData(): number { try { const files = fs.readdirSync(this.storageDir); let cleanedCount = 0; for (const file of files) { if (file.endsWith(AUTH_CONFIG.SERVER.STORAGE.FILE_EXTENSION)) { const filePath = path.join(this.storageDir, file); try { const data = fs.readFileSync(filePath, 'utf8'); const parsedData = JSON.parse(data) as { expires?: number }; // Check if expired (all our data types have expires field) if (parsedData.expires && parsedData.expires < Date.now()) { fs.unlinkSync(filePath); cleanedCount++; logger.debug(`Cleaned up expired file: ${file}`); } } catch (error) { // Remove corrupted files logger.warn(`Removing corrupted file ${file}: ${error}`); try { fs.unlinkSync(filePath); cleanedCount++; } catch (unlinkError) { logger.error(`Failed to remove corrupted file ${file}: ${unlinkError}`); } } } } if (cleanedCount > 0) { logger.info(`Cleaned up ${cleanedCount} expired/corrupted files`); } return cleanedCount; } catch (error) { logger.error(`Failed to cleanup expired data: ${error}`); return 0; } } /** * Lists all files in the storage directory that match a given prefix. * * @param filePrefix - The file prefix to filter by (optional) * @returns Array of file names (without directory path) */ listFiles(filePrefix?: string): string[] { try { if (!fs.existsSync(this.storageDir)) { return []; } const files = fs.readdirSync(this.storageDir); return files.filter((file) => { if (!file.endsWith('.json')) { return false; } if (filePrefix) { return file.startsWith(filePrefix); } return true; }); } catch (error) { logger.error(`Failed to list files: ${error}`); return []; } } /** * Gets the storage directory path */ getStorageDir(): string { return this.storageDir; } /** * Graceful shutdown - stops cleanup interval */ shutdown(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; logger.info('FileStorageService cleanup interval stopped'); } } }

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/1mcp-app/agent'

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