Skip to main content
Glama
ConfigFetcherService.ts12.5 kB
/** * ConfigFetcherService * * DESIGN PATTERNS: * - Service pattern for business logic encapsulation * - Single responsibility principle * - Caching pattern for performance optimization * * CODING STANDARDS: * - Use async/await for asynchronous operations * - Throw descriptive errors for error cases * - Keep methods focused and well-named * - Document complex logic with comments * * AVOID: * - Mixing concerns (keep focused on single domain) * - Direct tool implementation (services should be tool-agnostic) */ import { readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import yaml from 'js-yaml'; import type { RemoteMcpConfiguration } from '../types'; import { parseMcpConfig, validateRemoteConfigSource, type RemoteConfigSource, type ClaudeCodeMcpConfig } from '../utils/mcpConfigSchema'; import { RemoteConfigCacheService } from './RemoteConfigCacheService'; export interface ConfigFetcherOptions { configFilePath?: string; cacheTtlMs?: number; useCache?: boolean; // Whether to use cache (both read and write) remoteCacheTtlMs?: number; // TTL for remote config cache } /** * Service for fetching and caching MCP server configurations from local file and remote sources * Supports merging multiple remote configs with local config */ export class ConfigFetcherService { private configFilePath?: string; private cacheTtlMs: number; private cachedConfig: RemoteMcpConfiguration | null = null; private lastFetchTime: number = 0; private remoteConfigCache: RemoteConfigCacheService; constructor(options: ConfigFetcherOptions) { this.configFilePath = options.configFilePath; this.cacheTtlMs = options.cacheTtlMs || 60000; // Default 1 minute cache // Initialize remote config cache service const useCache = options.useCache !== undefined ? options.useCache : true; this.remoteConfigCache = new RemoteConfigCacheService({ ttl: options.remoteCacheTtlMs || 60 * 60 * 1000, // Default 1 hour readEnabled: useCache, writeEnabled: true, // Always write to cache (even when --no-cache is used) }); if (!this.configFilePath) { throw new Error('configFilePath must be provided'); } } /** * Fetch MCP configuration from local file and remote sources with caching * Merges remote configs with local config based on merge strategy * @param forceRefresh - Force reload from source, bypassing cache */ async fetchConfiguration(forceRefresh = false): Promise<RemoteMcpConfiguration> { const now = Date.now(); // Return cached config if still valid and not forcing refresh if (!forceRefresh && this.cachedConfig && now - this.lastFetchTime < this.cacheTtlMs) { return this.cachedConfig; } // Load local configuration from file const localConfigData = await this.loadRawConfigFromFile(); // Parse the raw config to get remoteConfigs array const parsedLocalData = localConfigData as ClaudeCodeMcpConfig; const remoteConfigSources = parsedLocalData.remoteConfigs || []; // Start with local config let mergedConfig = await this.parseConfig(localConfigData); // Fetch all remote configs in parallel const remoteConfigPromises = remoteConfigSources.map(async (remoteSource) => { try { // Validate remote source validateRemoteConfigSource(remoteSource); // Fetch remote config const remoteConfig = await this.loadFromUrl(remoteSource); // Return the config with its merge strategy return { config: remoteConfig, mergeStrategy: remoteSource.mergeStrategy || 'local-priority', url: remoteSource.url, }; } catch (error) { if (error instanceof Error) { console.error(`Failed to fetch remote config from ${remoteSource.url}: ${error.message}`); } return null; // Return null for failed fetches } }); // Wait for all remote configs to be fetched const remoteConfigResults = await Promise.all(remoteConfigPromises); // Merge all successfully fetched remote configs for (const result of remoteConfigResults) { if (result !== null) { mergedConfig = this.mergeConfigurations(mergedConfig, result.config, result.mergeStrategy); } } // Validate final configuration structure if (!mergedConfig.mcpServers || typeof mergedConfig.mcpServers !== 'object') { throw new Error('Invalid MCP configuration: missing or invalid mcpServers'); } // Cache the configuration this.cachedConfig = mergedConfig; this.lastFetchTime = now; return mergedConfig; } /** * Load raw configuration data from a local file (supports JSON and YAML) * Returns unparsed config data to allow access to remoteConfigs */ private async loadRawConfigFromFile(): Promise<any> { if (!this.configFilePath) { throw new Error('No config file path provided'); } if (!existsSync(this.configFilePath)) { throw new Error(`Config file not found: ${this.configFilePath}`); } try { const content = await readFile(this.configFilePath, 'utf-8'); let rawConfig: any; // Detect file format by extension const isYaml = this.configFilePath.endsWith('.yaml') || this.configFilePath.endsWith('.yml'); if (isYaml) { rawConfig = yaml.load(content); } else { rawConfig = JSON.parse(content); } return rawConfig; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to load config file: ${error.message}`); } throw new Error('Failed to load config file: Unknown error'); } } /** * Parse raw config data using Zod schema * Filters out remoteConfigs to avoid including them in the final config */ private async parseConfig(rawConfig: any): Promise<RemoteMcpConfiguration> { try { // Remove remoteConfigs before parsing to avoid validation errors const { remoteConfigs, ...configWithoutRemote } = rawConfig; // Parse and transform using Zod schema return parseMcpConfig(configWithoutRemote) as RemoteMcpConfiguration; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to parse config: ${error.message}`); } throw new Error('Failed to parse config: Unknown error'); } } /** * Load configuration from a remote URL with caching * * SECURITY NOTE: This method fetches remote configs based on URLs from the local config file. * This is intentional and safe because: * 1. URLs are user-controlled via their local config file (not external input) * 2. SSRF protection validates URLs before fetching (blocks private IPs, enforces HTTPS) * 3. Users explicitly opt-in to remote configs in their local configuration * 4. This enables centralized config management (intended feature, not a vulnerability) * * CodeQL alert "file-access-to-http" is a false positive here - we're not leaking * file contents to arbitrary URLs, we're fetching configs from user-specified sources. */ private async loadFromUrl(source: RemoteConfigSource): Promise<RemoteMcpConfiguration> { try { // Interpolate environment variables in URL const interpolatedUrl = this.interpolateEnvVars(source.url); // Try to get from cache first const cachedConfig = await this.remoteConfigCache.get(interpolatedUrl); if (cachedConfig) { return cachedConfig; } // Cache miss - fetch from remote const interpolatedHeaders = source.headers ? Object.fromEntries( Object.entries(source.headers).map(([key, value]) => [ key, this.interpolateEnvVars(value), ]) ) : {}; // SSRF protection is enforced in validateRemoteConfigSource() before this is called const response = await fetch(interpolatedUrl, { headers: interpolatedHeaders, }); if (!response.ok) { throw new Error( `Failed to fetch remote config: ${response.status} ${response.statusText}` ); } const rawConfig = await response.json(); // Parse and transform using Zod schema const config = parseMcpConfig(rawConfig) as RemoteMcpConfiguration; // Cache the fetched config await this.remoteConfigCache.set(interpolatedUrl, config); return config; } catch (error) { if (error instanceof Error) { throw new Error(`Failed to fetch remote config from ${source.url}: ${error.message}`); } throw new Error(`Failed to fetch remote config from ${source.url}: Unknown error`); } } /** * Interpolate environment variables in a string * Supports ${VAR_NAME} syntax */ private interpolateEnvVars(value: string): string { return value.replace(/\$\{([^}]+)\}/g, (_, varName) => { const envValue = process.env[varName]; if (envValue === undefined) { console.warn(`Environment variable ${varName} is not defined, keeping placeholder`); return `\${${varName}}`; } return envValue; }); } /** * Merge two MCP configurations based on the specified merge strategy * @param localConfig Configuration loaded from local file * @param remoteConfig Configuration loaded from remote URL * @param mergeStrategy Strategy for merging configs * @returns Merged configuration */ private mergeConfigurations( localConfig: RemoteMcpConfiguration, remoteConfig: RemoteMcpConfiguration, mergeStrategy: 'local-priority' | 'remote-priority' | 'merge-deep' ): RemoteMcpConfiguration { switch (mergeStrategy) { case 'local-priority': // Local servers override remote servers with the same name return { mcpServers: { ...remoteConfig.mcpServers, ...localConfig.mcpServers, }, }; case 'remote-priority': // Remote servers override local servers with the same name return { mcpServers: { ...localConfig.mcpServers, ...remoteConfig.mcpServers, }, }; case 'merge-deep': { // Deep merge: combine both, local overrides on conflict const merged: Record<string, any> = { ...remoteConfig.mcpServers }; // Merge local servers, performing deep merge for servers with the same name for (const [serverName, localServerConfig] of Object.entries(localConfig.mcpServers)) { if (merged[serverName]) { // Server exists in both - deep merge the config const remoteServer = merged[serverName]; const mergedConfig: any = { ...remoteServer.config, ...localServerConfig.config, }; // Deep merge nested objects like env, headers const remoteEnv = 'env' in remoteServer.config ? remoteServer.config.env : undefined; const localEnv = 'env' in localServerConfig.config ? localServerConfig.config.env : undefined; if (remoteEnv || localEnv) { mergedConfig.env = { ...(remoteEnv || {}), ...(localEnv || {}), }; } const remoteHeaders = 'headers' in remoteServer.config ? remoteServer.config.headers : undefined; const localHeaders = 'headers' in localServerConfig.config ? localServerConfig.config.headers : undefined; if (remoteHeaders || localHeaders) { mergedConfig.headers = { ...(remoteHeaders || {}), ...(localHeaders || {}), }; } merged[serverName] = { ...remoteServer, ...localServerConfig, config: mergedConfig, }; } else { // Server only in local - add it merged[serverName] = localServerConfig; } } return { mcpServers: merged }; } default: throw new Error(`Unknown merge strategy: ${mergeStrategy}`); } } /** * Clear the cached configuration */ clearCache(): void { this.cachedConfig = null; this.lastFetchTime = 0; } /** * Check if cache is valid */ isCacheValid(): boolean { const now = Date.now(); return this.cachedConfig !== null && now - this.lastFetchTime < this.cacheTtlMs; } }

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