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.

355 lines 14 kB
/** * Reloaderoo - Production Implementation * * A transparent proxy that enables hot-reloading of MCP servers during development * while maintaining client session state. Supports the full MCP protocol including * tools, resources, prompts, completion, sampling, and ping. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { // Tools ListToolsRequestSchema, CallToolRequestSchema, // Prompts ListPromptsRequestSchema, GetPromptRequestSchema, // Resources ListResourcesRequestSchema, ReadResourceRequestSchema, // Completion CompleteRequestSchema, // Sampling CreateMessageRequestSchema, // Core PingRequestSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { logger } from './mcp-logger.js'; import { MCP_PROTOCOL, PROXY_TOOLS } from './constants.js'; import { ToolRequestHandler, ResourceRequestHandler, PromptRequestHandler, CompletionRequestHandler, CoreRequestHandler } from './handlers/index.js'; /** * Production-ready Reloaderoo with full protocol support */ export class MCPProxy { config; server; childClient = null; childTransport = null; isShuttingDown = false; restartInProgress = false; childTools = []; // Request handlers toolHandler; resourceHandler; promptHandler; completionHandler; coreHandler; constructor(config) { this.config = config; // Create proxy server with full capabilities this.server = new Server({ name: `${this.extractServerName()}-dev`, version: '1.0.0-dev' }, { capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, resources: { subscribe: true, listChanged: true }, completion: { argument: true }, sampling: {} } }); // Initialize request handlers this.toolHandler = new ToolRequestHandler(this.childClient, this.childTools, this.handleRestartServer.bind(this)); this.resourceHandler = new ResourceRequestHandler(this.childClient); this.promptHandler = new PromptRequestHandler(this.childClient); this.completionHandler = new CompletionRequestHandler(this.childClient); this.coreHandler = new CoreRequestHandler(this.childClient); this.setupRequestHandlers(); this.setupErrorHandling(); } /** * Start the proxy and connect to child server */ async start() { logger.info('Starting Reloaderoo', { childCommand: this.config.childCommand, childArgs: this.config.childArgs }); // Start child server first await this.startChildServer(); // Connect proxy server to stdio const transport = new StdioServerTransport(); await this.server.connect(transport); logger.info('Reloaderoo started successfully'); } /** * Stop the proxy and cleanup resources */ async stop() { if (this.isShuttingDown) return; this.isShuttingDown = true; logger.info('Stopping Reloaderoo'); try { await this.stopChildServer(); await this.server.close(); } catch (error) { logger.error('Error during shutdown', { error }); } } /** * Start or restart the child MCP server */ async startChildServer() { await this.stopChildServer(); logger.info('Starting child MCP server', { command: this.config.childCommand, args: this.config.childArgs }); // Create transport and client for child communication // This will spawn the child process automatically this.childTransport = new StdioClientTransport({ command: this.config.childCommand, args: this.config.childArgs, env: this.config.environment }); this.childClient = new Client({ name: 'reloaderoo', version: '1.0.0' }, { capabilities: { tools: {}, prompts: {}, resources: {}, completion: {}, sampling: {} } }); // Connect to child via stdio await this.childClient.connect(this.childTransport); // Try to access child process for stderr capture try { // Check if transport exposes stderr stream const transport = this.childTransport; if (transport._stderrStream) { logger.debug('Found child stderr stream, setting up capture', undefined, 'RELOADEROO'); transport._stderrStream.on('data', (data) => { const output = data.toString().trim(); if (output) { logger.info(output, undefined, 'CHILD-MCP'); } }); } else if (transport._process && transport._process.stderr) { logger.debug('Found child process stderr, setting up capture', undefined, 'RELOADEROO'); transport._process.stderr.on('data', (data) => { const output = data.toString().trim(); if (output) { logger.info(output, undefined, 'CHILD-MCP'); } }); } else if (transport._process) { logger.debug('Found _process property, setting up stderr capture', undefined, 'RELOADEROO'); transport._process.stderr.on('data', (data) => { const output = data.toString().trim(); if (output) { logger.info(output, undefined, 'CHILD-MCP'); } }); } else { logger.debug('No stderr access available from transport', { hasStderrStream: !!transport._stderrStream, hasProcess: !!transport._process, transportKeys: Object.keys(transport) }, 'RELOADEROO'); } } catch (error) { logger.debug('Error setting up stderr capture', { error }, 'RELOADEROO'); } // Mirror child capabilities await this.mirrorChildCapabilities(); logger.info('Connected to child MCP server successfully'); } /** * Stop the child MCP server */ async stopChildServer() { if (this.childClient) { try { await this.childClient.close(); } catch (error) { logger.debug('Error closing child client', { error }); } this.childClient = null; this.childTools = []; this.updateHandlersWithChildClient(); } if (this.childTransport) { try { await this.childTransport.close(); } catch (error) { logger.debug('Error closing child transport', { error }); } this.childTransport = null; } this.childTools = []; } /** * Mirror tools and other capabilities from child server */ async mirrorChildCapabilities() { if (!this.childClient) { throw new Error('Child client not connected'); } try { // Get tools from child const toolsResult = await this.childClient.listTools(); this.childTools = toolsResult.tools || []; // Update handlers with new child client and tools this.updateHandlersWithChildClient(); logger.debug('Mirrored child capabilities', { toolCount: this.childTools.length, toolNames: this.childTools.map(t => t.name) }); // Notify about capability changes if this is a restart if (this.restartInProgress) { await this.notifyCapabilityChanges(); this.restartInProgress = false; } } catch (error) { logger.error('Failed to mirror child capabilities', { error }); // If the child server doesn't support tools/list, continue anyway // This makes Reloaderoo compatible with incomplete MCP implementations if (error instanceof McpError && error.code === ErrorCode.MethodNotFound) { logger.warn('Child server does not support tools/list - continuing with empty tool list'); this.childTools = []; this.updateHandlersWithChildClient(); if (this.restartInProgress) { this.restartInProgress = false; } return; } // For other errors, still throw to prevent startup with broken child throw error; } } /** * Send notifications about capability changes after restart */ async notifyCapabilityChanges() { try { // Notify tools changed await this.server.notification({ method: MCP_PROTOCOL.NOTIFICATIONS.TOOLS_LIST_CHANGED }); // Notify other capabilities if supported await this.server.notification({ method: MCP_PROTOCOL.NOTIFICATIONS.PROMPTS_LIST_CHANGED }); await this.server.notification({ method: MCP_PROTOCOL.NOTIFICATIONS.RESOURCES_LIST_CHANGED }); logger.debug('Sent capability change notifications'); } catch (error) { logger.debug('Error sending notifications', { error }); } } /** * Setup all MCP request handlers using dedicated handler classes */ setupRequestHandlers() { // Tools this.server.setRequestHandler(ListToolsRequestSchema, (request) => this.toolHandler.handleListTools(request)); this.server.setRequestHandler(CallToolRequestSchema, (request) => this.toolHandler.handleCallTool(request)); // Prompts this.server.setRequestHandler(ListPromptsRequestSchema, (request) => this.promptHandler.handleListPrompts(request)); this.server.setRequestHandler(GetPromptRequestSchema, (request) => this.promptHandler.handleGetPrompt(request)); // Resources this.server.setRequestHandler(ListResourcesRequestSchema, (request) => this.resourceHandler.handleListResources(request)); this.server.setRequestHandler(ReadResourceRequestSchema, (request) => this.resourceHandler.handleReadResource(request)); // Completion this.server.setRequestHandler(CompleteRequestSchema, (request) => this.completionHandler.handleComplete(request)); // Sampling this.server.setRequestHandler(CreateMessageRequestSchema, (request) => this.completionHandler.handleCreateMessage(request)); // Core this.server.setRequestHandler(PingRequestSchema, (request) => this.coreHandler.handlePing(request)); } /** * Setup tool-related request handlers */ updateHandlersWithChildClient() { this.toolHandler.updateChildClient(this.childClient); this.toolHandler.updateChildTools(this.childTools); this.resourceHandler.updateChildClient(this.childClient); this.promptHandler.updateChildClient(this.childClient); this.completionHandler.updateChildClient(this.childClient); this.coreHandler.updateChildClient(this.childClient); } /** * Handle restart_server tool call */ async handleRestartServer(args) { const force = args?.force || false; try { logger.info(`Executing ${PROXY_TOOLS.RESTART_SERVER} tool`, { force }); this.restartInProgress = true; await this.startChildServer(); return { content: [{ type: 'text', text: 'Child MCP server restarted successfully. New capabilities have been loaded.' }] }; } catch (error) { this.restartInProgress = false; const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.error('Failed to restart child server', { error: errorMessage }); return { content: [{ type: 'text', text: `Failed to restart child server: ${errorMessage}` }], isError: true }; } } /** * Setup error handling for the proxy */ setupErrorHandling() { this.server.onerror = (error) => { logger.error('Proxy server error', { error }); }; process.on('SIGINT', () => this.handleShutdown('SIGINT')); process.on('SIGTERM', () => this.handleShutdown('SIGTERM')); } /** * Handle process shutdown signals */ async handleShutdown(signal) { logger.info(`Received ${signal}, shutting down gracefully`); await this.stop(); process.exit(0); } /** * Extract server name from child command for proxy naming */ extractServerName() { const command = this.config.childCommand; if (!command || typeof command !== 'string') { return 'mcp-server'; } const parts = command.split(/[\\/]/); const filename = parts[parts.length - 1] || 'mcp-server'; return filename.replace(/\.(js|ts|py|rb|go)$/, '') || 'mcp-server'; } } //# sourceMappingURL=mcp-proxy.js.map