Skip to main content
Glama
configManager.test.ts22.9 kB
import { randomBytes } from 'crypto'; import fs from 'fs'; import { promises as fsPromises } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { CONFIG_EVENTS, ConfigChangeType, ConfigManager } from '@src/config/configManager.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock AgentConfigManager before any tests run const mockAgentConfig = { get: vi.fn().mockImplementation((key: string) => { const config = { features: { configReload: true, envSubstitution: true, }, configReload: { debounceMs: 100, }, }; return key.split('.').reduce((obj: any, k: string) => obj?.[k], config); }), }; vi.mock('@src/core/server/agentConfig.js', () => ({ AgentConfigManager: { getInstance: () => mockAgentConfig, }, })); describe('ConfigManager', () => { let tempConfigDir: string; let configFilePath: string; let configManager: ConfigManager; beforeEach(async () => { // Create temporary config directory tempConfigDir = join(tmpdir(), `config-test-${randomBytes(4).toString('hex')}`); await fsPromises.mkdir(tempConfigDir, { recursive: true }); configFilePath = join(tempConfigDir, 'mcp.json'); // Reset singleton instances (ConfigManager as any).instance = null; // Create initial config const initialConfig = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1.js'], tags: ['test', 'server1'], }, 'test-server-2': { command: 'node', args: ['server2.js'], tags: ['test', 'server2'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(initialConfig, null, 2)); configManager = ConfigManager.getInstance(configFilePath); await configManager.initialize(); }); afterEach(async () => { if (configManager) { await configManager.stop(); } try { await fsPromises.rm(tempConfigDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } vi.clearAllMocks(); }); describe('initialization', () => { it('should load initial configuration correctly', () => { const config = configManager.getTransportConfig(); expect(Object.keys(config)).toHaveLength(2); expect(config['test-server-1']).toBeDefined(); expect(config['test-server-2']).toBeDefined(); expect(config['test-server-1'].command).toBe('node'); }); it('should create config file if it does not exist', async () => { const nonExistentPath = join(tempConfigDir, 'nonexistent.json'); // Reset singleton to force creation of new instance (ConfigManager as any).instance = null; const newManager = ConfigManager.getInstance(nonExistentPath); const config = newManager.getTransportConfig(); expect(typeof config).toBe('object'); expect(fs.existsSync(nonExistentPath)).toBe(true); await newManager.stop(); }); it('should respect config reload feature flag', () => { // Mock to return an object where features.configReload is false mockAgentConfig.get.mockImplementation((key: string) => { if (key === 'features') { return { configReload: false }; } return {}; }); expect(configManager.isReloadEnabled()).toBe(false); // Reset mock for other tests mockAgentConfig.get.mockImplementation((key: string) => { const config = { features: { configReload: true, envSubstitution: true, }, configReload: { debounceMs: 100, }, }; return key.split('.').reduce((obj: any, k: string) => obj?.[k], config); }); }); }); describe('change detection', () => { it('should detect added servers', async () => { const updatedConfig = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1.js'], tags: ['test', 'server1'], }, 'test-server-2': { command: 'node', args: ['server2.js'], tags: ['test', 'server2'], }, 'test-server-3': { command: 'python', args: ['server3.py'], tags: ['test', 'python'], }, }, }; const changes: any[] = []; configManager.on(CONFIG_EVENTS.CONFIG_CHANGED, (detectedChanges) => { changes.push(...detectedChanges); }); await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2)); await configManager.reloadConfig(); expect(changes).toHaveLength(1); expect(changes[0].type).toBe(ConfigChangeType.ADDED); expect(changes[0].serverName).toBe('test-server-3'); }); it('should detect removed servers', async () => { const updatedConfig = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1.js'], tags: ['test', 'server1'], }, }, }; const changes: any[] = []; configManager.on(CONFIG_EVENTS.CONFIG_CHANGED, (detectedChanges) => { changes.push(...detectedChanges); }); await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2)); await configManager.reloadConfig(); expect(changes).toHaveLength(1); expect(changes[0].type).toBe(ConfigChangeType.REMOVED); expect(changes[0].serverName).toBe('test-server-2'); }); it('should detect modified servers', async () => { const updatedConfig = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1-updated.js'], tags: ['test', 'server1', 'updated'], }, 'test-server-2': { command: 'node', args: ['server2.js'], tags: ['test', 'server2'], }, }, }; const changes: any[] = []; configManager.on(CONFIG_EVENTS.CONFIG_CHANGED, (detectedChanges) => { changes.push(...detectedChanges); }); await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2)); await configManager.reloadConfig(); expect(changes).toHaveLength(1); expect(changes[0].type).toBe(ConfigChangeType.MODIFIED); expect(changes[0].serverName).toBe('test-server-1'); expect(changes[0].fieldsChanged).toContain('args'); expect(changes[0].fieldsChanged).toContain('tags'); }); it('should detect tag-only changes', async () => { const updatedConfig = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1.js'], tags: ['test', 'server1', 'tag-only-change'], }, 'test-server-2': { command: 'node', args: ['server2.js'], tags: ['test', 'server2'], }, }, }; const changes: any[] = []; configManager.on(CONFIG_EVENTS.CONFIG_CHANGED, (detectedChanges) => { changes.push(...detectedChanges); }); await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2)); await configManager.reloadConfig(); expect(changes).toHaveLength(1); expect(changes[0].type).toBe(ConfigChangeType.MODIFIED); expect(changes[0].fieldsChanged).toEqual(['tags']); }); it('should emit specific events for server additions and removals', async () => { const addedServers: string[] = []; const removedServers: string[] = []; configManager.on(CONFIG_EVENTS.SERVER_ADDED, (serverName) => { addedServers.push(serverName); }); configManager.on(CONFIG_EVENTS.SERVER_REMOVED, (serverName) => { removedServers.push(serverName); }); // Add server const updatedConfig = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1.js'], tags: ['test', 'server1'], }, 'test-server-2': { command: 'node', args: ['server2.js'], tags: ['test', 'server2'], }, 'test-server-3': { command: 'python', args: ['server3.py'], tags: ['test', 'python'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2)); await configManager.reloadConfig(); expect(addedServers).toContain('test-server-3'); // Remove server const finalConfig = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1.js'], tags: ['test', 'server1'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(finalConfig, null, 2)); await configManager.reloadConfig(); expect(removedServers).toContain('test-server-2'); }); }); describe('file watching', () => { it('should not watch files when config reload is disabled', async () => { mockAgentConfig.get.mockReturnValue({ features: { configReload: false }, }); const newManager = ConfigManager.getInstance(configFilePath); await newManager.initialize(); const changes: any[] = []; newManager.on(CONFIG_EVENTS.CONFIG_CHANGED, (detectedChanges) => { changes.push(...detectedChanges); }); // Update config file const updatedConfig = { mcpServers: { 'test-server-1': { command: 'python', args: ['server1.py'], tags: ['test', 'server1'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2)); // Wait a bit to ensure no event is fired await new Promise((resolve) => setTimeout(resolve, 200)); expect(changes).toHaveLength(0); await newManager.stop(); // Reset for other tests mockAgentConfig.get.mockReturnValue({ features: { configReload: true }, configReload: { debounceMs: 100 }, }); }); it('should handle debouncing correctly', async () => { const changes: any[] = []; configManager.on(CONFIG_EVENTS.CONFIG_CHANGED, (detectedChanges) => { changes.push(...detectedChanges); }); // Make multiple rapid changes for (let i = 0; i < 5; i++) { const updatedConfig = { mcpServers: { [`test-server-${i}`]: { command: 'node', args: [`server${i}.js`], tags: ['test'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(updatedConfig, null, 2)); } // Wait for debouncing (100ms + buffer) await new Promise((resolve) => setTimeout(resolve, 200)); // Should have only triggered one reload due to debouncing expect(changes.length).toBeGreaterThanOrEqual(1); }); }); describe('environment variable substitution', () => { it('should substitute environment variables when enabled', async () => { process.env.TEST_VAR = 'substituted-value'; mockAgentConfig.get.mockImplementation((key: string) => { const config = { features: { configReload: true, envSubstitution: true }, configReload: { debounceMs: 100 }, }; return key.split('.').reduce((obj: any, k: string) => obj?.[k], config); }); const configWithEnv = { mcpServers: { 'test-server': { command: '${TEST_VAR}', args: ['server.js'], tags: ['test'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(configWithEnv, null, 2)); // Reset singleton to force creation of new instance (ConfigManager as any).instance = null; const newManager = ConfigManager.getInstance(configFilePath); const config = newManager.getTransportConfig(); expect(config['test-server'].command).toBe('substituted-value'); delete process.env.TEST_VAR; await newManager.stop(); }); it('should not substitute environment variables when disabled', async () => { process.env.TEST_VAR = 'should-not-substitute'; mockAgentConfig.get.mockImplementation((key: string) => { const config = { features: { configReload: true, envSubstitution: false }, configReload: { debounceMs: 100 }, }; return key.split('.').reduce((obj: any, k: string) => obj?.[k], config); }); const configWithEnv = { mcpServers: { 'test-server': { command: '${TEST_VAR}', args: ['server.js'], tags: ['test'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(configWithEnv, null, 2)); // Reset singleton to force creation of new instance (ConfigManager as any).instance = null; const newManager = ConfigManager.getInstance(configFilePath); const config = newManager.getTransportConfig(); expect(config['test-server'].command).toBe('${TEST_VAR}'); delete process.env.TEST_VAR; await newManager.stop(); // Reset for other tests mockAgentConfig.get.mockReturnValue({ features: { configReload: true, envSubstitution: true }, configReload: { debounceMs: 100 }, }); }); }); describe('utility methods', () => { it('should get available tags correctly', () => { const tags = configManager.getAvailableTags(); expect(tags).toContain('server1'); expect(tags).toContain('server2'); expect(tags).toContain('test'); expect(tags.sort()).toEqual([...tags].sort()); // Should be sorted }); it('should skip tags from disabled servers', async () => { const configWithDisabled = { mcpServers: { 'test-server-1': { command: 'node', args: ['server1.js'], tags: ['enabled', 'tag1'], }, 'test-server-2': { command: 'node', args: ['server2.js'], tags: ['disabled', 'tag2'], disabled: true, }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(configWithDisabled, null, 2)); await configManager.reloadConfig(); const tags = configManager.getAvailableTags(); expect(tags).toContain('enabled'); expect(tags).toContain('tag1'); expect(tags).not.toContain('disabled'); expect(tags).not.toContain('tag2'); }); }); describe('error handling', () => { it('should handle invalid JSON gracefully', async () => { await fsPromises.writeFile(configFilePath, 'invalid json content'); // Reset singleton to force creation of new instance (ConfigManager as any).instance = null; const newManager = ConfigManager.getInstance(configFilePath); const config = newManager.getTransportConfig(); expect(typeof config).toBe('object'); expect(Object.keys(config)).toHaveLength(0); }); it('should handle missing mcpServers section', async () => { const configWithoutServers = { otherConfig: 'value' }; await fsPromises.writeFile(configFilePath, JSON.stringify(configWithoutServers, null, 2)); // Reset singleton to force creation of new instance (ConfigManager as any).instance = null; const newManager = ConfigManager.getInstance(configFilePath); const config = newManager.getTransportConfig(); expect(typeof config).toBe('object'); expect(Object.keys(config)).toHaveLength(0); }); }); describe('configuration validation', () => { it('should validate and load correct configuration', async () => { const validConfig = { mcpServers: { 'valid-server': { command: 'echo', args: ['hello'], tags: ['test'], disabled: false, timeout: 5000, connectionTimeout: 3000, requestTimeout: 10000, envFilter: ['TEST_VAR'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(validConfig, null, 2)); await configManager.reloadConfig(); const config = configManager.getTransportConfig(); expect(Object.keys(config)).toContain('valid-server'); expect(config['valid-server'].command).toBe('echo'); expect(config['valid-server'].args).toEqual(['hello']); expect(config['valid-server'].tags).toEqual(['test']); expect(config['valid-server'].timeout).toBe(5000); }); it('should skip invalid server configurations', async () => { const invalidConfig = { mcpServers: { 'invalid-server': { command: 'echo', args: 'not-an-array', // Should be array timeout: 'not-a-number', // Should be number url: 'invalid-url', // Should be valid URL maxRestarts: -1, // Should be >= 0 }, 'valid-server': { command: 'node', args: ['server.js'], tags: ['valid'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(invalidConfig, null, 2)); await configManager.reloadConfig(); const config = configManager.getTransportConfig(); expect(Object.keys(config)).not.toContain('invalid-server'); expect(Object.keys(config)).toContain('valid-server'); expect(config['valid-server'].command).toBe('node'); }); it('should handle completely invalid server configuration', async () => { const completelyInvalidConfig = { mcpServers: { 'bad-server': null, // Completely invalid }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(completelyInvalidConfig, null, 2)); await configManager.reloadConfig(); const config = configManager.getTransportConfig(); expect(Object.keys(config)).toHaveLength(0); }); it('should handle mixed valid and invalid configurations', async () => { const mixedConfig = { mcpServers: { 'server-1': { command: 'echo', args: ['test1'], tags: ['tag1'], }, 'server-2': { command: 123, // Invalid - should be string args: ['test2'], }, 'server-3': { command: 'node', args: ['test3'], restartDelay: -100, // Invalid - should be >= 0 }, 'server-4': { command: 'python', args: ['test4'], tags: ['tag4'], env: ['VALID_ENV'], // Valid - array of strings }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(mixedConfig, null, 2)); await configManager.reloadConfig(); const config = configManager.getTransportConfig(); expect(Object.keys(config)).toHaveLength(2); expect(Object.keys(config)).toContain('server-1'); expect(Object.keys(config)).toContain('server-4'); expect(Object.keys(config)).not.toContain('server-2'); expect(Object.keys(config)).not.toContain('server-3'); }); it('should validate HTTP transport configuration', async () => { const httpConfig = { mcpServers: { 'http-server': { type: 'http', url: 'https://example.com/mcp', headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json', }, tags: ['http'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(httpConfig, null, 2)); await configManager.reloadConfig(); const config = configManager.getTransportConfig(); expect(Object.keys(config)).toContain('http-server'); expect(config['http-server'].type).toBe('http'); expect(config['http-server'].url).toBe('https://example.com/mcp'); expect(config['http-server'].headers).toEqual({ Authorization: 'Bearer token', 'Content-Type': 'application/json', }); }); it('should reject invalid HTTP URL', async () => { const invalidHttpConfig = { mcpServers: { 'invalid-http': { type: 'http', url: 'not-a-valid-url', }, 'valid-server': { command: 'echo', args: ['test'], }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(invalidHttpConfig, null, 2)); await configManager.reloadConfig(); const config = configManager.getTransportConfig(); expect(Object.keys(config)).not.toContain('invalid-http'); expect(Object.keys(config)).toContain('valid-server'); }); it('should emit validation error event when config is invalid', async () => { const invalidConfig = { mcpServers: { 'invalid-server': { command: 123, // Invalid type }, }, }; const validationErrorSpy = vi.fn(); configManager.on(CONFIG_EVENTS.VALIDATION_ERROR, validationErrorSpy); await fsPromises.writeFile(configFilePath, JSON.stringify(invalidConfig, null, 2)); await configManager.reloadConfig(); // Note: The validation errors are logged but don't prevent reload from completing // Invalid servers are simply skipped expect(validationErrorSpy).not.toHaveBeenCalled(); // Config should be empty since all servers are invalid expect(Object.keys(configManager.getTransportConfig())).toHaveLength(0); }); it('should validate OAuth configuration', async () => { const oauthConfig = { mcpServers: { 'oauth-server': { type: 'http', url: 'https://api.example.com/mcp', oauth: { clientId: 'test-client-id', clientSecret: 'test-client-secret', scopes: ['read', 'write'], autoRegister: true, }, }, }, }; await fsPromises.writeFile(configFilePath, JSON.stringify(oauthConfig, null, 2)); await configManager.reloadConfig(); const config = configManager.getTransportConfig(); expect(Object.keys(config)).toContain('oauth-server'); expect(config['oauth-server'].oauth?.clientId).toBe('test-client-id'); expect(config['oauth-server'].oauth?.clientSecret).toBe('test-client-secret'); expect(config['oauth-server'].oauth?.scopes).toEqual(['read', 'write']); expect(config['oauth-server'].oauth?.autoRegister).toBe(true); }); }); });

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

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