UNPKG

repl-mcp

Version:

Universal REPL session manager MCP server

743 lines 33.4 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { SessionManager } from './session-manager.js'; // Remove writeFileSync import import { DEFAULT_REPL_CONFIGS, getConfigByName, listAvailableConfigs } from './repl-configs.js'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; // Web server imports import express from 'express'; import { createServer } from 'http'; import { WebSocketServer, WebSocket } from 'ws'; import path from 'path'; import url from 'url'; // Get version info from package.json function getVersionInfo() { try { const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageJsonPath = join(__dirname, '..', 'package.json'); const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); return { version: packageJson.version, name: packageJson.name, description: packageJson.description, author: packageJson.author, license: packageJson.license, repository: packageJson.repository?.url, homepage: packageJson.homepage }; } catch (error) { return { version: "unknown", name: "repl-mcp", description: "Universal REPL session manager MCP server", error: "Failed to read package.json" }; } } // Create MCP server const server = new Server({ name: "repl-mcp", version: getVersionInfo().version }, { capabilities: { tools: {} } }); // Create session manager const sessionManager = new SessionManager(); // Schema definitions const CreateSessionSchema = z.object({ presetConfig: z.string().optional().describe("Pre-defined configuration name"), displayName: z.string().optional().describe("Custom display name for the session (shown in browser tab)"), customConfig: z.object({ type: z.enum(['pry', 'irb', 'ipython', 'node', 'python', 'bash', 'zsh', 'cmd', 'custom']).describe("REPL type"), shell: z.enum(['bash', 'zsh', 'cmd', 'powershell']).describe("Shell type"), commands: z.array(z.string()).describe("Commands to execute in order. The last command should start the REPL."), startingDirectory: z.string().optional().describe("Host directory where the shell process will start (must exist)"), environment: z.record(z.string()).optional().describe("Environment variables"), timeout: z.number().optional().describe("Command timeout in milliseconds") }).optional().describe("Custom configuration"), debug: z.boolean().optional().describe("Include debug logs in response (default: auto - included for failures/LLM assistance)") }); const SendInputSchema = z.object({ sessionId: z.string().describe("Session ID"), input: z.string().describe("Input text to send to the session"), options: z.object({ wait_for_prompt: z.boolean().optional().describe("Wait for prompt to return (default: false)"), timeout: z.number().optional().describe("Timeout in milliseconds (default: 30000)"), add_newline: z.boolean().optional().describe("Add newline to input (default: true)") }).optional().describe("Input options") }); const SendSignalSchema = z.object({ sessionId: z.string().describe("Session ID"), signal: z.enum(['SIGINT', 'SIGTSTP', 'SIGQUIT']).describe("Signal to send to the process") }); const SetSessionReadySchema = z.object({ sessionId: z.string().describe("Session ID"), pattern: z.string().describe("Prompt pattern that was detected (can be regex like '[\\d]+] pry\\(main\\)> $' or literal like '$ ')") }); const WaitForSessionSchema = z.object({ sessionId: z.string().describe("Session ID"), seconds: z.number().describe("Number of seconds to wait") }); const MarkSessionFailedSchema = z.object({ sessionId: z.string().describe("Session ID"), reason: z.string().describe("Reason for failure") }); const SessionIdSchema = z.object({ sessionId: z.string().describe("Session ID"), debug: z.boolean().optional().describe("Include debug logs in response (default: false)") }); const ListSessionsSchema = z.object({ debug: z.boolean().optional().describe("Include debug logs in response (default: false)") }); const ListConfigsSchema = z.object({ debug: z.boolean().optional().describe("Include debug logs in response (default: false)") }); const GetFullOutputSchema = z.object({ sessionId: z.string().describe("Session ID"), offset: z.number().optional().describe("Starting position in characters (default: 0)"), limit: z.number().optional().describe("Number of characters to retrieve (default: 40000)") }); const GetCleanTextSchema = z.object({ sessionId: z.string().describe("Session ID"), fullText: z.boolean().optional().describe("Get full terminal text instead of current line (default: false)") }); // Handle list tools request server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "create_session", description: "Create a new REPL session with preset or custom configuration. Use displayName to set a custom name that appears in the browser tab title. Returns a webUrl - you MUST display this URL to the user or open it in a browser. Note: If using zsh in custom commands, you may need to manually run 'histchars=' to disable history expansion.", inputSchema: zodToJsonSchema(CreateSessionSchema) }, { name: "send_input_to_session", description: "Send input to a REPL session. Can either wait for prompt return (like execute_repl_command) or send input immediately for interactive programs. ALWAYS show the command output to the user after execution when wait_for_prompt is true.", inputSchema: zodToJsonSchema(SendInputSchema) }, { name: "send_signal_to_session", description: "Send a signal (like Ctrl+C, Ctrl+Z) to a REPL session process. Can be used even when session is executing to interrupt long-running commands.", inputSchema: zodToJsonSchema(SendSignalSchema) }, { name: "list_repl_sessions", description: "List all active REPL sessions. Each session includes a webUrl for browser access. Please show the webUrls to the user so they can open sessions in their browser.", inputSchema: zodToJsonSchema(ListSessionsSchema) }, { name: "get_session_details", description: "Get detailed information about a specific session. Includes webUrl for browser access. Please show the webUrl to the user so they can open the session in their browser.", inputSchema: zodToJsonSchema(SessionIdSchema) }, { name: "destroy_repl_session", description: "Destroy an existing REPL session", inputSchema: zodToJsonSchema(SessionIdSchema) }, { name: "list_repl_configurations", description: "List all available predefined REPL configurations", inputSchema: zodToJsonSchema(ListConfigsSchema) }, { name: "set_session_ready", description: "Mark a session as ready by specifying the detected prompt pattern. Supports regex patterns (e.g., '[\\d]+] pry\\(main\\)> $') or literal strings (e.g., '$ '). Used during LLM-assisted session recovery.", inputSchema: zodToJsonSchema(SetSessionReadySchema) }, { name: "wait_for_session", description: "Wait additional seconds for a session to become ready. Used during LLM-assisted session recovery when more time is needed.", inputSchema: zodToJsonSchema(WaitForSessionSchema) }, { name: "mark_session_failed", description: "Mark a session as failed with a specific reason. Used during LLM-assisted session recovery when recovery is not possible.", inputSchema: zodToJsonSchema(MarkSessionFailedSchema) }, { name: "get_full_output", description: "Get the last command's output buffer for a session in chunks to avoid token limits. This returns the output from the most recent command execution. Use offset and limit parameters to retrieve specific portions of large outputs.", inputSchema: zodToJsonSchema(GetFullOutputSchema) }, { name: "get_clean_text", description: "Get clean terminal text without ANSI escape codes. Returns either the current line (where cursor is) or full terminal content depending on the fullText parameter.", inputSchema: zodToJsonSchema(GetCleanTextSchema) }, ] }; }); // Handle call tool request server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; let attemptedSessionId; try { switch (name) { case "create_session": { const params = CreateSessionSchema.parse(args); const { presetConfig, displayName, customConfig, debug } = params; let config; if (presetConfig) { const predefinedConfig = getConfigByName(presetConfig); if (!predefinedConfig) { return { content: [ { type: "text", text: `Configuration '${presetConfig}' not found. Available configurations: ${listAvailableConfigs().join(', ')}` } ], isError: true }; } config = predefinedConfig; } else if (customConfig) { config = { name: `Custom ${customConfig.type}`, ...customConfig }; } else { return { content: [ { type: "text", text: "Either presetConfig or customConfig must be provided" } ], isError: true }; } const result = await sessionManager.createSession(config, displayName); attemptedSessionId = result.sessionId; const response = { ...result, config: config.name }; // Add Web UI URL if session ID exists (even for LLM assistance cases) if (result.sessionId) { const port = global.webServerPort || 8023; response.webUrl = `http://localhost:${port}/session/${result.sessionId}`; } // Auto-include debug logs for failures, LLM assistance, or when explicitly requested if (debug || !result.success || result.question) { response.debugLogs = sessionManager.getDebugLogs(result.sessionId); } return { content: [ { type: "text", text: JSON.stringify(response, null, 2) } ] }; } case "send_input_to_session": { const params = SendInputSchema.parse(args); const { sessionId, input, options = {} } = params; const { wait_for_prompt = false, timeout = 30000, add_newline = true } = options; const result = await sessionManager.sendInput(sessionId, input, { wait_for_prompt, timeout, add_newline }); const response = { success: result.success, rawOutput: result.rawOutput, error: result.error, executionTime: result.executionTime, // LLM assistance fields question: result.question, questionType: result.questionType, context: result.context, canContinue: result.canContinue, // Hint for agent behavior hint: result.success && wait_for_prompt ? "Decode ANSI escape codes, extract meaningful output, and present it to the user in a clean, readable format" : undefined }; return { content: [ { type: "text", text: JSON.stringify(response, null, 2) } ] }; } case "send_signal_to_session": { const params = SendSignalSchema.parse(args); const { sessionId, signal } = params; const result = await sessionManager.sendSignal(sessionId, signal); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) } ] }; } case "set_session_ready": { const params = SetSessionReadySchema.parse(args); const { sessionId, pattern } = params; const result = await sessionManager.setSessionReady(sessionId, pattern); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) } ] }; } case "wait_for_session": { const params = WaitForSessionSchema.parse(args); const { sessionId, seconds } = params; const result = await sessionManager.waitForSession(sessionId, seconds); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) } ] }; } case "mark_session_failed": { const params = MarkSessionFailedSchema.parse(args); const { sessionId, reason } = params; const result = await sessionManager.markSessionFailed(sessionId, reason); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) } ] }; } case "list_repl_sessions": { const params = ListSessionsSchema.parse(args); const sessions = sessionManager.listSessions(); const response = { success: true, sessions: sessions.map(session => ({ id: session.id, name: session.config.name, displayName: session.displayName, type: session.config.type, status: session.status, createdAt: session.createdAt, lastActivity: session.lastActivity, historyCount: session.history.length, webUrl: `http://localhost:${global.webServerPort || 8023}/session/${session.id}` })) }; // Only include debug logs if explicitly requested if (params.debug) { response.debugLogs = sessionManager.getDebugLogs(); } return { content: [ { type: "text", text: JSON.stringify(response, null, 2) } ] }; } case "get_session_details": { const params = SessionIdSchema.parse(args); const { sessionId, debug } = params; const session = sessionManager.getSession(sessionId); if (!session) { return { content: [ { type: "text", text: JSON.stringify({ success: false, error: `Session ${sessionId} not found` }, null, 2) } ], isError: true }; } const response = { success: true, session: { id: session.id, config: session.config, displayName: session.displayName, status: session.status, currentDirectory: session.currentDirectory, history: session.history, lastOutput: session.lastOutput, lastError: session.lastError, createdAt: session.createdAt, lastActivity: session.lastActivity, webUrl: `http://localhost:${global.webServerPort || 8023}/session/${session.id}` } }; // Only include debug logs if explicitly requested if (debug) { response.debugLogs = sessionManager.getDebugLogs(sessionId); } return { content: [ { type: "text", text: JSON.stringify(response, null, 2) } ] }; } case "destroy_repl_session": { const params = SessionIdSchema.parse(args); const { sessionId } = params; const success = await sessionManager.destroySession(sessionId); return { content: [ { type: "text", text: JSON.stringify({ success, message: success ? `Session ${sessionId} destroyed successfully` : `Session ${sessionId} not found` }, null, 2) } ] }; } case "list_repl_configurations": { const params = ListConfigsSchema.parse(args); const configs = listAvailableConfigs(); const configDetails = configs.map((name) => ({ name, config: DEFAULT_REPL_CONFIGS[name] })); const response = { success: true, configurations: configDetails }; // Only include debug logs if explicitly requested if (params.debug) { response.debugLogs = sessionManager.getDebugLogs(); } return { content: [ { type: "text", text: JSON.stringify(response, null, 2) } ] }; } case "get_full_output": { const params = GetFullOutputSchema.parse(args); const { sessionId, offset = 0, limit = 40000 } = params; const result = sessionManager.getFullOutput(sessionId, offset, limit); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) } ] }; } case "get_clean_text": { const params = GetCleanTextSchema.parse(args); const { sessionId, fullText = false } = params; const cleanText = fullText ? sessionManager.getFullCleanText(sessionId) : sessionManager.getCurrentLineCleanText(sessionId); return { content: [ { type: "text", text: JSON.stringify({ success: cleanText !== null, cleanText: cleanText || '', fullText }, null, 2) } ] }; } default: return { content: [ { type: "text", text: `Unknown tool: ${name}` } ], isError: true }; } } catch (error) { return { content: [ { type: "text", text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error), debugLogs: attemptedSessionId ? sessionManager.getDebugLogs(attemptedSessionId) : sessionManager.getDebugLogs() // Fallback to global logs only if no sessionId }, null, 2) } ], isError: true }; } finally { // sessionManager.clearDebugLogs(); // Clear logs after each request } }); // Web server setup function setupWebServer() { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const basePort = 8023; // HTTP (80) + Telnet (23) = Terminal-like port // Serve static files from public/ at root level app.use(express.static(path.join(__dirname, '../public'))); // Route for root page (server status) app.get('/', (_req, res) => { const indexPath = path.resolve(__dirname, '../public/index.html'); try { const fileContent = readFileSync(indexPath, 'utf8'); res.setHeader('Content-Type', 'text/html'); res.send(fileContent); } catch (err) { console.error(`Error reading file: ${indexPath}`, err); res.status(500).send('Internal Server Error'); } }); // API endpoint to get session info app.get('/api/session/:sessionId', (req, res) => { const sessionId = req.params.sessionId; const session = sessionManager.getSession(sessionId); if (!session) { res.status(404).json({ error: 'Session not found' }); return; } res.json({ id: session.id, displayName: session.displayName }); }); // Route for session pages app.get('/session/:sessionId', (_req, res) => { const sessionPath = path.resolve(__dirname, '../public/session.html'); try { const fileContent = readFileSync(sessionPath, 'utf8'); res.setHeader('Content-Type', 'text/html'); res.send(fileContent); } catch (err) { console.error(`Error reading file: ${sessionPath}`, err); res.status(500).send('Internal Server Error'); } }); const httpServer = createServer(app); const wss = new WebSocketServer({ server: httpServer, path: '/terminal' }); wss.on('connection', (ws, req) => { // Extract sessionId from URL query const parsedUrl = url.parse(req.url || '', true); const sessionId = parsedUrl.query.sessionId; console.error(`[DEBUG] WebSocket connection established for session: ${sessionId}`); if (!sessionId) { ws.send('Error: sessionId is required\r\n'); ws.close(); return; } // Get existing session const session = sessionManager.getSession(sessionId); if (!session) { ws.send(`Error: Session ${sessionId} not found\r\n`); ws.close(); return; } // Allow WebUI access for sessions that can potentially be recovered via LLM assistance if (session.status !== 'ready' && session.status !== 'error') { ws.send(`Error: Session ${sessionId} is not ready (status: ${session.status})\r\n`); ws.close(); return; } // For error status sessions, check if they have an active process (indicating they might be recoverable) if (session.status === 'error' && !session.process) { ws.send(`Error: Session ${sessionId} is in error state and cannot be accessed (no active process)\r\n`); ws.close(); return; } if (!session.process) { ws.send(`Error: Session ${sessionId} has no active process\r\n`); ws.close(); return; } console.error(`WebSocket connected to session ${sessionId}`); // Restore terminal state from server-side terminal const serializedState = sessionManager.getSerializedTerminalState(sessionId); if (serializedState) { console.error(`Restoring terminal state for session ${sessionId}`); ws.send(serializedState); } else { console.error(`No terminal state to restore for session ${sessionId}`); } // Connect to existing session's pty process const ptyProcess = session.process; // pty output → WebSocket const dataHandler = (data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }; ptyProcess.onData(dataHandler); // WebSocket input → pty ws.on('message', (msg) => { const message = msg.toString(); // Log WebSocket input using the built-in log system sessionManager.log(`WebSocket received: "${message}" (length: ${message.length}, char codes: [${[...message].map(c => c.charCodeAt(0)).join(', ')}])`, sessionId); try { // Parse as structured JSON message const data = JSON.parse(message); if (data && typeof data === 'object' && data.type) { switch (data.type) { case 'terminal_input': // Terminal text input (all characters sent as-is) if (typeof data.data === 'string') { sessionManager.log(`Processing terminal input: "${data.data}"`, sessionId); try { ptyProcess.write(data.data); sessionManager.log(`Successfully wrote to pty: "${data.data}"`, sessionId); } catch (writeError) { sessionManager.log(`ERROR writing to pty: ${writeError}`, sessionId); } } else { sessionManager.log(`Invalid terminal_input data type: ${typeof data.data}`, sessionId); } return; case 'send_signal': // Signal sending (Ctrl+C, Ctrl+Z, etc.) if (typeof data.signal === 'string') { sessionManager.sendSignal(sessionId, data.signal); } else { sessionManager.log(`Invalid send_signal data type: ${typeof data.signal}`, sessionId); } return; case 'resize': // Terminal resize if (data.data && typeof data.data === 'object' && data.data.cols && data.data.rows) { sessionManager.log(`Resizing terminal to ${data.data.cols}x${data.data.rows}`, sessionId); ptyProcess.resize(data.data.cols, data.data.rows); } else { sessionManager.log(`Invalid resize data format`, sessionId); } return; default: sessionManager.log(`Unknown message type: ${data.type}`, sessionId); return; } } // Invalid JSON structure - missing or invalid type field sessionManager.log(`Invalid message format - missing or invalid type field: "${message}"`, sessionId); } catch (e) { // Invalid JSON format sessionManager.log(`Invalid JSON format: "${message}", error: ${e}`, sessionId); } }); ws.on('close', () => { console.error(`WebSocket disconnected from session ${sessionId}`); // Keep session alive, only disconnect WebSocket // TODO: Remove dataHandler from ptyProcess if needed }); }); // Find available port starting from basePort function findAvailablePort(startPort) { return new Promise((resolve, reject) => { const server = createServer(); server.listen(startPort, () => { const port = server.address().port; server.close(() => { resolve(port); }); }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { // Try next port findAvailablePort(startPort + 1).then(resolve).catch(reject); } else { reject(err); } }); }); } // Start server with dynamic port selection findAvailablePort(basePort).then(port => { httpServer.listen(port, () => { console.error(`Web UI available at http://localhost:${port}`); console.error(`Connect to session: http://localhost:${port}/session/YOUR_SESSION_ID`); console.error(`Use --no-web-ui to disable Web UI`); // Update global port for URL generation global.webServerPort = port; }); }).catch(error => { console.error(`Failed to start Web server: ${error}`); }); } // Parse command line arguments function parseArgs() { const args = process.argv.slice(2); // Handle --version flag if (args.includes('--version')) { const versionInfo = getVersionInfo(); console.log(`${versionInfo.name} v${versionInfo.version}`); console.log(versionInfo.description); console.log(`Author: ${versionInfo.author}`); console.log(`License: ${versionInfo.license}`); console.log(`Repository: ${versionInfo.repository}`); console.log(`Homepage: ${versionInfo.homepage}`); process.exit(0); } return { noWebUI: args.includes('--no-web-ui') }; } // Start the server async function main() { const { noWebUI } = parseArgs(); // Start MCP server on stdio const transport = new StdioServerTransport(); await server.connect(transport); console.error('REPL MCP server running on stdio'); // Start Web server on HTTP (unless disabled) if (!noWebUI) { setupWebServer(); } else { console.error('Web UI disabled by --no-web-ui flag'); } } main().catch(console.error); //# sourceMappingURL=index.js.map