/**
* Configuration Manager for MCP Gateway
*/
import { readFileSync, writeFileSync, existsSync, watchFile, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import {
GatewayConfig,
GatewayConfigSchema,
ServersConfig,
ServersConfigSchema,
ServerConfig,
} from './types.js';
import { logger } from './logger.js';
/**
* Gateway Settings - persisted gateway configuration
*/
export interface GatewaySettings {
liteMode: boolean; // Reduce gateway meta-tools from 30 to ~7 essential tools
}
/**
* Feature Flags - optional features that can be enabled/disabled
* These are read from environment variables and are useful for public deployments
* where users may not need all features (Cipher, Antigravity, Skills, etc.)
*/
export interface FeatureFlags {
skills: boolean; // Skills system - reusable code patterns
cipher: boolean; // Cipher Memory - cross-IDE persistent memory
antigravity: boolean; // Antigravity Usage - IDE quota tracking
claudeUsage: boolean; // Claude Usage - API token consumption tracking
}
/**
* UI State - persisted tool/backend enabled/disabled selections
*/
export interface UIState {
disabledTools: string[];
disabledBackends: string[];
gatewaySettings?: GatewaySettings;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Substitute environment variables in strings
* Supports ${VAR_NAME} syntax
*/
export function substituteEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (match, varName) => {
const envValue = process.env[varName];
if (envValue === undefined) {
logger.warn(`Environment variable ${varName} not found`);
return match;
}
return envValue;
});
}
/**
* Deep substitute environment variables in an object
*/
export function substituteEnvVarsDeep<T>(obj: T): T {
if (typeof obj === 'string') {
return substituteEnvVars(obj) as T;
}
if (Array.isArray(obj)) {
return obj.map(substituteEnvVarsDeep) as T;
}
if (obj !== null && typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = substituteEnvVarsDeep(value);
}
return result as T;
}
return obj;
}
/**
* Load gateway configuration from environment
*/
export function loadGatewayConfig(): GatewayConfig {
const port = parseInt(process.env.PORT ?? '3010', 10);
const host = process.env.HOST ?? '0.0.0.0';
const name = process.env.GATEWAY_NAME ?? 'mcp-gateway';
const logLevel = process.env.LOG_LEVEL ?? 'info';
// Derive CORS origins:
// - Default: restrict to localhost for safer out-of-the-box behavior
// - CORS_ORIGINS="*" → allow all
// - CORS_ORIGINS="http://a,http://b" → explicit list
const rawCorsOrigins = process.env.CORS_ORIGINS;
let corsOrigins: string | string[];
if (!rawCorsOrigins || rawCorsOrigins.trim() === '') {
const localOrigin = `http://localhost:${port}`;
const loopbackOrigin = `http://127.0.0.1:${port}`;
corsOrigins = [localOrigin, loopbackOrigin];
} else if (rawCorsOrigins === '*') {
corsOrigins = '*';
} else if (rawCorsOrigins.includes(',')) {
corsOrigins = rawCorsOrigins
.split(',')
.map(origin => origin.trim())
.filter(origin => origin.length > 0);
} else {
corsOrigins = [rawCorsOrigins.trim()];
}
const config = {
port,
host,
name,
logLevel,
auth: {
mode: process.env.AUTH_MODE ?? 'none',
apiKeys: process.env.API_KEYS?.split(',').map(k => k.trim()).filter(Boolean),
oauth: {
issuer: process.env.OAUTH_ISSUER,
audience: process.env.OAUTH_AUDIENCE,
jwksUri: process.env.OAUTH_JWKS_URI,
},
},
cors: {
origins: corsOrigins,
},
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10),
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? '1000', 10),
},
};
return GatewayConfigSchema.parse(config);
}
/**
* Load servers configuration from JSON file
*/
export function loadServersConfig(configPath?: string): ServersConfig {
const path = configPath ?? resolve(__dirname, '../config/servers.json');
if (!existsSync(path)) {
logger.warn(`Servers config not found at ${path}, using empty config`);
return { servers: [] };
}
try {
const content = readFileSync(path, 'utf-8');
const parsed = JSON.parse(content);
const config = ServersConfigSchema.parse(parsed);
// Substitute environment variables in transport configs
const servers = config.servers.map(server => ({
...server,
transport: substituteEnvVarsDeep(server.transport),
}));
return { servers };
} catch (error) {
logger.error('Failed to load servers config', { error, path });
throw error;
}
}
/**
* Get enabled servers from config
*/
export function getEnabledServers(config: ServersConfig): ServerConfig[] {
return config.servers.filter(server => server.enabled);
}
/**
* Watch servers config for changes with debounce and validation
*/
export function watchServersConfig(
configPath: string,
onChange: (config: ServersConfig) => void,
debounceMs: number = 500
): void {
let debounceTimer: NodeJS.Timeout | null = null;
let lastContent: string | null = null;
watchFile(configPath, { interval: 1000 }, () => {
// Debounce rapid file changes (editors often write multiple times)
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
debounceTimer = null;
try {
// Read content first to check if it actually changed
const content = readFileSync(configPath, 'utf-8');
// Skip if content hasn't changed (avoids duplicate reloads)
if (content === lastContent) {
logger.debug('Config file touched but content unchanged, skipping reload');
return;
}
lastContent = content;
logger.info('Servers config changed, reloading...');
// Parse and validate before calling onChange
const parsed = JSON.parse(content);
const config = ServersConfigSchema.parse(parsed);
// Substitute environment variables
const servers = config.servers.map(server => ({
...server,
transport: substituteEnvVarsDeep(server.transport),
}));
onChange({ servers });
} catch (error) {
if (error instanceof SyntaxError) {
logger.error('Config reload failed: Invalid JSON syntax', {
error: error.message,
});
} else if (error && typeof error === 'object' && 'issues' in error) {
// Zod validation error
logger.error('Config reload failed: Validation error', {
issues: (error as { issues: unknown[] }).issues,
});
} else {
logger.error('Failed to reload servers config', { error });
}
// Don't call onChange with invalid config
}
}, debounceMs);
});
}
/**
* Configuration singleton
*/
class ConfigManager {
private static instance: ConfigManager;
private gatewayConfig: GatewayConfig;
private serversConfig: ServersConfig;
private serversConfigPath: string;
private uiStatePath: string;
private uiState: UIState;
private featureFlags: FeatureFlags;
private constructor() {
this.serversConfigPath = resolve(__dirname, '../config/servers.json');
this.uiStatePath = resolve(__dirname, '../config/.ui-state.json');
this.gatewayConfig = loadGatewayConfig();
this.serversConfig = loadServersConfig(this.serversConfigPath);
this.uiState = this.loadUIState();
this.featureFlags = this.loadFeatureFlags();
}
/**
* Load feature flags from environment variables
* All optional features are disabled by default for clean public deployments
*/
private loadFeatureFlags(): FeatureFlags {
const envToBool = (key: string): boolean => {
const val = process.env[key];
return val === '1' || val === 'true';
};
return {
skills: envToBool('ENABLE_SKILLS'),
cipher: envToBool('ENABLE_CIPHER'),
antigravity: envToBool('ENABLE_ANTIGRAVITY'),
claudeUsage: envToBool('ENABLE_CLAUDE_USAGE'),
};
}
static getInstance(): ConfigManager {
if (!ConfigManager.instance) {
ConfigManager.instance = new ConfigManager();
}
return ConfigManager.instance;
}
getGatewayConfig(): GatewayConfig {
return this.gatewayConfig;
}
getServersConfig(): ServersConfig {
return this.serversConfig;
}
getEnabledServers(): ServerConfig[] {
return getEnabledServers(this.serversConfig);
}
reload(): void {
this.gatewayConfig = loadGatewayConfig();
this.serversConfig = loadServersConfig(this.serversConfigPath);
}
/**
* Get the path to the servers config file
*/
getServersConfigPath(): string {
return this.serversConfigPath;
}
/**
* Add a new server to the configuration and persist to file
*/
addServer(server: ServerConfig): void {
// Check if server ID already exists
const existingIndex = this.serversConfig.servers.findIndex(s => s.id === server.id);
if (existingIndex !== -1) {
throw new Error(`Server with ID '${server.id}' already exists`);
}
this.serversConfig.servers.push(server);
this.saveServersConfig();
logger.info(`Server added: ${server.id}`);
}
/**
* Update an existing server in the configuration and persist to file
*/
updateServer(id: string, server: ServerConfig): void {
const index = this.serversConfig.servers.findIndex(s => s.id === id);
if (index === -1) {
throw new Error(`Server with ID '${id}' not found`);
}
// If the ID is changing, check that the new ID doesn't already exist
if (server.id !== id) {
const newIdExists = this.serversConfig.servers.some(s => s.id === server.id);
if (newIdExists) {
throw new Error(`Server with ID '${server.id}' already exists`);
}
}
this.serversConfig.servers[index] = server;
this.saveServersConfig();
logger.info(`Server updated: ${id}${server.id !== id ? ` -> ${server.id}` : ''}`);
}
/**
* Delete a server from the configuration and persist to file
*/
deleteServer(id: string): void {
const index = this.serversConfig.servers.findIndex(s => s.id === id);
if (index === -1) {
throw new Error(`Server with ID '${id}' not found`);
}
this.serversConfig.servers.splice(index, 1);
this.saveServersConfig();
logger.info(`Server deleted: ${id}`);
}
/**
* Get a server by ID
*/
getServer(id: string): ServerConfig | undefined {
return this.serversConfig.servers.find(s => s.id === id);
}
/**
* Save the current servers configuration to file
*/
private saveServersConfig(): void {
try {
const content = JSON.stringify({
"$schema": "./servers.schema.json",
servers: this.serversConfig.servers
}, null, 2);
writeFileSync(this.serversConfigPath, content, 'utf-8');
logger.info('Servers config saved successfully');
} catch (error) {
logger.error('Failed to save servers config', { error });
throw error;
}
}
/**
* Load UI state from file
*/
private loadUIState(): UIState {
const defaultState: UIState = {
disabledTools: [],
disabledBackends: [],
gatewaySettings: { liteMode: false },
};
if (!existsSync(this.uiStatePath)) {
return defaultState;
}
try {
const content = readFileSync(this.uiStatePath, 'utf-8');
const parsed = JSON.parse(content);
return {
disabledTools: Array.isArray(parsed.disabledTools) ? parsed.disabledTools : [],
disabledBackends: Array.isArray(parsed.disabledBackends) ? parsed.disabledBackends : [],
gatewaySettings: parsed.gatewaySettings ?? { liteMode: false },
};
} catch (error) {
logger.warn('Failed to load UI state, using defaults', { error });
return defaultState;
}
}
/**
* Get the current UI state
*/
getUIState(): UIState {
return this.uiState;
}
/**
* Save UI state to file
*/
saveUIState(state: UIState): void {
this.uiState = state;
try {
// Ensure config directory exists
const configDir = dirname(this.uiStatePath);
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
const content = JSON.stringify(state, null, 2);
writeFileSync(this.uiStatePath, content, 'utf-8');
logger.debug('UI state saved successfully');
} catch (error) {
logger.error('Failed to save UI state', { error });
}
}
/**
* Update disabled tools list and persist
*/
updateDisabledTools(disabledTools: string[]): void {
this.uiState.disabledTools = disabledTools;
this.saveUIState(this.uiState);
}
/**
* Update disabled backends list and persist
*/
updateDisabledBackends(disabledBackends: string[]): void {
this.uiState.disabledBackends = disabledBackends;
this.saveUIState(this.uiState);
}
/**
* Get gateway settings
*/
getGatewaySettings(): GatewaySettings {
return this.uiState.gatewaySettings ?? { liteMode: false };
}
/**
* Update gateway settings and persist
*/
updateGatewaySettings(settings: Partial<GatewaySettings>): void {
this.uiState.gatewaySettings = {
...this.uiState.gatewaySettings,
liteMode: false, // default
...settings,
};
this.saveUIState(this.uiState);
logger.info('Gateway settings updated', { settings: this.uiState.gatewaySettings });
}
/**
* Check if lite mode is enabled (from UI state or env var)
*/
isLiteModeEnabled(): boolean {
// Environment variable takes precedence
if (process.env.GATEWAY_LITE_MODE === '1' || process.env.GATEWAY_LITE_MODE === 'true') {
return true;
}
// Fall back to UI state
return this.uiState.gatewaySettings?.liteMode ?? false;
}
/**
* Get all feature flags
*/
getFeatureFlags(): FeatureFlags {
return this.featureFlags;
}
/**
* Check if Skills feature is enabled
*/
isSkillsEnabled(): boolean {
return this.featureFlags.skills;
}
/**
* Check if Cipher Memory feature is enabled
*/
isCipherEnabled(): boolean {
return this.featureFlags.cipher;
}
/**
* Check if Antigravity Usage feature is enabled
*/
isAntigravityEnabled(): boolean {
return this.featureFlags.antigravity;
}
/**
* Check if Claude Usage feature is enabled
*/
isClaudeUsageEnabled(): boolean {
return this.featureFlags.claudeUsage;
}
}
export default ConfigManager;