Skip to main content
Glama

1MCP Server

presetManager.ts19.8 kB
import { promises as fs } from 'fs'; import { FSWatcher, watch } from 'fs'; import { join } from 'path'; import { McpConfigManager } from '@src/config/mcpConfigManager.js'; import { getConfigDir } from '@src/constants.js'; import { TagQueryEvaluator } from '@src/domains/preset/parsers/tagQueryEvaluator.js'; import { TagQueryParser } from '@src/domains/preset/parsers/tagQueryParser.js'; import { PresetConfig, PresetListItem, PresetStorage, PresetValidationResult, } from '@src/domains/preset/types/presetTypes.js'; import logger from '@src/logger/logger.js'; import { PresetErrorHandler } from '../services/presetErrorHandler.js'; import { PresetServerChangeDetector } from '../services/presetServerChangeDetector.js'; /** * PresetManager handles dynamic preset storage, validation, and hot-reloading. * Integrates with client notification system for real-time updates. */ export class PresetManager { private static instance: PresetManager | null = null; private presets: Map<string, PresetConfig> = new Map(); private configPath: string; private watcher: FSWatcher | null = null; private reloadTimeout: ReturnType<typeof setTimeout> | null = null; private readonly DEBOUNCE_DELAY = 500; // 500ms debounce delay private notificationCallbacks: Set<(presetName: string) => Promise<void>> = new Set(); private configDirOption?: string; private changeDetector: PresetServerChangeDetector = new PresetServerChangeDetector(); private constructor(configDirOption?: string) { // Store presets in config directory based on CLI option, environment, or default this.configDirOption = configDirOption; const configDir = getConfigDir(configDirOption); this.configPath = join(configDir, 'presets.json'); } public static getInstance(configDirOption?: string): PresetManager { if (!PresetManager.instance) { PresetManager.instance = new PresetManager(configDirOption); } return PresetManager.instance; } /** * Reset the singleton instance. Primarily for testing. */ public static resetInstance(): void { if (PresetManager.instance) { PresetManager.instance.cleanup().catch((error) => { console.warn('Failed to cleanup PresetManager during reset:', error); }); PresetManager.instance = null; } } /** * Initialize preset manager and start file watching */ public async initialize(): Promise<void> { try { await this.loadPresets(); await this.startWatching(); logger.info('PresetManager initialized successfully', { presetsLoaded: this.presets.size, configPath: this.configPath, }); } catch (error) { logger.error('Failed to initialize PresetManager', { error }); throw error; } } /** * Register callback for preset change notifications */ public onPresetChange(callback: (presetName: string) => Promise<void>): void { this.notificationCallbacks.add(callback); } /** * Remove notification callback */ public offPresetChange(callback: (presetName: string) => Promise<void>): void { this.notificationCallbacks.delete(callback); } /** * Load presets from storage file */ private async loadPresets(skipChangeDetectorInit: boolean = false): Promise<void> { try { await this.ensureConfigDirectory(); try { const data = await fs.readFile(this.configPath, 'utf-8'); const storage: PresetStorage = JSON.parse(data); // Clear existing presets this.presets.clear(); // Load presets into memory for (const [name, config] of Object.entries(storage.presets || {})) { this.presets.set(name, config); } logger.debug('Presets loaded from file', { presetCount: this.presets.size, presetNames: Array.from(this.presets.keys()), }); // Initialize change detector with current server lists if (!skipChangeDetectorInit) { await this.initializeChangeDetector(); } } catch (error: any) { if (error.code === 'ENOENT') { // File doesn't exist, start with empty presets logger.info('No preset file found, starting with empty presets'); await this.savePresets(); } else { throw error; } } } catch (error) { logger.error('Failed to load presets', { error }); PresetErrorHandler.throwError( `Failed to load presets: ${error instanceof Error ? error.message : 'Unknown error'}`, { context: 'preset loading', exitCode: 2 }, ); } } /** * Reload presets and notify clients of any server list changes */ private async reloadAndNotifyChanges(): Promise<void> { // Store current server lists before reloading const previousServerLists = new Map<string, string[]>(); for (const presetName of this.presets.keys()) { try { const testResult = await this.testPreset(presetName); previousServerLists.set(presetName, testResult.servers); } catch (error) { logger.warn('Failed to get server list before reload', { presetName, error }); previousServerLists.set(presetName, []); } } // Reload presets from file (skip change detector initialization) await this.loadPresets(true); // Check for changes and notify affected presets const changedPresets: string[] = []; for (const presetName of this.presets.keys()) { try { const newTestResult = await this.testPreset(presetName); const previousServers = previousServerLists.get(presetName) || []; // Manually check for changes by comparing server lists const previousSet = new Set(previousServers); const currentSet = new Set(newTestResult.servers); const hasChanged = previousServers.length !== newTestResult.servers.length || !newTestResult.servers.every((server) => previousSet.has(server)); if (hasChanged) { const added = newTestResult.servers.filter((s) => !previousSet.has(s)); const removed = previousServers.filter((s) => !currentSet.has(s)); logger.info('Detected server list changes for preset', { presetName, added, removed, previousCount: previousServers.length, currentCount: newTestResult.servers.length, }); changedPresets.push(presetName); } // Update the detector with the new server list try { this.changeDetector.updateServerList(presetName, newTestResult.servers); } catch (error) { logger.error('Failed to update change detector for preset', { presetName, error: error instanceof Error ? error.message : 'Unknown error', }); } } catch (error) { logger.error('Failed to check for preset changes', { presetName, error: error instanceof Error ? error.message : 'Unknown error', }); } } // Handle deleted presets const currentPresetNames = new Set(this.presets.keys()); const trackedPresetNames = this.changeDetector.getTrackedPresets(); for (const trackedPresetName of trackedPresetNames) { if (!currentPresetNames.has(trackedPresetName)) { logger.info('Preset was deleted, cleaning up tracking', { presetName: trackedPresetName }); this.changeDetector.removePreset(trackedPresetName); // Note: We don't need to notify for deleted presets as clients will get errors // when trying to use them and will handle gracefully } } // Notify clients for presets with server list changes if (changedPresets.length > 0) { logger.info('Notifying clients of preset changes', { changedPresets, totalChangedPresets: changedPresets.length, }); for (const presetName of changedPresets) { await this.notifyPresetChange(presetName); } } else { logger.debug('No preset server list changes detected, skipping notifications'); } } /** * Initialize change detector with current preset server lists */ private async initializeChangeDetector(): Promise<void> { for (const presetName of this.presets.keys()) { try { const testResult = await this.testPreset(presetName); this.changeDetector.updateServerList(presetName, testResult.servers); logger.debug('Initialized change detector for preset', { presetName, serverCount: testResult.servers.length, }); } catch (error) { logger.warn('Failed to initialize change detector for preset', { presetName, error: error instanceof Error ? error.message : 'Unknown error', }); this.changeDetector.updateServerList(presetName, []); } } } /** * Save presets to storage file */ private async savePresets(): Promise<void> { try { await this.ensureConfigDirectory(); const storage: PresetStorage = { version: '1.0.0', presets: Object.fromEntries(this.presets), }; await fs.writeFile(this.configPath, JSON.stringify(storage, null, 2), 'utf-8'); logger.debug('Presets saved to file', { presetCount: this.presets.size, configPath: this.configPath, }); } catch (error) { logger.error('Failed to save presets', { error }); PresetErrorHandler.throwError( `Failed to save presets: ${error instanceof Error ? error.message : 'Unknown error'}`, { context: 'preset saving', exitCode: 3 }, ); } } /** * Ensure config directory exists */ private async ensureConfigDirectory(): Promise<void> { const configDir = getConfigDir(this.configDirOption); try { await fs.mkdir(configDir, { recursive: true }); } catch (error: any) { if (error.code !== 'EEXIST') { throw error; } } } /** * Start watching preset file for changes */ private async startWatching(): Promise<void> { if (this.watcher) { return; } try { this.watcher = watch(this.configPath, { persistent: false }, async (eventType) => { if (eventType === 'change') { logger.debug('Preset file changed, scheduling reload...'); // Clear any existing debounce timeout if (this.reloadTimeout) { clearTimeout(this.reloadTimeout); } // Schedule debounced reload this.reloadTimeout = setTimeout(async () => { try { await this.reloadAndNotifyChanges(); logger.info('Presets reloaded successfully'); } catch (error) { logger.error('Failed to reload presets', { error }); } finally { this.reloadTimeout = null; } }, this.DEBOUNCE_DELAY); } }); logger.debug('Started watching preset file', { path: this.configPath, debounceDelay: this.DEBOUNCE_DELAY }); } catch (error) { logger.warn('Failed to start preset file watching', { error }); } } /** * Stop watching preset file */ public async cleanup(): Promise<void> { logger.debug('Starting PresetManager cleanup'); try { // Clear any pending debounce timeout if (this.reloadTimeout) { clearTimeout(this.reloadTimeout); this.reloadTimeout = null; logger.debug('Cleared pending reload timeout'); } // Stop file watching if (this.watcher) { this.watcher.close(); this.watcher = null; logger.debug('Stopped watching preset file'); } // Clear all notification callbacks to prevent memory leaks if (this.notificationCallbacks.size > 0) { const callbackCount = this.notificationCallbacks.size; this.notificationCallbacks.clear(); logger.debug('Cleared notification callbacks', { count: callbackCount }); } // Clean up change detector if (this.changeDetector) { // Check if change detector has cleanup method if (typeof this.changeDetector.clear === 'function') { this.changeDetector.clear(); logger.debug('Cleared change detector'); } } // Clear presets from memory if (this.presets.size > 0) { const presetCount = this.presets.size; this.presets.clear(); logger.debug('Cleared presets from memory', { count: presetCount }); } logger.debug('PresetManager cleanup completed successfully'); } catch (error) { logger.error('Error during PresetManager cleanup', { error }); // Don't throw during cleanup to prevent cascading failures } } /** * Create or update a preset */ public async savePreset( name: string, config: Omit<PresetConfig, 'name' | 'created' | 'lastModified'>, ): Promise<void> { const validation = await this.validatePreset(name, config); if (!validation.isValid) { throw new Error(`Invalid preset: ${validation.errors.join('; ')}`); } const now = new Date().toISOString(); const existingPreset = this.presets.get(name); const presetConfig: PresetConfig = { ...config, name, created: existingPreset?.created || now, lastModified: now, }; this.presets.set(name, presetConfig); await this.savePresets(); // Notify clients of preset change await this.notifyPresetChange(name); logger.info('Preset saved successfully', { name, strategy: config.strategy, tagQuery: config.tagQuery, }); } /** * Get a preset by name */ public getPreset(name: string): PresetConfig | null { return this.presets.get(name) || null; } /** * Get all presets as list items */ public getPresetList(): PresetListItem[] { return Array.from(this.presets.values()).map((preset) => ({ name: preset.name, description: preset.description, strategy: preset.strategy, tagQuery: preset.tagQuery, })); } /** * Delete a preset */ public async deletePreset(name: string): Promise<boolean> { if (!this.presets.has(name)) { return false; } this.presets.delete(name); await this.savePresets(); logger.info('Preset deleted successfully', { name }); return true; } /** * Validate a preset configuration */ public async validatePreset( name: string, config: Omit<PresetConfig, 'name' | 'created' | 'lastModified'>, ): Promise<PresetValidationResult> { const errors: string[] = []; const warnings: string[] = []; // Validate name if (!name || typeof name !== 'string') { errors.push('Preset name is required and must be a string'); } else if (name.length > 50) { errors.push('Preset name must be 50 characters or less'); } else if (!/^[a-zA-Z0-9_-]+$/.test(name)) { errors.push('Preset name can only contain letters, numbers, hyphens, and underscores'); } // Validate strategy if (!config.strategy || !['or', 'and', 'advanced'].includes(config.strategy)) { errors.push('Strategy must be one of: or, and, advanced'); } // Validate tag query if (!config.tagQuery || typeof config.tagQuery !== 'object') { errors.push('Tag query is required and must be an object'); } else { try { // Validate JSON query structure const validation = TagQueryEvaluator.validateQuery(config.tagQuery); if (!validation.isValid) { errors.push(...validation.errors.map((err) => `Tag query: ${err}`)); } // Check if query has any meaningful content const queryString = TagQueryEvaluator.queryToString(config.tagQuery); if (!queryString.trim()) { warnings.push('Tag query produces no meaningful filter'); } } catch (error) { errors.push(`Invalid tag query: ${error instanceof Error ? error.message : 'Unknown error'}`); } } return { isValid: errors.length === 0, errors, warnings, }; } /** * Resolve preset to tag expression for filtering */ public resolvePresetToExpression(name: string): string | null { const preset = this.presets.get(name); if (!preset) { logger.warn('Attempted to resolve non-existent preset', { name }); return null; } try { // Convert JSON query to string representation for backward compatibility const expression = TagQueryEvaluator.queryToString(preset.tagQuery); if (!expression || expression.trim() === '') { logger.warn('Preset resolved to empty expression', { name, tagQuery: preset.tagQuery }); return null; } return expression; } catch (error) { logger.error('Failed to resolve preset to expression', { name, error: error instanceof Error ? error.message : 'Unknown error', tagQuery: preset.tagQuery, }); return null; } } /** * Test a preset against current server configuration */ public async testPreset(name: string): Promise<{ servers: string[]; tags: string[] }> { const preset = this.presets.get(name); if (!preset) { throw new Error(`Preset '${name}' not found`); } const mcpConfig = McpConfigManager.getInstance(); const availableServers = mcpConfig.getTransportConfig(); // Find matching servers based on tag expression const matchingServers: string[] = []; const allTags = new Set<string>(); for (const [serverName, serverConfig] of Object.entries(availableServers)) { const serverTags = serverConfig.tags || []; // Add server tags to collection serverTags.forEach((tag: string) => allTags.add(tag)); // Test if server matches preset expression let matches = false; try { // Use unified JSON query evaluator // Convert any legacy $advanced expressions to JSON format first let jsonQuery = preset.tagQuery; if (preset.strategy === 'advanced' && preset.tagQuery.$advanced) { // Convert legacy advanced expression to JSON format jsonQuery = TagQueryParser.advancedQueryToJSON(preset.tagQuery.$advanced); } matches = TagQueryEvaluator.evaluate(jsonQuery, serverTags); } catch (error) { logger.warn('Failed to evaluate preset against server', { preset: name, server: serverName, error: error instanceof Error ? error.message : 'Unknown error', tagQuery: preset.tagQuery, serverTags, }); // Ensure failed evaluation doesn't match any servers matches = false; } if (matches) { matchingServers.push(serverName); } } return { servers: matchingServers, tags: Array.from(allTags).sort(), }; } /** * Notify clients of preset changes */ private async notifyPresetChange(presetName: string): Promise<void> { const promises = Array.from(this.notificationCallbacks).map((callback) => callback(presetName).catch((error) => { logger.error('Preset change notification failed', { presetName, error }); }), ); await Promise.all(promises); logger.debug('Preset change notifications sent', { presetName, callbackCount: this.notificationCallbacks.size, }); } /** * Check if a preset exists */ public hasPreset(name: string): boolean { return this.presets.has(name); } /** * Get preset names */ public getPresetNames(): string[] { return Array.from(this.presets.keys()); } /** * Get the configuration path */ public getConfigPath(): string { return this.configPath; } }

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