Skip to main content
Glama
ooples

MCP Console Automation Server

LocalProtocol.ts19.5 kB
import { spawn, ChildProcess, SpawnOptions } from 'child_process'; import { platform } from 'os'; import { BaseProtocol } from '../core/BaseProtocol.js'; import { ConsoleType, ConsoleSession, ConsoleOutput, SessionOptions, CommandExecution, } from '../types/index.js'; import { ProtocolCapabilities, SessionState, ErrorContext, ProtocolHealthStatus, ErrorRecoveryResult, ResourceUsage, } from '../core/IProtocol.js'; /** * Local Protocol implementation for native shell access * Supports cmd, powershell, bash, zsh, and other local shells */ export class LocalProtocol extends BaseProtocol { public readonly type: ConsoleType; public readonly capabilities: ProtocolCapabilities; private localSessions: Map<string, LocalSession> = new Map(); // Compatibility property for old ProtocolFactory interface public get healthStatus(): ProtocolHealthStatus { // Return a default health status, real status should be obtained via getHealthStatus() return { isHealthy: this.isInitialized, lastChecked: new Date(), errors: [], warnings: [], metrics: { activeSessions: this.sessions.size, totalSessions: this.sessions.size, averageLatency: 0, successRate: 100, uptime: 0, }, dependencies: {}, }; } constructor(type: ConsoleType) { super('local'); this.type = type; this.capabilities = { supportsStreaming: true, supportsFileTransfer: false, supportsX11Forwarding: false, supportsPortForwarding: false, supportsAuthentication: false, supportsEncryption: false, supportsCompression: false, supportsMultiplexing: false, supportsKeepAlive: false, supportsReconnection: false, supportsBinaryData: false, supportsCustomEnvironment: true, supportsWorkingDirectory: true, supportsSignals: true, supportsResizing: true, supportsPTY: true, maxConcurrentSessions: 50, defaultTimeout: 30000, supportedEncodings: ['utf-8', 'ascii'], supportedAuthMethods: [], platformSupport: { windows: true, linux: true, macos: true, freebsd: true, }, }; } async initialize(): Promise<void> { if (this.isInitialized) { return; } try { // Validate shell availability await this.validateShellAvailability(); this.isInitialized = true; this.logger.info(`Local protocol initialized for shell: ${this.type}`); } catch (error) { this.logger.error(`Failed to initialize Local protocol: ${error}`); throw error; } } async dispose(): Promise<void> { await this.cleanup(); } async createSession(options: SessionOptions): Promise<ConsoleSession> { const sessionId = `local-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; const result = await this.createSessionWithTypeDetection( sessionId, options ); return result; } async doCreateSession( sessionId: string, options: SessionOptions, sessionState: SessionState ): Promise<ConsoleSession> { if (!this.isInitialized) { await this.initialize(); } try { const shellInfo = this.getShellInfo(options); const spawnOptions: SpawnOptions = { cwd: options.cwd || process.cwd(), env: { ...process.env, ...options.env } as NodeJS.ProcessEnv, stdio: ['pipe', 'pipe', 'pipe'], shell: false, }; const childProcess = spawn( shellInfo.command, shellInfo.args, spawnOptions ); const localSession: LocalSession = { id: sessionId, type: this.type, process: childProcess, created: new Date(), lastActivity: new Date(), outputBuffer: '', errorBuffer: '', isActive: true, }; // Setup process event handlers this.setupProcessHandlers(localSession); this.localSessions.set(sessionId, localSession); const consoleSession: ConsoleSession = { id: sessionId, command: shellInfo.command, args: shellInfo.args, cwd: options.cwd || process.cwd(), env: Object.fromEntries( Object.entries({ ...process.env, ...options.env }).filter( ([_, value]) => typeof value === 'string' ) ) as Record<string, string>, createdAt: new Date(), pid: childProcess.pid ?? undefined, status: 'running', type: this.type, streaming: options.streaming ?? false, lastActivity: new Date(), executionState: 'idle', activeCommands: new Map(), }; this.sessions.set(sessionId, consoleSession); this.logger.info( `Local session ${sessionId} created for shell: ${this.type}` ); this.emit('session-created', { sessionId, type: this.type, session: consoleSession, }); return consoleSession; } catch (error) { this.logger.error(`Failed to create local session: ${error}`); throw error; } } async executeCommand( sessionId: string, command: string, args?: string[] ): Promise<void> { const localSession = this.localSessions.get(sessionId); const session = this.sessions.get(sessionId); if (!localSession || !localSession.isActive || !session) { throw new Error(`Session ${sessionId} not found or inactive`); } try { const fullCommand = args ? `${command} ${args.join(' ')}` : command; const commandWithNewline = fullCommand + '\n'; if (localSession.process.stdin) { localSession.process.stdin.write(commandWithNewline); localSession.lastActivity = new Date(); session.lastActivity = new Date(); } else { throw new Error('Session stdin is not available'); } this.emit('command-executed', { sessionId, command: fullCommand, timestamp: new Date(), }); } catch (error) { this.logger.error( `Failed to execute command in session ${sessionId}: ${error}` ); throw error; } } async sendInput(sessionId: string, input: string): Promise<void> { const localSession = this.localSessions.get(sessionId); const session = this.sessions.get(sessionId); if (!localSession || !localSession.isActive || !session) { throw new Error(`Session ${sessionId} not found or inactive`); } try { if (localSession.process.stdin) { localSession.process.stdin.write(input); localSession.lastActivity = new Date(); session.lastActivity = new Date(); } else { throw new Error('Session stdin is not available'); } this.emit('input-sent', { sessionId, input, timestamp: new Date(), }); } catch (error) { this.logger.error( `Failed to send input to session ${sessionId}: ${error}` ); throw error; } } // Override getOutput to satisfy old ProtocolFactory interface (returns string) // while still providing BaseProtocol functionality async getOutput(sessionId: string, since?: Date): Promise<any> { const outputs = await super.getOutput(sessionId, since); // Check if caller expects string (old interface) or ConsoleOutput[] (new interface) // For now, always return string for compatibility with ProtocolFactory return outputs.map((output) => output.data).join(''); } // Separate method for getting structured output async getOutputArray( sessionId: string, since?: Date ): Promise<ConsoleOutput[]> { return await super.getOutput(sessionId, since); } // Missing IProtocol methods that BaseProtocol may not implement getAllSessions(): ConsoleSession[] { return Array.from(this.sessions.values()); } getActiveSessions(): ConsoleSession[] { return Array.from(this.sessions.values()).filter( (session) => session.status === 'running' ); } getSessionCount(): number { return this.sessions.size; } async getSessionState(sessionId: string): Promise<SessionState> { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`Session ${sessionId} not found`); } return { sessionId, status: session.status, isOneShot: false, // Local sessions are persistent by default isPersistent: true, createdAt: session.createdAt, lastActivity: session.lastActivity, pid: session.pid, metadata: {}, }; } async handleError( error: Error, context: ErrorContext ): Promise<ErrorRecoveryResult> { this.logger.error( `Error in session ${context.sessionId}: ${error.message}` ); return { recovered: false, strategy: 'none', attempts: 0, duration: 0, error: error.message, }; } async recoverSession(sessionId: string): Promise<boolean> { // Simple recovery - just check if session exists and is active const localSession = this.localSessions.get(sessionId); return localSession?.isActive || false; } getResourceUsage(): ResourceUsage { const memUsage = process.memoryUsage(); const cpuUsage = process.cpuUsage(); return { memory: { used: memUsage.heapUsed, available: memUsage.heapTotal, peak: memUsage.heapTotal, }, cpu: { usage: cpuUsage.user + cpuUsage.system, load: [0, 0, 0], // Default load averages }, network: { bytesIn: 0, bytesOut: 0, connectionsActive: this.localSessions.size, }, storage: { bytesRead: 0, bytesWritten: 0, }, sessions: { active: this.sessions.size, total: this.sessions.size, peak: this.sessions.size, }, }; } async closeSession(sessionId: string): Promise<void> { const localSession = this.localSessions.get(sessionId); const session = this.sessions.get(sessionId); if (!localSession && !session) { return; // Session doesn't exist, nothing to close } try { if (localSession) { localSession.isActive = false; // Clear any existing kill timeout if (localSession.killTimeout) { clearTimeout(localSession.killTimeout); localSession.killTimeout = undefined; } if (localSession.process && !localSession.process.killed) { // Try graceful shutdown first localSession.process.kill('SIGTERM'); // Force kill after timeout (only if process is still running) localSession.killTimeout = setTimeout(() => { if (localSession.process && !localSession.process.killed) { localSession.process.kill('SIGKILL'); } }, 5000); } this.localSessions.delete(sessionId); } if (session) { this.sessions.delete(sessionId); } // Session count is managed by BaseProtocol // CRITICAL FIX: Emit proper object structure instead of just sessionId string this.emit('session-closed', { sessionId, exitCode: 0 }); // Manual closure = success } catch (error) { this.logger.error(`Failed to close session ${sessionId}: ${error}`); throw error; } } async getHealthStatus(): Promise<ProtocolHealthStatus> { const baseStatus = await super.getHealthStatus(); // Add shell-specific health checks try { await this.validateShellAvailability(); return { ...baseStatus, dependencies: { shell: { available: true }, }, }; } catch (error) { return { ...baseStatus, isHealthy: false, errors: [...baseStatus.errors, `Shell not available: ${error}`], dependencies: { shell: { available: false }, }, }; } } async cleanup(): Promise<void> { this.logger.info(`Disposing local protocol: ${this.type}`); // Save local sessions before closing (so we can clear timeouts after) const localSessionsSnapshot = new Map(this.localSessions); // Close all active sessions const sessionIds = Array.from(this.sessions.keys()); await Promise.all(sessionIds.map((id) => this.closeSession(id))); // Clear any remaining kill timeouts from the saved snapshot for (const [, localSession] of localSessionsSnapshot) { if (localSession.killTimeout) { clearTimeout(localSession.killTimeout); localSession.killTimeout = undefined; } } this.sessions.clear(); this.localSessions.clear(); this.removeAllListeners(); this.isInitialized = false; } private getShellInfo(options?: SessionOptions): { command: string; args: string[]; } { // If a direct command is provided, use it instead of the shell wrapper // This is for executing programs directly (like .NET console apps, python scripts, etc.) if (options?.command) { return { command: options.command, args: options.args || [], }; } // Otherwise, return the appropriate shell for interactive sessions switch (this.type) { case 'cmd': return { command: 'cmd.exe', args: ['/k'] }; case 'powershell': // For one-shot sessions, don't use -NoExit so PowerShell closes after command return { command: 'powershell.exe', args: options?.isOneShot ? ['-NoLogo'] : ['-NoLogo', '-NoExit'], }; case 'pwsh': // For one-shot sessions, don't use -NoExit so PowerShell Core closes after command return { command: 'pwsh', args: options?.isOneShot ? ['-NoLogo'] : ['-NoLogo', '-NoExit'], }; case 'bash': return { command: 'bash', args: ['--login'] }; case 'zsh': return { command: 'zsh', args: ['-l'] }; case 'sh': return { command: 'sh', args: [] }; case 'auto': return this.getDefaultShell(options); default: throw new Error(`Unsupported shell type: ${this.type}`); } } private getDefaultShell(options?: SessionOptions): { command: string; args: string[]; } { switch (platform()) { case 'win32': // For one-shot sessions, don't use -NoExit so PowerShell closes after command return { command: 'powershell.exe', args: options?.isOneShot ? ['-NoLogo'] : ['-NoLogo', '-NoExit'], }; case 'darwin': return { command: 'zsh', args: ['-l'] }; default: return { command: 'bash', args: ['--login'] }; } } private async validateShellAvailability(): Promise<void> { return new Promise((resolve, reject) => { const shellInfo = this.getShellInfo(); const versionArgs = this.getVersionArgs(); this.logger.debug( `Validating shell: ${shellInfo.command} ${versionArgs.join(' ')}` ); const testProcess = spawn(shellInfo.command, versionArgs, { stdio: 'ignore', windowsHide: true, }); testProcess.on('close', (code) => { this.logger.debug( `Shell validation for ${shellInfo.command} exited with code: ${code}` ); if (code === 0) { resolve(); } else { reject( new Error( `Shell ${shellInfo.command} not available (exit code: ${code ?? 'unknown'})` ) ); } }); testProcess.on('error', (error) => { this.logger.error( `Shell validation error for ${shellInfo.command}: ${error.message}` ); reject( new Error( `Shell ${shellInfo.command} not available: ${error.message}` ) ); }); // Timeout after 5 seconds setTimeout(() => { this.logger.warn(`Shell validation timeout for ${shellInfo.command}`); testProcess.kill(); reject(new Error(`Shell validation timeout for ${shellInfo.command}`)); }, 5000); }); } private getVersionArgs(): string[] { switch (this.type) { case 'cmd': return ['/C', 'ver']; case 'powershell': return ['-Command', '$PSVersionTable.PSVersion.ToString()']; case 'pwsh': return ['-Command', '$PSVersionTable.PSVersion.ToString()']; case 'bash': return ['--version']; case 'zsh': return ['--version']; case 'sh': // sh typically doesn't have a version flag, just test basic execution return ['-c', 'true']; case 'auto': // For auto mode, use the default shell's version command const defaultShell = this.getDefaultShell(); if ( defaultShell.command.includes('powershell') || defaultShell.command.includes('pwsh') ) { return ['-Command', '$PSVersionTable.PSVersion.ToString()']; } else if (defaultShell.command.includes('cmd')) { return ['/C', 'ver']; } else { return ['--version']; } default: return ['--version']; } } private setupProcessHandlers(session: LocalSession): void { if (!session.process) { return; } // Handle stdout session.process.stdout?.on('data', (data: Buffer) => { const text = data.toString(); session.outputBuffer += text; session.lastActivity = new Date(); const output: ConsoleOutput = { sessionId: session.id, type: 'stdout', data: text, timestamp: new Date(), raw: text, }; // Add to BaseProtocol's output buffer this.addToOutputBuffer(session.id, output); this.emit('output', output); }); // Handle stderr session.process.stderr?.on('data', (data: Buffer) => { const text = data.toString(); session.errorBuffer += text; session.lastActivity = new Date(); const output: ConsoleOutput = { sessionId: session.id, type: 'stderr', data: text, timestamp: new Date(), raw: text, }; // Add to BaseProtocol's output buffer this.addToOutputBuffer(session.id, output); this.emit('output', output); }); // Handle process exit session.process.on( 'close', (code: number | null, signal: NodeJS.Signals | null) => { session.isActive = false; this.logger.info( `Local session ${session.id} closed with code: ${code}, signal: ${signal}` ); // CRITICAL FIX: Emit proper object structure with actual exit code this.emit('session-closed', { sessionId: session.id, exitCode: code || 0, }); this.sessions.delete(session.id); this.localSessions.delete(session.id); // Session count is managed by BaseProtocol } ); // Handle process errors session.process.on('error', (error) => { session.isActive = false; this.logger.error(`Local session ${session.id} error: ${error}`); this.emit('error', { sessionId: session.id, error: error.message, timestamp: new Date(), }); }); } } interface LocalSession { id: string; type: ConsoleType; process: ChildProcess; created: Date; lastActivity: Date; outputBuffer: string; errorBuffer: string; isActive: boolean; killTimeout?: NodeJS.Timeout; }

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/ooples/mcp-console-automation'

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