reloaderoo
Version:
Hot-reload your MCP servers without restarting your AI coding assistant. Works excellently with VSCode MCP, well with Claude Code. A transparent development proxy for the Model Context Protocol that enables seamless server restarts during development.
501 lines • 17.6 kB
JavaScript
/**
* Comprehensive configuration system for mcpdev-proxy
*
* Provides robust configuration loading, validation, and management with support for:
* - Environment variable mapping and type conversion
* - Multi-source configuration merging with proper precedence
* - Comprehensive validation with helpful error messages
* - Runtime configuration updates with change tracking
* - Type-safe configuration access and modification
*/
import { EventEmitter } from 'events';
import { existsSync, accessSync, constants } from 'fs';
import { resolve, isAbsolute, delimiter } from 'path';
import { DEFAULT_PROXY_CONFIG } from './types.js';
// =============================================================================
// CONFIGURATION INTERFACES
// =============================================================================
/**
* Configuration sources in priority order (highest to lowest)
*/
export var ConfigSource;
(function (ConfigSource) {
ConfigSource["RUNTIME"] = "runtime";
ConfigSource["ENVIRONMENT"] = "environment";
ConfigSource["DEFAULT"] = "default";
})(ConfigSource || (ConfigSource = {}));
// =============================================================================
// UTILITY FUNCTIONS
// =============================================================================
/**
* Native implementation of 'which' command functionality
* Finds the path of an executable command in the system PATH
*/
function which(command) {
if (isAbsolute(command)) {
return existsSync(command) ? command : null;
}
const pathExt = process.platform === 'win32'
? (process.env['PATHEXT'] || '.COM;.EXE;.BAT;.CMD').split(';')
: [''];
const paths = (process.env['PATH'] || '').split(delimiter);
for (const dir of paths) {
if (!dir || dir.trim() === '')
continue;
for (const ext of pathExt) {
const fullPath = resolve(dir, command + ext);
if (existsSync(fullPath)) {
try {
accessSync(fullPath, constants.X_OK);
return fullPath;
}
catch {
// Not executable, continue searching
}
}
}
}
return null;
}
// =============================================================================
// ENVIRONMENT VARIABLE PROCESSING
// =============================================================================
/**
* Environment variable mappings with type information
*/
const ENV_VAR_MAPPINGS = {
MCPDEV_PROXY_LOG_LEVEL: {
configKey: 'logLevel',
type: 'string'
},
MCPDEV_PROXY_LOG_FILE: {
configKey: 'logFile',
type: 'string'
},
MCPDEV_PROXY_RESTART_LIMIT: {
configKey: 'restartLimit',
type: 'number'
},
MCPDEV_PROXY_AUTO_RESTART: {
configKey: 'autoRestart',
type: 'boolean'
},
MCPDEV_PROXY_TIMEOUT: {
configKey: 'operationTimeout',
type: 'number'
},
MCPDEV_PROXY_RESTART_DELAY: {
configKey: 'restartDelay',
type: 'number'
},
MCPDEV_PROXY_CHILD_CMD: {
configKey: 'childCommand',
type: 'string'
},
MCPDEV_PROXY_CHILD_ARGS: {
configKey: 'childArgs',
type: 'array',
parser: (value) => value.split(',').map(arg => arg.trim())
},
MCPDEV_PROXY_CWD: {
configKey: 'workingDirectory',
type: 'string'
},
MCPDEV_PROXY_DEBUG_MODE: {
configKey: 'debugMode',
type: 'boolean'
}
};
/**
* Convert environment variable string to appropriate type
*/
function convertEnvValue(value, type, parser) {
if (parser) {
return parser(value);
}
switch (type) {
case 'boolean':
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
case 'number':
const num = parseInt(value, 10);
if (isNaN(num)) {
throw new Error(`Invalid number value: ${value}`);
}
return num;
case 'array':
return value.split(',').map(item => item.trim()).filter(Boolean);
case 'string':
default:
return value;
}
}
/**
* Load configuration from environment variables
*/
function loadEnvironmentConfig() {
const config = {};
const environment = {};
// Process known environment variables
for (const [envVar, mapping] of Object.entries(ENV_VAR_MAPPINGS)) {
const value = process.env[envVar];
if (value !== undefined) {
try {
config[mapping.configKey] = convertEnvValue(value, mapping.type, mapping.parser);
}
catch (error) {
throw new Error(`Invalid environment variable ${envVar}: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
}
// Collect environment variables for child process
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !key.startsWith('MCPDEV_PROXY_')) {
environment[key] = value;
}
}
if (Object.keys(environment).length > 0) {
config.environment = environment;
}
return config;
}
// =============================================================================
// CONFIGURATION CLASS
// =============================================================================
/**
* Main configuration management class
*
* Provides comprehensive configuration loading, validation, merging, and runtime updates
* with proper event emission and error handling.
*/
export class Config extends EventEmitter {
state;
constructor() {
super();
this.state = {
sources: new Map(),
merged: null,
lastValidation: null,
changeCount: 0
};
// Initialize with default configuration
this.state.sources.set(ConfigSource.DEFAULT, { ...DEFAULT_PROXY_CONFIG });
}
/**
* Load configuration from all sources with proper precedence
* Priority: Runtime > Environment > Default
*/
loadConfig() {
try {
// Load environment configuration
const envConfig = loadEnvironmentConfig();
this.state.sources.set(ConfigSource.ENVIRONMENT, envConfig);
// Merge configurations
const merged = this.mergeConfigs();
// Validate merged configuration
const validation = this.validateConfig(merged);
if (validation.valid && validation.config) {
this.state.merged = validation.config;
this.state.lastValidation = validation;
this.emit('configLoaded', validation.config);
}
return validation;
}
catch (error) {
const validation = {
valid: false,
errors: [error instanceof Error ? error.message : 'Unknown configuration loading error'],
warnings: []
};
this.state.lastValidation = validation;
return validation;
}
}
/**
* Validate configuration with comprehensive checks
*/
validateConfig(config) {
const errors = [];
const warnings = [];
// Required fields validation
if (!config.childCommand) {
errors.push('childCommand is required');
}
else {
// Check if command exists and is executable
const cmdPath = isAbsolute(config.childCommand) ? config.childCommand : which(config.childCommand);
if (!cmdPath) {
errors.push(`Child command not found: ${config.childCommand}`);
}
else if (!existsSync(cmdPath)) {
errors.push(`Child command path does not exist: ${cmdPath}`);
}
else {
try {
accessSync(cmdPath, constants.X_OK);
}
catch {
warnings.push(`Child command may not be executable: ${cmdPath}`);
}
}
}
// Working directory validation
if (config.workingDirectory) {
const workDir = resolve(config.workingDirectory);
if (!existsSync(workDir)) {
errors.push(`Working directory does not exist: ${workDir}`);
}
else {
try {
accessSync(workDir, constants.R_OK | constants.W_OK);
}
catch {
warnings.push(`Working directory may not be accessible: ${workDir}`);
}
}
}
// Log level validation
if (config.logLevel) {
const validLevels = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
if (!validLevels.includes(config.logLevel)) {
errors.push(`Invalid log level: ${config.logLevel}. Must be one of: ${validLevels.join(', ')}`);
}
}
// Numeric range validations
if (config.restartLimit !== undefined) {
if (config.restartLimit < 0 || config.restartLimit > 10) {
errors.push('restartLimit must be between 0 and 10');
}
}
if (config.operationTimeout !== undefined) {
if (config.operationTimeout < 1000 || config.operationTimeout > 300000) {
errors.push('operationTimeout must be between 1000ms and 300000ms');
}
}
if (config.restartDelay !== undefined) {
if (config.restartDelay < 0 || config.restartDelay > 60000) {
errors.push('restartDelay must be between 0ms and 60000ms');
}
}
// Child args validation
if (config.childArgs) {
if (!Array.isArray(config.childArgs)) {
errors.push('childArgs must be an array of strings');
}
else if (config.childArgs.some(arg => typeof arg !== 'string')) {
errors.push('All childArgs must be strings');
}
}
// Environment validation
if (config.environment) {
if (typeof config.environment !== 'object' || config.environment === null) {
errors.push('environment must be an object');
}
else {
for (const [key, value] of Object.entries(config.environment)) {
if (typeof value !== 'string') {
errors.push(`Environment variable ${key} must be a string`);
}
}
}
}
// Create validated config if no errors
let validatedConfig;
if (errors.length === 0) {
validatedConfig = {
childCommand: config.childCommand,
childArgs: config.childArgs || [],
workingDirectory: config.workingDirectory || process.cwd(),
environment: config.environment || {},
restartLimit: config.restartLimit ?? 3,
operationTimeout: config.operationTimeout ?? 30000,
logLevel: config.logLevel || 'info',
autoRestart: config.autoRestart ?? true,
restartDelay: config.restartDelay ?? 1000
};
}
const result = {
valid: errors.length === 0,
errors,
warnings
};
if (validatedConfig) {
result.config = validatedConfig;
}
return result;
}
/**
* Merge configurations from all sources with proper precedence
*/
mergeConfigs() {
const merged = {};
// Apply configurations in priority order (lowest to highest)
for (const source of [ConfigSource.DEFAULT, ConfigSource.ENVIRONMENT, ConfigSource.RUNTIME]) {
const sourceConfig = this.state.sources.get(source);
if (sourceConfig) {
Object.assign(merged, sourceConfig);
// Special handling for environment variables (merge, don't replace)
if (source !== ConfigSource.DEFAULT && sourceConfig.environment && merged.environment) {
merged.environment = { ...merged.environment, ...sourceConfig.environment };
}
}
}
return merged;
}
/**
* Generate configuration summary for diagnostics
*/
getConfigSummary() {
return {
sources: Object.fromEntries(this.state.sources.entries()),
merged: this.state.merged,
validation: this.state.lastValidation,
changeCount: this.state.changeCount
};
}
/**
* Update configuration at runtime
*/
updateConfig(updates, source = ConfigSource.RUNTIME) {
const previousConfig = this.state.merged;
if (!previousConfig) {
return {
valid: false,
errors: ['No base configuration loaded. Call loadConfig() first.'],
warnings: []
};
}
// Apply updates to the specified source
const currentSource = this.state.sources.get(source) || {};
const updatedSource = { ...currentSource, ...updates };
// Special handling for environment variables
if (updates.environment && currentSource.environment) {
updatedSource.environment = { ...currentSource.environment, ...updates.environment };
}
this.state.sources.set(source, updatedSource);
// Re-merge and validate
const merged = this.mergeConfigs();
const validation = this.validateConfig(merged);
if (validation.valid && validation.config) {
this.state.merged = validation.config;
this.state.lastValidation = validation;
this.state.changeCount++;
// Emit change event
const changeEvent = {
source,
changes: updates,
previousConfig,
newConfig: validation.config
};
this.emit('configChanged', changeEvent);
}
return validation;
}
/**
* Get current merged configuration
*/
getCurrentConfig() {
return this.state.merged;
}
/**
* Get configuration from specific source
*/
getSourceConfig(source) {
return this.state.sources.get(source);
}
/**
* Check if configuration has been loaded and is valid
*/
isValid() {
return this.state.lastValidation?.valid === true;
}
/**
* Get last validation result
*/
getLastValidation() {
return this.state.lastValidation;
}
/**
* Reset configuration to defaults
*/
reset() {
this.state.sources.clear();
this.state.sources.set(ConfigSource.DEFAULT, { ...DEFAULT_PROXY_CONFIG });
this.state.merged = null;
this.state.lastValidation = null;
this.state.changeCount = 0;
this.emit('configReset');
}
/**
* Serialize configuration for debugging/logging
*/
toJSON() {
return {
...this.getConfigSummary(),
// Sanitize sensitive data
sanitized: this.state.merged ? {
...this.state.merged,
environment: Object.keys(this.state.merged.environment || {}).reduce((acc, key) => {
const env = this.state.merged?.environment;
acc[key] = key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')
? '[REDACTED]'
: env?.[key] || '';
return acc;
}, {})
} : null
};
}
}
// =============================================================================
// CONVENIENCE FUNCTIONS
// =============================================================================
/**
* Create and load a new configuration instance
*/
export function createConfig() {
const config = new Config();
const result = config.loadConfig();
return { config, result };
}
/**
* Validate a configuration object without creating a Config instance
*/
export function validateConfigObject(config) {
const tempConfig = new Config();
return tempConfig.validateConfig(config);
}
/**
* Load environment configuration without creating a Config instance
*/
export function getEnvironmentConfig() {
return loadEnvironmentConfig();
}
/**
* Check if a command exists and is executable
*/
export function validateCommand(command) {
try {
const cmdPath = isAbsolute(command) ? command : which(command);
if (!cmdPath) {
return { valid: false, error: `Command not found: ${command}` };
}
if (!existsSync(cmdPath)) {
return { valid: false, error: `Command path does not exist: ${cmdPath}` };
}
try {
accessSync(cmdPath, constants.X_OK);
return { valid: true, path: cmdPath };
}
catch {
return { valid: false, error: `Command is not executable: ${cmdPath}` };
}
}
catch (error) {
return {
valid: false,
error: `Error validating command: ${error instanceof Error ? error.message : 'unknown error'}`
};
}
}
// Export the configuration class as default
export default Config;
//# sourceMappingURL=config.js.map