UNPKG

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.

333 lines 14.8 kB
/** * RestartHandler for processing restart_server tool calls with validation, * config updates, notifications, and ProcessManager integration. */ import { EventEmitter } from 'events'; import { logger } from './mcp-logger.js'; import { ProcessState, PROXY_ERROR_RESPONSES, isRestartServerRequest } from './types.js'; /** * RestartHandler manages restart_server tool calls with secure config updates, * error handling, client notifications and rate limiting for system stability. */ export class RestartHandler extends EventEmitter { processManager; sendNotification; getServerInfo; state; rateLimit; /** Initialize RestartHandler with dependencies and configuration. */ constructor(processManager, sendNotification, getServerInfo, rateLimitConfig) { super(); this.processManager = processManager; this.sendNotification = sendNotification; this.getServerInfo = getServerInfo; this.state = { isRestartInProgress: false, lastRestartTime: 0, concurrentRequests: new Set(), operationCount: 0 }; this.rateLimit = { minInterval: 5000, // 5 seconds minimum between restarts maxConcurrent: 1, maxPerHour: 12, ...rateLimitConfig }; this.setupProcessManagerListeners(); } /** Main entry point for handling restart_server tool calls. */ async handleRestartTool(request) { const requestId = String(request.id); // Validate this is actually a restart_server request (before any state changes) if (!isRestartServerRequest(request)) { return this.createErrorResponse(request.id, PROXY_ERROR_RESPONSES.INVALID_RESTART_CONFIG, 'Request is not a valid restart_server tool call'); } // Extract tool call parameters const toolCall = request.params; const restartParams = toolCall.arguments; // Atomically check rate limits and set restart flag const rateLimitError = this.checkRateLimitAndSetFlag(requestId); if (rateLimitError) { return rateLimitError; } try { // Validate restart request parameters (now inside try block to ensure finally runs) const validation = this.validateRestartRequest(restartParams); if (!validation.valid) { return this.createErrorResponse(request.id, PROXY_ERROR_RESPONSES.INVALID_RESTART_CONFIG, validation.error || 'Invalid restart request'); } // Execute the restart operation const result = await this.executeRestart(restartParams); // Send success notifications await this.sendRestartNotifications(); this.emit('restart-completed', result); return this.createRestartResult(request.id, result); } catch (error) { logger.error('Restart tool handler failed', { error: error instanceof Error ? error.message : String(error), requestId }); this.emit('restart-failed', error, this.state.operationCount); return this.createErrorResponse(request.id, PROXY_ERROR_RESPONSES.RESTART_FAILED, error instanceof Error ? error.message : 'Unknown restart error'); } finally { // Always reset the restart flag to prevent permanent blocking this.state.isRestartInProgress = false; this.state.concurrentRequests.delete(requestId); } } /** Validate restart request parameters. */ validateRestartRequest(params) { try { // Basic parameter validation if (params && typeof params !== 'object') { return { valid: false, error: 'Parameters must be an object' }; } // Validate configuration update if provided if (params?.config) { const configValidation = this.validateConfigUpdate(params.config); if (!configValidation.valid) { return { valid: false, error: configValidation.error || 'Configuration validation failed' }; } } // Validate force parameter if (params?.force !== undefined && typeof params.force !== 'boolean') { return { valid: false, error: 'Force parameter must be a boolean' }; } return { valid: true }; } catch (error) { return { valid: false, error: `Validation error: ${error instanceof Error ? error.message : String(error)}` }; } } /** Execute the restart operation with configuration updates. */ async executeRestart(params) { const startTime = Date.now(); // Note: isRestartInProgress flag is already set by handleRestartTool this.state.operationCount++; try { // Check if restart is necessary (unless forced) if (!params.force && this.processManager.getState() === ProcessState.RUNNING) { const isHealthy = await this.processManager.isHealthy(); if (isHealthy) { logger.info('Server is healthy, skipping restart (use force=true to override)'); } } // Apply configuration updates if provided let appliedConfig; if (params.config) { appliedConfig = this.sanitizeConfigUpdate(params.config); this.emit('config-validated', appliedConfig); } // Emit restart initiated event this.emit('restart-initiated', appliedConfig); // Perform the restart via ProcessManager await this.processManager.restart(appliedConfig); // Calculate restart time and get updated server info const restartTime = Date.now() - startTime; const serverInfo = this.getServerInfo(); if (!serverInfo) { throw new Error('Server information not available after restart'); } const result = { success: true, message: appliedConfig ? 'Server restarted successfully with configuration updates' : 'Server restarted successfully', restartTime, serverInfo, restartCount: this.processManager.getRestartCount() }; this.state.lastRestartTime = Date.now(); logger.info('Restart operation completed successfully', { restartTime, restartCount: result.restartCount }); return result; } finally { // Note: isRestartInProgress flag is reset by handleRestartTool's finally block // to ensure proper lifecycle management across the entire operation } } /** Send notifications to client after successful restart. */ async sendRestartNotifications() { const serverInfo = this.getServerInfo(); if (!serverInfo) { logger.warn('Cannot send restart notifications: server info not available'); return; } const sentNotifications = []; try { // Always send tools/list_changed since we add restart_server tool await this.sendNotification('notifications/tools/list_changed'); sentNotifications.push('tools/list_changed'); // Send resource notifications if child server supports resources if (serverInfo.capabilities.resources) { await this.sendNotification('notifications/resources/list_changed'); sentNotifications.push('resources/list_changed'); } // Send prompt notifications if child server supports prompts if (serverInfo.capabilities.prompts) { await this.sendNotification('notifications/prompts/list_changed'); sentNotifications.push('prompts/list_changed'); } this.emit('notifications-sent', sentNotifications); logger.debug('Restart notifications sent successfully', { notifications: sentNotifications }); } catch (error) { logger.error('Failed to send restart notifications', { error: error instanceof Error ? error.message : String(error), sentNotifications }); // Don't throw - restart was successful even if notifications failed } } /** Create a successful CallToolResult response for restart operations. */ createRestartResult(requestId, result) { const toolResult = { content: [ { type: 'text', text: `${result.message}\n\nRestart completed in ${result.restartTime}ms\n` + `Server: ${result.serverInfo.name} v${result.serverInfo.version}\n` + `Total restarts: ${result.restartCount}` } ], isError: false }; return { jsonrpc: '2.0', id: requestId, result: toolResult }; } /** Validate and sanitize configuration updates for security. */ validateConfigUpdate(config) { // Validate environment variables if (config.environment) { if (typeof config.environment !== 'object' || Array.isArray(config.environment)) { return { valid: false, error: 'Environment must be an object' }; } for (const [key, value] of Object.entries(config.environment)) { if (typeof key !== 'string' || typeof value !== 'string') { return { valid: false, error: 'Environment variables must be string key-value pairs' }; } } } // Validate child arguments if (config.childArgs) { if (!Array.isArray(config.childArgs)) { return { valid: false, error: 'Child arguments must be an array' }; } if (!config.childArgs.every(arg => typeof arg === 'string')) { return { valid: false, error: 'All child arguments must be strings' }; } } // Validate working directory if (config.workingDirectory !== undefined) { if (typeof config.workingDirectory !== 'string') { return { valid: false, error: 'Working directory must be a string' }; } } return { valid: true }; } /** Sanitize configuration updates by removing dangerous values. */ sanitizeConfigUpdate(config) { const sanitized = {}; // Sanitize environment variables if (config.environment) { sanitized.environment = {}; for (const [key, value] of Object.entries(config.environment)) { // Remove potentially dangerous environment variables if (!this.isDangerousEnvVar(key)) { sanitized.environment[key] = String(value).trim(); } } } // Sanitize child arguments (remove obviously dangerous ones) if (config.childArgs) { sanitized.childArgs = config.childArgs .map(arg => String(arg).trim()) .filter(arg => !this.isDangerousArg(arg)); } // Sanitize working directory if (config.workingDirectory) { sanitized.workingDirectory = String(config.workingDirectory).trim(); } return sanitized; } /** Check if environment variable name is dangerous. */ isDangerousEnvVar(name) { const dangerous = ['PATH', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'HOME', 'USER']; return dangerous.includes(name.toUpperCase()); } /** Check if command line argument is dangerous. */ isDangerousArg(arg) { return arg.includes(';') || arg.includes('&&') || arg.includes('||') || arg.includes('|') || arg.includes('>') || arg.includes('<'); } /** Apply rate limiting and atomically set restart flag if checks pass. */ checkRateLimitAndSetFlag(requestId) { const now = Date.now(); // Check if restart is already in progress (atomic check) if (this.state.isRestartInProgress) { return this.createErrorResponse(requestId, PROXY_ERROR_RESPONSES.RESTART_IN_PROGRESS, 'A restart operation is already in progress'); } // Check concurrent requests if (this.state.concurrentRequests.size >= this.rateLimit.maxConcurrent) { return this.createErrorResponse(requestId, PROXY_ERROR_RESPONSES.RESTART_IN_PROGRESS, 'Another restart operation is already in progress'); } // Check minimum interval if (now - this.state.lastRestartTime < this.rateLimit.minInterval) { const remaining = Math.ceil((this.rateLimit.minInterval - (now - this.state.lastRestartTime)) / 1000); return this.createErrorResponse(requestId, PROXY_ERROR_RESPONSES.RESTART_IN_PROGRESS, `Please wait ${remaining} seconds before requesting another restart`); } // All checks passed - atomically set the restart flag and track request this.state.isRestartInProgress = true; this.state.concurrentRequests.add(requestId); return null; } /** Create a JSON-RPC error response. */ createErrorResponse(requestId, _errorInfo, details) { const toolResult = { content: [ { type: 'text', text: `Restart failed: ${details}` } ], isError: true }; return { jsonrpc: '2.0', id: requestId, result: toolResult }; } /** Setup ProcessManager event listeners. */ setupProcessManagerListeners() { this.processManager.on('restarting', (reason) => { logger.debug('ProcessManager restart initiated', { reason }); }); this.processManager.on('restarted', (pid, restartTime) => { logger.debug('ProcessManager restart completed', { pid, restartTime }); }); this.processManager.on('restart-failed', (error) => { logger.error('ProcessManager restart failed', { error: error.message }); }); } /** Get current restart handler state for monitoring. */ getState() { return { ...this.state }; } /** Check if restart is in progress. */ isRestartInProgress() { return this.state.isRestartInProgress; } } //# sourceMappingURL=restart-handler.js.map