// Copyright 2025 Chris Bunting
// Brief: Configuration management system for MCP Code Analysis & Quality Server
// Scope: Centralized configuration handling for all MCP servers
import { ConfigInterface, ServerConfig, LogLevel } from '@mcp-code-analysis/shared-types';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export interface ConfigSource {
name: string;
priority: number;
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T): Promise<void>;
has(key: string): Promise<boolean>;
}
export class FileConfigSource implements ConfigSource {
name = 'file';
priority = 100;
private configPath: string;
private config: Record<string, unknown> = {};
constructor(configPath?: string) {
this.configPath = configPath ?? path.join(process.cwd(), 'mcp-config.json');
this.loadConfig();
}
private loadConfig(): void {
try {
if (fs.existsSync(this.configPath)) {
const configData = fs.readFileSync(this.configPath, 'utf8');
this.config = JSON.parse(configData);
}
} catch (error) {
console.warn(`Failed to load config from ${this.configPath}:`, error);
this.config = {};
}
}
async get<T>(key: string): Promise<T | undefined> {
return this.config[key] as T;
}
async set<T>(key: string, value: T): Promise<void> {
this.config[key] = value;
await this.saveConfig();
}
async has(key: string): Promise<boolean> {
return key in this.config;
}
private async saveConfig(): Promise<void> {
try {
const configDir = path.dirname(this.configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
} catch (error) {
console.error(`Failed to save config to ${this.configPath}:`, error);
}
}
}
export class EnvironmentConfigSource implements ConfigSource {
name = 'environment';
priority = 200;
async get<T>(key: string): Promise<T | undefined> {
const envKey = key.toUpperCase().replace(/\./g, '_');
const value = process.env[envKey];
if (value !== undefined) {
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
}
return undefined;
}
async set<T>(_key: string, _value: T): Promise<void> {
throw new Error('Environment config source is read-only');
}
async has(key: string): Promise<boolean> {
const envKey = key.toUpperCase().replace(/\./g, '_');
return envKey in process.env;
}
}
export class DefaultConfigSource implements ConfigSource {
name = 'default';
priority = 0;
private defaults: Record<string, any>;
constructor(defaults: Record<string, any>) {
this.defaults = { ...defaults };
}
async get<T>(key: string): Promise<T | undefined> {
return this.defaults[key] as T;
}
async set<T>(_key: string, _value: T): Promise<void> {
throw new Error('Default config source is read-only');
}
async has(key: string): Promise<boolean> {
return key in this.defaults;
}
}
export class ConfigManager extends EventEmitter implements ConfigInterface {
private sources: ConfigSource[] = [];
private cache: Map<string, any> = new Map();
private watchers: fs.FSWatcher[] = [];
constructor() {
super();
}
addSource(source: ConfigSource): void {
this.sources.push(source);
this.sources.sort((a, b) => b.priority - a.priority);
this.clearCache();
}
async get<T>(key: string, defaultValue?: T): Promise<T> {
if (this.cache.has(key)) {
return this.cache.get(key) as T;
}
for (const source of this.sources) {
try {
const value = await source.get<T>(key);
if (value !== undefined) {
this.cache.set(key, value);
return value;
}
} catch (error) {
console.warn(`Failed to get config key "${key}" from source "${source.name}":`, error);
}
}
if (defaultValue !== undefined) {
this.cache.set(key, defaultValue);
return defaultValue;
}
throw new Error(`Configuration key "${key}" not found`);
}
async set<T>(key: string, value: T): Promise<void> {
// Find the first writable source
for (const source of this.sources) {
try {
await source.set(key, value);
break;
} catch (error) {
console.warn(`Failed to set config key "${key}" in source "${source.name}":`, error);
}
}
this.cache.set(key, value);
this.emit('change', { key, value });
}
async has(key: string): Promise<boolean> {
if (this.cache.has(key)) {
return true;
}
for (const source of this.sources) {
try {
if (await source.has(key)) {
return true;
}
} catch (error) {
console.warn(`Failed to check config key "${key}" in source "${source.name}":`, error);
}
}
return false;
}
async reload(): Promise<void> {
this.clearCache();
this.emit('reload');
}
private clearCache(): void {
this.cache.clear();
}
watchFile(filePath: string): void {
if (fs.existsSync(filePath)) {
const watcher = fs.watch(filePath, (eventType) => {
if (eventType === 'change') {
this.reload().catch(error => {
console.error('Failed to reload config:', error);
});
}
});
this.watchers.push(watcher);
}
}
dispose(): void {
this.watchers.forEach(watcher => watcher.close());
this.watchers = [];
this.removeAllListeners();
}
}
export function createDefaultConfig(): ServerConfig {
return {
name: 'mcp-code-analysis-server',
version: '1.0.0',
host: 'localhost',
port: 3000,
logLevel: LogLevel.INFO,
cache: {
enabled: true,
ttl: 3600, // 1 hour
maxSize: 1000,
storage: 'memory'
},
plugins: []
};
}
export function createConfigManager(configPath?: string): ConfigManager {
const manager = new ConfigManager();
// Add sources in priority order (highest first)
manager.addSource(new EnvironmentConfigSource());
manager.addSource(new FileConfigSource(configPath));
manager.addSource(new DefaultConfigSource(createDefaultConfig()));
// Watch for file changes
if (configPath) {
manager.watchFile(configPath);
}
return manager;
}
// Configuration validation
export function validateServerConfig(config: Partial<ServerConfig>): ServerConfig {
const defaults = createDefaultConfig();
return {
name: config.name || defaults.name,
version: config.version || defaults.version,
host: config.host || defaults.host,
port: config.port || defaults.port,
logLevel: config.logLevel || defaults.logLevel,
cache: {
...defaults.cache,
...config.cache
},
plugins: config.plugins || defaults.plugins
};
}
// Configuration schema for validation
export const ConfigSchema = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
version: { type: 'string', pattern: '^\\d+\\.\\d+\\.\\d+$' },
host: { type: 'string', format: 'hostname' },
port: { type: 'integer', minimum: 1, maximum: 65535 },
logLevel: {
type: 'string',
enum: Object.values(LogLevel)
},
cache: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
ttl: { type: 'integer', minimum: 0 },
maxSize: { type: 'integer', minimum: 1 },
storage: {
type: 'string',
enum: ['memory', 'redis', 'file']
}
},
required: ['enabled', 'ttl', 'maxSize', 'storage']
},
plugins: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
enabled: { type: 'boolean' },
config: { type: 'object' }
},
required: ['name', 'enabled', 'config']
}
}
},
required: ['name', 'version', 'logLevel', 'cache', 'plugins']
};
// Configuration utilities
export class ConfigUtils {
static resolvePath(configPath: string): string {
if (path.isAbsolute(configPath)) {
return configPath;
}
return path.resolve(process.cwd(), configPath);
}
static mergeConfigs(...configs: Partial<ServerConfig>[]): ServerConfig {
const result: Partial<ServerConfig> = {};
for (const config of configs) {
Object.assign(result, config);
}
return validateServerConfig(result);
}
static getConfigPaths(): string[] {
const paths: string[] = [];
// Current directory
paths.push(path.join(process.cwd(), 'mcp-config.json'));
// Home directory
paths.push(path.join(os.homedir(), '.mcp', 'config.json'));
// Global config
paths.push(path.join('/etc', 'mcp', 'config.json'));
return paths.filter(fs.existsSync);
}
static async findConfigFile(): Promise<string | null> {
const paths = this.getConfigPaths();
return paths.length > 0 ? paths[0] : null;
}
}