Skip to main content
Glama
mcp-server-manager.ts10.8 kB
/** * Story 1: MCP Server Lifecycle Management - MCPServerManager Implementation * * SPEC-COMPLIANT IMPLEMENTATION - ONLY implements the 5 acceptance criteria: * 1. Launch fresh MCP server instance * 2. Establish stdin/stdout communication * 3. Clean up server process when testing completes * 4. Handle server startup failures gracefully * 5. Ensure no port conflicts with other test runs * * NO EventEmitter, NO unauthorized methods, NO over-engineering */ import { spawn, ChildProcess } from "child_process"; import * as fs from "fs"; import * as path from "path"; export interface MCPServerConfig { serverPath?: string; timeout?: number; port?: number; shutdownTimeout?: number; } export interface ServerProcess { pid: number; isRunning: boolean; startTime: Date; stdin: NodeJS.WritableStream | null; stdout: NodeJS.ReadableStream | null; } type ServerState = 'stopped' | 'starting' | 'running' | 'stopping'; export class MCPServerManager { private config: Required<MCPServerConfig>; private serverProcess: ChildProcess | null = null; private state: ServerState = 'stopped'; private startTime: Date | null = null; constructor(config: MCPServerConfig = {}) { // Basic configuration with defaults - no complex validation this.config = { serverPath: config.serverPath || this.findServerPath(), timeout: config.timeout !== undefined ? config.timeout : 30000, port: config.port !== undefined ? config.port : 0, // Auto-discover shutdownTimeout: config.shutdownTimeout !== undefined ? config.shutdownTimeout : 10000 }; // Basic validation only if (this.config.timeout <= 0) { throw new Error("Timeout must be positive"); } } /** * Find the MCP server file path, trying multiple locations */ private findServerPath(): string { const possiblePaths = [ path.join(process.cwd(), "dist/src/mcp-server.js"), path.join(process.cwd(), "dist/src/mcp-ssh-server.js"), path.join(process.cwd(), "src/mcp-server.js"), // Fallback for non-built environments path.join(process.cwd(), "src/mcp-ssh-server.js") ]; for (const serverPath of possiblePaths) { if (fs.existsSync(serverPath)) { return serverPath; } } // If no file found, return the preferred default for better error messages return path.join(process.cwd(), "dist/src/mcp-server.js"); } /** * Check if server is currently running */ public isRunning(): boolean { return this.state === 'running' && this.serverProcess !== null && !this.serverProcess.killed; } /** * Get current server state */ public getState(): ServerState { return this.state; } /** * Get server process information */ public getProcess(): ServerProcess | null { if (!this.serverProcess || !this.startTime) { return null; } return { pid: this.serverProcess.pid || 0, isRunning: this.isRunning(), startTime: this.startTime, stdin: this.serverProcess.stdin, stdout: this.serverProcess.stdout }; } /** * Get raw child process for components that need direct access */ public getRawProcess(): ChildProcess | null { return this.serverProcess; } /** * ACCEPTANCE CRITERIA 1 & 2: Launch fresh MCP server instance with stdin/stdout communication */ public async start(): Promise<void> { if (this.isRunning()) { throw new Error("Server is already running"); } // ACCEPTANCE CRITERIA 4: Handle server startup failures gracefully if (!fs.existsSync(this.config.serverPath)) { throw new Error("Server file does not exist"); } try { await this.launchServerProcess(); } catch (error) { this.state = 'stopped'; throw error; } } private async launchServerProcess(): Promise<void> { this.state = 'starting'; const env: NodeJS.ProcessEnv = { ...process.env }; // ACCEPTANCE CRITERIA 5: Ensure no port conflicts - only set port if specified if (this.config.port > 0) { env.WEB_PORT = this.config.port.toString(); } try { // ACCEPTANCE CRITERIA 1 & 2: Launch with stdin/stdout communication this.serverProcess = spawn('node', [this.config.serverPath], { stdio: ['pipe', 'pipe', 'pipe'], // stdin, stdout, stderr env: env, detached: false }); if (!this.serverProcess.pid) { throw new Error("Failed to spawn server process"); } this.startTime = new Date(); // Basic process event handlers - no event emissions this.setupBasicProcessHandlers(); // Wait for server to be ready await this.waitForServerReady(); this.state = 'running'; } catch (error) { await this.cleanupProcess(); // ACCEPTANCE CRITERIA 4: Handle startup failures gracefully with clear error messages if (error instanceof Error) { if (error.message.includes('ENOENT')) { throw new Error(`Server file not executable: ${this.config.serverPath}`); } else if (error.message.includes('EACCES')) { throw new Error(`Permission denied executing server: ${this.config.serverPath}`); } else if (error.message.includes('EADDRINUSE')) { throw new Error(`Port conflict detected for port ${this.config.port}`); } } throw error; } } private setupBasicProcessHandlers(): void { if (!this.serverProcess) return; // Basic error handling - no event emissions this.serverProcess.on('error', () => { this.state = 'stopped'; void this.cleanupProcess(); }); this.serverProcess.on('exit', () => { this.state = 'stopped'; void this.cleanupProcess(); }); // Basic stderr monitoring for port conflicts only this.serverProcess.stderr?.on('data', (data: Buffer) => { const errorText = data.toString(); if (errorText.includes('EADDRINUSE')) { // Port conflict detected - will be handled by process exit } }); } private async waitForServerReady(): Promise<void> { if (!this.serverProcess) { throw new Error("No server process to wait for"); } return new Promise<void>((resolve, reject) => { const timeoutHandle = setTimeout(() => { reject(new Error("Server startup timeout")); }, this.config.timeout); // Simple readiness check - minimal delay const readyCheckDelay = Math.min(2000, this.config.timeout / 2); const readyTimer = setTimeout(() => { if (this.serverProcess && !this.serverProcess.killed) { clearTimeout(timeoutHandle); resolve(); } else { clearTimeout(timeoutHandle); reject(new Error("Server process died during startup")); } }, readyCheckDelay); // Handle process exit during startup if (this.serverProcess) { this.serverProcess.once('exit', (code, signal) => { clearTimeout(timeoutHandle); clearTimeout(readyTimer); reject(new Error(`Server exited during startup with code ${code}, signal ${signal}`)); }); } }); } /** * ACCEPTANCE CRITERIA 3: Clean up server process when testing completes */ public async stop(): Promise<void> { if (!this.isRunning() || !this.serverProcess) { this.state = 'stopped'; await this.cleanupProcess(); return; } this.state = 'stopping'; try { await this.gracefulShutdown(); } catch (error) { await this.forceKill(); } finally { this.state = 'stopped'; await this.cleanupProcess(); } } private async gracefulShutdown(): Promise<void> { if (!this.serverProcess || !this.serverProcess.pid) { return; } return new Promise<void>((resolve, reject) => { if (!this.serverProcess) { resolve(); return; } const shutdownTimeout = setTimeout(() => { reject(new Error("Graceful shutdown timeout")); }, this.config.shutdownTimeout); this.serverProcess.once('exit', () => { clearTimeout(shutdownTimeout); resolve(); }); try { this.serverProcess.kill('SIGTERM'); } catch (error) { clearTimeout(shutdownTimeout); reject(error); } }); } private async forceKill(): Promise<void> { if (!this.serverProcess || !this.serverProcess.pid) { return; } return new Promise<void>((resolve) => { if (!this.serverProcess) { resolve(); return; } const forceTimeout = setTimeout(() => { try { if (this.serverProcess?.pid) { process.kill(this.serverProcess.pid, 'SIGKILL'); } } catch { // Ignore errors in force kill } resolve(); }, 2000); this.serverProcess.once('exit', () => { clearTimeout(forceTimeout); resolve(); }); try { this.serverProcess.kill('SIGKILL'); } catch { // Ignore kill errors - process might already be dead } }); } /** * ACCEPTANCE CRITERIA 3: Critical cleanup to prevent resource leaks * This is the fix that prevents tests from hanging */ private async cleanupProcess(): Promise<void> { if (this.serverProcess) { try { // CRITICAL: Close all streams to prevent PIPEWRAP leaks that cause Jest to hang if (this.serverProcess.stdin && !this.serverProcess.stdin.destroyed) { this.serverProcess.stdin.end(); this.serverProcess.stdin.destroy(); } if (this.serverProcess.stdout && !this.serverProcess.stdout.destroyed) { this.serverProcess.stdout.destroy(); } if (this.serverProcess.stderr && !this.serverProcess.stderr.destroyed) { this.serverProcess.stderr.destroy(); } // Remove all event listeners this.serverProcess.removeAllListeners(); // Kill process if still running if (this.serverProcess.pid && !this.serverProcess.killed) { try { process.kill(this.serverProcess.pid, 'SIGKILL'); } catch { // Ignore if process already dead } } } catch (error) { // Ignore cleanup errors } } // Clean up port file since SIGKILL prevents MCP server's own cleanup try { const portFilePath = path.join(process.cwd(), ".ssh-mcp-server.port"); if (fs.existsSync(portFilePath)) { await fs.promises.unlink(portFilePath); } } catch { // Ignore port file cleanup errors } this.serverProcess = null; this.startTime = null; } }

Latest Blog Posts

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/LightspeedDMS/ssh-mcp'

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