Skip to main content
Glama
ooples

MCP Console Automation Server

WeTTYProtocol.ts28.9 kB
import { spawn, ChildProcess } from 'child_process'; import * as http from 'http'; import * as https from 'https'; import * as net from 'net'; import WebSocket, { WebSocketServer } from 'ws'; import * as express from 'express'; import { BaseProtocol } from '../core/BaseProtocol.js'; import { ConsoleSession, SessionOptions, ConsoleType, ConsoleOutput, } from '../types/index.js'; import { SessionState, ProtocolCapabilities, ProtocolHealthStatus, } from '../core/IProtocol.js'; import { Logger } from '../utils/logger.js'; import { v4 as uuidv4 } from 'uuid'; /** * WeTTY session configuration */ export interface WeTTYSessionConfig { port?: number; host?: string; ssl?: boolean; sslKey?: string; sslCert?: string; sshHost?: string; sshPort?: number; sshAuth?: 'password' | 'key'; sshUser?: string; sshPassword?: string; sshKeyPath?: string; shell?: string; shellArgs?: string[]; webRoot?: string; base?: string; title?: string; allowIframe?: boolean; terminalOptions?: TerminalOptions; customCSS?: string; customJS?: string; reconnect?: boolean; reconnectTime?: number; maxConnections?: number; logLevel?: 'error' | 'warn' | 'info' | 'debug'; recordingSessions?: boolean; sessionRecordingPath?: string; authProvider?: 'none' | 'simple' | 'oauth' | 'ldap'; authConfig?: any; } /** * Terminal options for WeTTY */ export interface TerminalOptions { cols?: number; rows?: number; fontSize?: number; fontFamily?: string; theme?: 'light' | 'dark' | 'custom'; cursorBlink?: boolean; scrollback?: number; tabStopWidth?: number; bellSound?: string | null; macOptionIsMeta?: boolean; rightClickSelectsWord?: boolean; rendererType?: 'canvas' | 'dom' | 'webgl'; } /** * WeTTY session state */ export interface WeTTYSession { sessionId: string; port: number; host: string; ssl: boolean; webUrl: string; wsUrl: string; status: 'initializing' | 'running' | 'connected' | 'disconnected' | 'failed'; connections: Map<string, WebSocket>; process?: ChildProcess; server?: http.Server | https.Server; app?: express.Application; wss?: WebSocketServer; createdAt: Date; lastActivity: Date; bytesReceived: number; bytesSent: number; connectionCount: number; metadata: Record<string, any>; } /** * WeTTY Protocol implementation with full web terminal support */ export class WeTTYProtocol extends BaseProtocol { public readonly type: ConsoleType = 'wetty'; public readonly capabilities: ProtocolCapabilities; private wettySessions: Map<string, WeTTYSession> = new Map(); private wettyProcesses: Map<string, ChildProcess> = new Map(); private wettyServers: Map<string, http.Server | https.Server> = new Map(); private wettyWebSockets: Map<string, WebSocketServer> = new Map(); private wettyConnections: Map<string, Map<string, WebSocket>> = new Map(); private healthStatus: ProtocolHealthStatus; private basePort: number = 3000; private portCounter: number = 0; constructor() { super('WeTTYProtocol'); this.capabilities = { supportsStreaming: true, supportsFileTransfer: true, supportsX11Forwarding: false, supportsPortForwarding: false, supportsAuthentication: true, supportsEncryption: true, supportsCompression: true, supportsMultiplexing: true, supportsKeepAlive: true, supportsReconnection: true, supportsBinaryData: true, supportsCustomEnvironment: true, supportsWorkingDirectory: true, supportsSignals: true, supportsResizing: true, supportsPTY: true, maxConcurrentSessions: 10, defaultTimeout: 30000, supportedEncodings: ['utf-8', 'base64'], supportedAuthMethods: ['password', 'oauth', 'ldap'], platformSupport: { windows: true, linux: true, macos: true, freebsd: true, }, }; this.healthStatus = { isHealthy: true, lastChecked: new Date(), errors: [], warnings: [], metrics: { activeSessions: 0, totalSessions: 0, averageLatency: 0, successRate: 1.0, uptime: 0, }, dependencies: { node: { available: true, version: process.version, }, wetty: { available: false, version: 'checking...', }, express: { available: false, version: 'checking...', }, ws: { available: false, version: 'checking...', }, }, }; this.isInitialized = true; } /** * Initialize WeTTY Protocol */ async initialize(): Promise<void> { if (this.isInitialized) return; this.logger.info('Initializing WeTTY Protocol'); // Check for WeTTY availability try { const wettyCheck = spawn('wetty', ['--version'], { shell: true }); await new Promise((resolve, reject) => { wettyCheck.on('exit', (code) => { if (code === 0) { this.healthStatus.dependencies.wetty = { available: true, version: 'system', }; resolve(undefined); } else { reject(new Error('WeTTY not found')); } }); wettyCheck.on('error', reject); }); } catch (error) { this.logger.warn( 'WeTTY command not found, will use embedded implementation' ); this.healthStatus.dependencies.wetty = { available: false, version: 'embedded', }; } // Check for required modules try { require('express'); this.healthStatus.dependencies.express = { available: true, version: 'installed', }; } catch { this.healthStatus.dependencies.express = { available: false, version: 'not installed', }; } try { require('ws'); this.healthStatus.dependencies.ws = { available: true, version: 'installed', }; } catch { this.healthStatus.dependencies.ws = { available: false, version: 'not installed', }; } this.isInitialized = true; } /** * Create session with BaseProtocol integration */ async createSession(options: SessionOptions): Promise<ConsoleSession> { const sessionId = `wetty-${Date.now()}-${uuidv4().substring(0, 8)}`; const sessionState = await this.createSessionWithTypeDetection( sessionId, options ); return sessionState; } /** * Implementation of BaseProtocol's doCreateSession */ protected async doCreateSession( sessionId: string, options: SessionOptions, sessionState: SessionState ): Promise<ConsoleSession> { const wettyConfig = this.buildWeTTYConfig(options); try { // Create WeTTY session const wettySession = await this.createWeTTYSession( sessionId, wettyConfig ); // Create console session const session: ConsoleSession = { id: sessionId, command: options.command || wettyConfig.shell || process.env.SHELL || 'bash', args: options.args || wettyConfig.shellArgs || [], cwd: options.cwd || process.cwd(), env: options.environment || {}, createdAt: new Date(), status: 'running', type: 'wetty', streaming: options.streaming ?? true, executionState: 'idle', activeCommands: new Map(), metadata: { wettySession, webUrl: wettySession.webUrl, wsUrl: wettySession.wsUrl, port: wettySession.port, }, }; this.sessions.set(sessionId, session); this.emit('sessionCreated', session); // Add initial output this.addToOutputBuffer(sessionId, { sessionId, type: 'stdout', data: `WeTTY session started\nWeb URL: ${wettySession.webUrl}\nWebSocket URL: ${wettySession.wsUrl}\n`, timestamp: new Date(), raw: '', }); return session; } catch (error) { this.logger.error(`Failed to create WeTTY session ${sessionId}:`, error); throw error; } } /** * Build WeTTY configuration from session options */ private buildWeTTYConfig(options: SessionOptions): WeTTYSessionConfig { const config: WeTTYSessionConfig = { port: this.getNextPort(), // Use next available port host: 'localhost', ssl: false, sslKey: undefined, sslCert: undefined, shell: options.command || process.env.SHELL || 'bash', shellArgs: options.args || [], webRoot: './public', base: '/', title: 'WeTTY Terminal', allowIframe: true, reconnect: true, reconnectTime: 10000, maxConnections: 10, logLevel: 'info', recordingSessions: false, sessionRecordingPath: undefined, authProvider: 'none', }; // SSH configuration if connecting to remote host if (options.sshOptions?.host) { config.sshHost = options.sshOptions.host; config.sshPort = options.sshOptions.port || 22; config.sshAuth = options.sshOptions.privateKey ? 'key' : 'password'; config.sshUser = options.sshOptions.username; config.sshPassword = options.sshOptions.password; config.sshKeyPath = options.sshOptions.privateKeyPath; } // Terminal options config.terminalOptions = { cols: options.cols || 80, rows: options.rows || 24, fontSize: 14, fontFamily: 'monospace', theme: 'dark', cursorBlink: true, scrollback: 1000, tabStopWidth: 8, rendererType: 'canvas', }; return config; } /** * Get next available port */ private getNextPort(): number { return this.basePort + this.portCounter++; } /** * Create WeTTY session */ private async createWeTTYSession( sessionId: string, config: WeTTYSessionConfig ): Promise<WeTTYSession> { const session: WeTTYSession = { sessionId, port: config.port!, host: config.host!, ssl: config.ssl!, webUrl: `${config.ssl ? 'https' : 'http'}://${config.host}:${config.port}${config.base}`, wsUrl: `${config.ssl ? 'wss' : 'ws'}://${config.host}:${config.port}${config.base}`, status: 'initializing', connections: new Map(), createdAt: new Date(), lastActivity: new Date(), bytesReceived: 0, bytesSent: 0, connectionCount: 0, metadata: {}, }; this.wettySessions.set(sessionId, session); // Try to use system WeTTY if available if (this.healthStatus.dependencies.wetty?.available) { await this.startSystemWeTTY(sessionId, config, session); } else { // Use embedded WeTTY implementation await this.startEmbeddedWeTTY(sessionId, config, session); } session.status = 'running'; this.wettySessions.set(sessionId, session); return session; } /** * Start system WeTTY process */ private async startSystemWeTTY( sessionId: string, config: WeTTYSessionConfig, session: WeTTYSession ): Promise<void> { const args: string[] = []; // Basic configuration args.push('--port', config.port!.toString()); args.push('--host', config.host!); if (config.ssl) { if (config.sslKey) args.push('--ssl-key', config.sslKey); if (config.sslCert) args.push('--ssl-cert', config.sslCert); } if (config.base) args.push('--base', config.base); if (config.title) args.push('--title', config.title); // SSH configuration if (config.sshHost) { args.push('--ssh-host', config.sshHost); if (config.sshPort) args.push('--ssh-port', config.sshPort.toString()); if (config.sshUser) args.push('--ssh-user', config.sshUser); if (config.sshAuth === 'key' && config.sshKeyPath) { args.push('--ssh-auth', 'publickey'); args.push('--ssh-key', config.sshKeyPath); } } else { // Local shell if (config.shell) args.push('--command', config.shell); if (config.shellArgs && config.shellArgs.length > 0) { args.push(...config.shellArgs); } } // Terminal options if (config.terminalOptions?.cols) args.push('--width', config.terminalOptions.cols.toString()); if (config.terminalOptions?.rows) args.push('--height', config.terminalOptions.rows.toString()); // Start WeTTY process const wettyProcess = spawn('wetty', args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env }, }); wettyProcess.stdout?.on('data', (data: Buffer) => { this.handleProcessOutput(sessionId, data.toString(), 'stdout'); }); wettyProcess.stderr?.on('data', (data: Buffer) => { this.handleProcessOutput(sessionId, data.toString(), 'stderr'); }); wettyProcess.on('error', (error) => { this.logger.error(`WeTTY process error for session ${sessionId}:`, error); session.status = 'failed'; this.emit('error', sessionId, error); }); wettyProcess.on('exit', (code) => { this.logger.info( `WeTTY process exited for session ${sessionId} with code ${code}` ); session.status = 'disconnected'; this.handleSessionClose(sessionId); }); session.process = wettyProcess; this.wettyProcesses.set(sessionId, wettyProcess); // Wait for server to start await this.waitForServerStart(session.port, 5000); } /** * Start embedded WeTTY server */ private async startEmbeddedWeTTY( sessionId: string, config: WeTTYSessionConfig, session: WeTTYSession ): Promise<void> { try { const express = require('express'); const app = express(); // Setup static files if (config.webRoot) { app.use(express.static(config.webRoot)); } // Setup base path const basePath = config.base || '/'; // Setup authentication middleware if needed if (config.authProvider !== 'none') { app.use(basePath, this.createAuthMiddleware(config)); } // Setup main route app.get(basePath, (req: any, res: any) => { res.send(this.generateHTMLPage(config)); }); // Create server const server = config.ssl ? https.createServer( { key: config.sslKey, cert: config.sslCert, }, app ) : http.createServer(app); // Setup WebSocket server const wss = new WebSocketServer({ server, path: `${basePath}ws` }); wss.on('connection', (ws: WebSocket, req: any) => { const connectionId = uuidv4(); session.connections.set(connectionId, ws); session.connectionCount++; this.handleWebSocketConnection(sessionId, connectionId, ws, config); }); // Start server await new Promise<void>((resolve, reject) => { server.listen(config.port, config.host, () => { this.logger.info( `Embedded WeTTY server started on ${config.host}:${config.port}` ); resolve(); }); server.on('error', reject); }); session.server = server; session.app = app; session.wss = wss; this.wettyServers.set(sessionId, server); this.wettyWebSockets.set(sessionId, wss); } catch (error) { this.logger.error( `Failed to start embedded WeTTY for session ${sessionId}:`, error ); throw error; } } /** * Handle WebSocket connection */ private handleWebSocketConnection( sessionId: string, connectionId: string, ws: WebSocket, config: WeTTYSessionConfig ): void { const session = this.wettySessions.get(sessionId); if (!session) return; // Create PTY for this connection const pty = this.createPTY(config); // Handle PTY output pty.stdout?.on('data', (data: Buffer) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'output', data: data.toString('base64'), }) ); session.bytesSent += data.length; this.addToOutputBuffer(sessionId, { sessionId, type: 'stdout', data: data.toString(), timestamp: new Date(), raw: data.toString(), }); } }); pty.stderr?.on('data', (data: Buffer) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'error', data: data.toString('base64'), }) ); this.addToOutputBuffer(sessionId, { sessionId, type: 'stderr', data: data.toString(), timestamp: new Date(), raw: data.toString(), }); } }); // Handle WebSocket messages ws.on('message', (message: WebSocket.Data) => { try { const msg = JSON.parse(message.toString()); switch (msg.type) { case 'input': if (pty.stdin) { const inputData = Buffer.from(msg.data, 'base64'); pty.stdin.write(inputData); session.bytesReceived += inputData.length; } break; case 'resize': // Handle terminal resize if (msg.cols && msg.rows) { this.resizePTY(pty, msg.cols, msg.rows); } break; case 'ping': ws.send(JSON.stringify({ type: 'pong' })); break; } session.lastActivity = new Date(); } catch (error) { this.logger.error( `Error processing WebSocket message for session ${sessionId}:`, error ); } }); // Handle WebSocket close ws.on('close', () => { session.connections.delete(connectionId); if (pty.kill) pty.kill(); if (session.connections.size === 0) { session.status = 'disconnected'; } }); // Handle WebSocket error ws.on('error', (error) => { this.logger.error(`WebSocket error for session ${sessionId}:`, error); session.connections.delete(connectionId); }); // Send initial connection message ws.send( JSON.stringify({ type: 'connected', sessionId, connectionId, config: { cols: config.terminalOptions?.cols || 80, rows: config.terminalOptions?.rows || 24, fontSize: config.terminalOptions?.fontSize || 14, theme: config.terminalOptions?.theme || 'dark', }, }) ); } /** * Create PTY process */ private createPTY(config: WeTTYSessionConfig): ChildProcess { const shell = config.shell || process.env.SHELL || 'bash'; const args = config.shellArgs || []; const env = { ...process.env }; // Set terminal environment env.TERM = 'xterm-256color'; env.COLORTERM = 'truecolor'; if (config.terminalOptions) { env.LINES = (config.terminalOptions.rows || 24).toString(); env.COLUMNS = (config.terminalOptions.cols || 80).toString(); } // Create process with PTY const pty = spawn(shell, args, { stdio: ['pipe', 'pipe', 'pipe'], env, cwd: process.cwd(), }); return pty; } /** * Resize PTY */ private resizePTY(pty: ChildProcess, cols: number, rows: number): void { // Send window change signal (platform specific) if (process.platform !== 'win32' && pty.pid) { process.kill(pty.pid, 'SIGWINCH'); } } /** * Generate HTML page for WeTTY */ private generateHTMLPage(config: WeTTYSessionConfig): string { return `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>${config.title || 'WeTTY Terminal'}</title> <style> body { margin: 0; padding: 0; background: #000; font-family: ${config.terminalOptions?.fontFamily || 'monospace'}; } #terminal { width: 100vw; height: 100vh; } ${config.customCSS || ''} </style> </head> <body> <div id="terminal"></div> <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script> <link href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" rel="stylesheet"> <script> const term = new Terminal({ cols: ${config.terminalOptions?.cols || 80}, rows: ${config.terminalOptions?.rows || 24}, fontSize: ${config.terminalOptions?.fontSize || 14}, fontFamily: '${config.terminalOptions?.fontFamily || 'monospace'}', theme: ${JSON.stringify(this.getTerminalTheme(config.terminalOptions?.theme))}, cursorBlink: ${config.terminalOptions?.cursorBlink !== false}, scrollback: ${config.terminalOptions?.scrollback || 1000} }); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(protocol + '//' + window.location.host + '${config.base}ws'); ws.onopen = () => { term.open(document.getElementById('terminal')); term.focus(); // Send terminal input to WebSocket term.onData(data => { ws.send(JSON.stringify({ type: 'input', data: btoa(data) })); }); // Handle resize term.onResize(({ cols, rows }) => { ws.send(JSON.stringify({ type: 'resize', cols, rows })); }); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'output') { term.write(atob(msg.data)); } else if (msg.type === 'error') { term.write('\\x1b[31m' + atob(msg.data) + '\\x1b[0m'); } }; ws.onerror = (error) => { term.write('\\r\\n\\x1b[31mWebSocket error: ' + error + '\\x1b[0m\\r\\n'); }; ws.onclose = () => { term.write('\\r\\n\\x1b[33mConnection closed\\x1b[0m\\r\\n'); ${ config.reconnect ? ` setTimeout(() => { window.location.reload(); }, ${config.reconnectTime || 10000}); ` : '' } }; // Keep connection alive setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); ${config.customJS || ''} </script> </body> </html>`; } /** * Get terminal theme */ private getTerminalTheme(theme?: string): any { switch (theme) { case 'light': return { background: '#ffffff', foreground: '#000000', cursor: '#000000', selection: 'rgba(0, 0, 0, 0.3)', }; case 'dark': default: return { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#d4d4d4', selection: 'rgba(255, 255, 255, 0.3)', }; } } /** * Create authentication middleware */ private createAuthMiddleware(config: WeTTYSessionConfig): any { return (req: any, res: any, next: any) => { // Simple auth check (implement based on authProvider) if (config.authProvider === 'simple' && config.authConfig) { const auth = req.headers.authorization; if (!auth) { res.status(401).send('Authentication required'); return; } // Validate auth token } next(); }; } /** * Wait for server to start */ private async waitForServerStart( port: number, timeout: number ): Promise<void> { const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { await new Promise<void>((resolve, reject) => { const socket = net.connect(port, 'localhost', () => { socket.end(); resolve(); }); socket.on('error', reject); }); return; } catch { await new Promise((resolve) => setTimeout(resolve, 100)); } } throw new Error( `Server failed to start on port ${port} within ${timeout}ms` ); } /** * Handle process output */ private handleProcessOutput( sessionId: string, data: string, type: 'stdout' | 'stderr' ): void { this.addToOutputBuffer(sessionId, { sessionId, type, data, timestamp: new Date(), raw: data, }); } /** * Handle session close */ private handleSessionClose(sessionId: string): void { const session = this.wettySessions.get(sessionId); if (!session) return; // Close all WebSocket connections session.connections.forEach((ws) => { if (ws.readyState === WebSocket.OPEN) { ws.close(); } }); session.connections.clear(); // Mark session as complete this.markSessionComplete(sessionId); this.emit('session-closed', sessionId); } /** * Execute command in session */ async executeCommand( sessionId: string, command: string, args?: string[] ): Promise<void> { const session = this.sessions.get(sessionId); const wettySession = this.wettySessions.get(sessionId); if (!session || !wettySession) { throw new Error(`Session ${sessionId} not found`); } const fullCommand = args ? `${command} ${args.join(' ')}` : command; // Send command to all connected clients wettySession.connections.forEach((ws) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'input', data: Buffer.from(fullCommand + '\n').toString('base64'), }) ); } }); // Update session session.lastActivity = new Date(); } /** * Send input to session */ async sendInput(sessionId: string, input: string): Promise<void> { const wettySession = this.wettySessions.get(sessionId); if (!wettySession) { throw new Error(`Session ${sessionId} not found`); } // Send input to all connected clients wettySession.connections.forEach((ws) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'input', data: Buffer.from(input).toString('base64'), }) ); } }); wettySession.lastActivity = new Date(); wettySession.bytesReceived += Buffer.byteLength(input); } /** * Close session */ async closeSession(sessionId: string): Promise<void> { const wettySession = this.wettySessions.get(sessionId); if (!wettySession) return; this.logger.info(`Closing WeTTY session ${sessionId}`); // Close WebSocket connections wettySession.connections.forEach((ws) => ws.close()); // Stop process const process = this.wettyProcesses.get(sessionId); if (process) { process.kill('SIGTERM'); this.wettyProcesses.delete(sessionId); } // Stop server const server = this.wettyServers.get(sessionId); if (server) { await new Promise<void>((resolve) => { server.close(() => resolve()); }); this.wettyServers.delete(sessionId); } // Clean up WebSocket server const wss = this.wettyWebSockets.get(sessionId); if (wss) { wss.close(); this.wettyWebSockets.delete(sessionId); } // Remove from maps this.wettySessions.delete(sessionId); this.sessions.delete(sessionId); this.wettyConnections.delete(sessionId); this.markSessionComplete(sessionId); } /** * Get health status */ async getHealthStatus(): Promise<ProtocolHealthStatus> { const baseHealth = await super.getHealthStatus(); this.healthStatus.metrics.activeSessions = this.wettySessions.size; this.healthStatus.lastChecked = new Date(); // Check server health let healthyServers = 0; this.wettyServers.forEach((server) => { if (server.listening) healthyServers++; }); this.healthStatus.isHealthy = healthyServers === this.wettyServers.size; return { ...baseHealth, ...this.healthStatus, metrics: { ...baseHealth.metrics, activeSessions: this.wettySessions.size, }, }; } /** * Dispose of protocol resources */ async dispose(): Promise<void> { this.logger.info('Disposing WeTTY Protocol'); // Close all sessions const sessionIds = Array.from(this.wettySessions.keys()); await Promise.all(sessionIds.map((id) => this.closeSession(id))); // Clean up await super.cleanup(); } } export default WeTTYProtocol;

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