Skip to main content
Glama

Interactive MCP

MIT License
97
299
  • Apple
  • Linux
index.ts13.3 kB
import { spawn, ChildProcess } from 'child_process'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs/promises'; import os from 'os'; import crypto from 'crypto'; import logger from '../../utils/logger.js'; // Get the directory name of the current module const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Interface for active session info interface SessionInfo { id: string; process: ChildProcess; outputDir: string; lastHeartbeatTime: number; isActive: boolean; title: string; timeoutSeconds?: number; } // Global object to keep track of active intensive chat sessions const activeSessions: Record<string, SessionInfo> = {}; // Start heartbeat monitoring for sessions startSessionMonitoring(); /** * Generate a unique temporary directory path for a session * @returns Path to a temporary directory */ async function createSessionDir(): Promise<string> { const tempDir = os.tmpdir(); const sessionId = crypto.randomBytes(8).toString('hex'); const sessionDir = path.join(tempDir, `intensive-chat-${sessionId}`); // Create the session directory await fs.mkdir(sessionDir, { recursive: true }); return sessionDir; } /** * Start an intensive chat session * @param title Title for the chat session * @param timeoutSeconds Optional timeout for each question in seconds * @returns Session ID for the created session */ export async function startIntensiveChatSession( title: string, timeoutSeconds?: number, ): Promise<string> { // Create a session directory const sessionDir = await createSessionDir(); // Generate a unique session ID const sessionId = path.basename(sessionDir).replace('intensive-chat-', ''); // Path to the UI script - Updated to use the compiled 'ui.js' filename const uiScriptPath = path.join(__dirname, 'ui.js'); // Create options payload for the UI const options = { sessionId, title, outputDir: sessionDir, timeoutSeconds, }; // Encode options as base64 payload const payload = Buffer.from(JSON.stringify(options)).toString('base64'); // Platform-specific spawning const platform = os.platform(); let childProcess: ChildProcess; if (platform === 'darwin') { // macOS // Escape potential special characters in paths/payload for the shell command // For the shell command executed by 'do script', we primarily need to handle spaces // or other characters that might break the command if paths aren't quoted. // The `${...}` interpolation within backticks handles basic variable insertion. // Quoting the paths within nodeCommand handles spaces. const escapedScriptPath = uiScriptPath; // Keep original path, rely on quotes below const escapedPayload = payload; // Keep original payload, rely on quotes below // Construct the command string directly for the shell. Quotes handle paths with spaces. const nodeBin = process.execPath; const nodeCommand = `exec "${nodeBin}" "${escapedScriptPath}" "${escapedPayload}"; exit 0`; // Escape the node command for osascript's AppleScript string: // 1. Escape existing backslashes (\ -> \\) // 2. Escape double quotes (" -> \") const escapedNodeCommand = nodeCommand // Escape backslashes first .replace(/\\/g, '\\\\') // Using /\\/g instead of /\/g // Then escape double quotes .replace(/"/g, '\\"'); // Activate Terminal first, then do script with exec const command = `osascript -e 'tell application "Terminal" to activate' -e 'tell application "Terminal" to do script "${escapedNodeCommand}"'`; const commandArgs: string[] = []; // No args needed when command is a single string for shell // Fallback launcher using .command + open -a Terminal const launchViaOpenCommand = async () => { try { const launcherPath = path.join( sessionDir, `interactive-mcp-intchat-${sessionId}.command`, ); const scriptContent = `#!/bin/bash\nexec "${process.execPath}" "${escapedScriptPath}" "${escapedPayload}"\n`; await fs.writeFile(launcherPath, scriptContent, 'utf8'); await fs.chmod(launcherPath, 0o755); const openProc = spawn('open', ['-a', 'Terminal', launcherPath], { stdio: ['ignore', 'ignore', 'ignore'], detached: true, }); openProc.unref(); } catch (e) { logger.error( { error: e }, 'Fallback open -a Terminal failed (intensive chat)', ); } }; childProcess = spawn(command, commandArgs, { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }); childProcess.on('error', () => { void launchViaOpenCommand(); }); childProcess.on('close', (code: number | null) => { if (code !== null && code !== 0) { void launchViaOpenCommand(); } }); } else if (platform === 'win32') { // Windows childProcess = spawn(process.execPath, [uiScriptPath, payload], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, windowsHide: false, }); } else { // Linux or other - use original method (might not pop up window) childProcess = spawn(process.execPath, [uiScriptPath, payload], { stdio: ['ignore', 'ignore', 'ignore'], shell: true, detached: true, }); } // Unref the process so it can run independently childProcess.unref(); // Store session info activeSessions[sessionId] = { id: sessionId, process: childProcess, // Use the conditionally spawned process outputDir: sessionDir, lastHeartbeatTime: Date.now(), isActive: true, title, timeoutSeconds, }; // Wait a bit to ensure the UI has started await new Promise((resolve) => setTimeout(resolve, 500)); return sessionId; } /** * Ask a new question in an existing intensive chat session * @param sessionId ID of the session to ask in * @param question The question text to ask * @param predefinedOptions Optional predefined options for the question * @returns The user's response or null if session is not active */ export async function askQuestionInSession( sessionId: string, question: string, predefinedOptions?: string[], ): Promise<string | null> { const session = activeSessions[sessionId]; if (!session || !session.isActive) { return null; // Session doesn't exist or is not active } // Generate a unique ID for this question-answer pair const questionId = crypto.randomUUID(); // Create the input data object const inputData: { id: string; text: string; options?: string[] } = { id: questionId, text: question, }; if (predefinedOptions && predefinedOptions.length > 0) { inputData.options = predefinedOptions; } // Write the combined input data to a session-specific JSON file const inputFilePath = path.join(session.outputDir, `${sessionId}.json`); await fs.writeFile(inputFilePath, JSON.stringify(inputData), 'utf8'); // Wait for the response file corresponding to the generated ID const responseFilePath = path.join( session.outputDir, `response-${questionId}.txt`, ); // Wait for response with timeout const maxWaitTime = (session.timeoutSeconds ?? 60) * 1000; // Use session timeout or default to 60s const pollInterval = 100; // 100ms polling interval const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { try { // Check if the response file exists await fs.access(responseFilePath); // Read the response const response = await fs.readFile(responseFilePath, 'utf8'); // Clean up the response file await fs.unlink(responseFilePath).catch(() => {}); return response; } catch { // Response file doesn't exist yet, check session status if (!(await isSessionActive(sessionId))) { return null; // Session has ended } // Wait before polling again await new Promise((resolve) => setTimeout(resolve, pollInterval)); } } // Timeout reached return 'User closed intensive chat session'; } /** * Stop an active intensive chat session * @param sessionId ID of the session to stop * @returns True if session was stopped, false otherwise */ export async function stopIntensiveChatSession( sessionId: string, ): Promise<boolean> { const session = activeSessions[sessionId]; if (!session || !session.isActive) { return false; // Session doesn't exist or is already inactive } // Write close signal file const closeFilePath = path.join(session.outputDir, 'close-session.txt'); await fs.writeFile(closeFilePath, '', 'utf8'); // Give the process some time to exit gracefully await new Promise((resolve) => setTimeout(resolve, 500)); try { // Force kill the process if it's still running if (!session.process.killed) { // Kill process group on Unix-like systems, standard kill on Windows try { if (os.platform() !== 'win32') { process.kill(-session.process.pid!, 'SIGTERM'); } else { process.kill(session.process.pid!, 'SIGTERM'); } } catch { // console.error("Error killing process:", killError); // Fallback or ignore if process already exited or group kill failed } } } catch { // Process might have already exited } // Mark session as inactive session.isActive = false; // Clean up session directory after a delay setTimeout(() => { // Use void to mark intentionally unhandled promise void (async () => { try { await fs.rm(session.outputDir, { recursive: true, force: true }); } catch { // Ignore errors during cleanup } // Remove from active sessions delete activeSessions[sessionId]; })(); }, 2000); return true; } /** * Check if a session is still active * @param sessionId ID of the session to check * @returns True if session is active, false otherwise */ export async function isSessionActive(sessionId: string): Promise<boolean> { const session = activeSessions[sessionId]; if (!session) { return false; // Session doesn't exist } if (!session.isActive) { return false; // Session was manually marked as inactive } try { // Check the heartbeat file const heartbeatPath = path.join(session.outputDir, 'heartbeat.txt'); const stats = await fs.stat(heartbeatPath); // Check if heartbeat was updated recently (within last 2 seconds) const heartbeatAge = Date.now() - stats.mtime.getTime(); if (heartbeatAge > 2000) { // Heartbeat is too old, session is likely dead session.isActive = false; return false; } return true; } catch (err: unknown) { // If error is ENOENT (file not found), assume session is still starting // Check if err is an object and has a code property before accessing it if ( err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT' ) { // Optional: Could add a check here to see if the session is very new // e.g., if (Date.now() - session.startTime < 2000) return true; // For now, let's assume ENOENT means it's possibly still starting. return true; } // Handle cases where err is not an object with a code property or other errors logger.error( { sessionId, error: err instanceof Error ? err.message : String(err) }, `Error checking heartbeat for session ${sessionId}`, ); session.isActive = false; return false; } } /** * Start background monitoring of all active sessions */ function startSessionMonitoring() { // Remove async from setInterval callback setInterval(() => { // Use void to mark intentionally unhandled promise void (async () => { for (const sessionId of Object.keys(activeSessions)) { const isActive = await isSessionActive(sessionId); if (!isActive && activeSessions[sessionId]) { // Clean up inactive session try { // Kill process if it's somehow still running if (!activeSessions[sessionId].process.killed) { try { if (os.platform() !== 'win32') { process.kill( -activeSessions[sessionId].process.pid!, 'SIGTERM', ); } else { process.kill( activeSessions[sessionId].process.pid!, 'SIGTERM', ); } } catch { // console.error("Error killing process:", killError); // Ignore errors during cleanup } } } catch { // Ignore errors during cleanup } // Clean up session directory try { await fs.rm(activeSessions[sessionId].outputDir, { recursive: true, force: true, }); } catch { // Ignore errors during cleanup } // Remove from active sessions delete activeSessions[sessionId]; } } })(); }, 5000); // Check every 5 seconds }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ttommyth/interactive-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server