UNPKG

@iflow-mcp/mcp-ssh-manager

Version:

MCP SSH Manager: Model Context Protocol server for SSH remote server management. Control SSH connections from Claude Code and OpenAI Codex - execute commands, transfer files, database operations, backups, health monitoring, and DevOps automation. NEW: Too

1,618 lines (1,414 loc) • 135 kB
#!/usr/bin/env node import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import SSHManager from './ssh-manager.js'; import * as dotenv from 'dotenv'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { configLoader } from './config-loader.js'; import { getTempFilename, buildDeploymentStrategy, detectDeploymentNeeds } from './deploy-helper.js'; import { resolveServerName, addAlias, removeAlias, listAliases } from './server-aliases.js'; import { expandCommandAlias, addCommandAlias, removeCommandAlias, listCommandAliases, suggestAliases } from './command-aliases.js'; import { initializeHooks, executeHook, toggleHook, listHooks } from './hooks-system.js'; import { loadProfile, listProfiles, setActiveProfile, getActiveProfileName } from './profile-loader.js'; import { logger } from './logger.js'; import { createSession, getSession, listSessions, closeSession, SESSION_STATES } from './session-manager.js'; import { getGroup, createGroup, updateGroup, deleteGroup, addServersToGroup, removeServersFromGroup, listGroups, executeOnGroup, EXECUTION_STRATEGIES } from './server-groups.js'; import { createTunnel, getTunnel, listTunnels, closeTunnel, closeServerTunnels, TUNNEL_TYPES } from './tunnel-manager.js'; import { getHostKeyFingerprint, isHostKnown, getCurrentHostKey, removeHostKey, addHostKey, updateHostKey, hasHostKeyChanged, listKnownHosts, detectSSHKeyError, extractHostFromSSHError } from './ssh-key-manager.js'; import { BACKUP_TYPES, DEFAULT_BACKUP_DIR, generateBackupId, getBackupMetadataPath, getBackupFilePath, buildMySQLDumpCommand, buildPostgreSQLDumpCommand, buildMongoDBDumpCommand, buildFilesBackupCommand, buildRestoreCommand, createBackupMetadata, buildSaveMetadataCommand, buildListBackupsCommand, parseBackupsList, buildCleanupCommand, buildCronScheduleCommand, parseCronJobs } from './backup-manager.js'; import { HEALTH_STATUS, COMMON_SERVICES, buildCPUCheckCommand, buildMemoryCheckCommand, buildDiskCheckCommand, buildNetworkCheckCommand, buildLoadAverageCommand, buildUptimeCommand, parseCPUUsage, parseMemoryUsage, parseDiskUsage, parseNetworkStats, determineOverallHealth, buildServiceStatusCommand, parseServiceStatus, buildProcessListCommand, parseProcessList, buildKillProcessCommand, buildProcessInfoCommand, createAlertConfig, buildSaveAlertConfigCommand, buildLoadAlertConfigCommand, checkAlertThresholds, buildComprehensiveHealthCheckCommand, parseComprehensiveHealthCheck, getCommonServices, resolveServiceName } from './health-monitor.js'; import { DB_TYPES, DB_PORTS, buildMySQLDumpCommand as buildDBMySQLDumpCommand, buildPostgreSQLDumpCommand as buildDBPostgreSQLDumpCommand, buildMongoDBDumpCommand as buildDBMongoDBDumpCommand, buildMySQLImportCommand, buildPostgreSQLImportCommand, buildMongoDBRestoreCommand, buildMySQLListDatabasesCommand, buildMySQLListTablesCommand, buildPostgreSQLListDatabasesCommand, buildPostgreSQLListTablesCommand, buildMongoDBListDatabasesCommand, buildMongoDBListCollectionsCommand, buildMySQLQueryCommand, buildPostgreSQLQueryCommand, buildMongoDBQueryCommand, isSafeQuery, parseDatabaseList, parseTableList, buildEstimateSizeCommand, parseSize, formatBytes, getConnectionInfo } from './database-manager.js'; import { loadToolConfig, isToolEnabled } from './tool-config-manager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Load environment variables (for backward compatibility) dotenv.config({ path: path.join(__dirname, '..', '.env') }); // Initialize logger logger.info('MCP SSH Manager starting', { logLevel: process.env.SSH_LOG_LEVEL || 'INFO', verbose: process.env.SSH_VERBOSE === 'true' }); // Load SSH server configuration let servers = {}; configLoader.load({ envPath: path.join(__dirname, '..', '.env'), tomlPath: process.env.SSH_CONFIG_PATH, preferToml: process.env.PREFER_TOML_CONFIG === 'true' }).then(loadedServers => { // Convert Map to object for backward compatibility servers = {}; for (const [name, config] of loadedServers) { servers[name] = config; } logger.info(`Loaded ${loadedServers.size} SSH server configurations from ${configLoader.configSource}`); }).catch(error => { logger.error('Failed to load server configuration', { error: error.message }); }); // Initialize hooks system initializeHooks().catch(error => { logger.error('Failed to initialize hooks', { error: error.message }); }); // Load tool configuration let toolConfig = null; loadToolConfig().then(config => { toolConfig = config; const summary = config.getSummary(); logger.info(`Tool configuration loaded: ${summary.mode} mode, ${summary.enabledCount}/${summary.totalTools} tools enabled`); if (summary.mode === 'all') { logger.info('šŸ’” Tip: Run "ssh-manager tools configure" to reduce context usage in Claude Code'); } }).catch(error => { logger.error('Failed to load tool configuration', { error: error.message }); logger.info('Using default configuration (all tools enabled)'); }); // Map to store active connections const connections = new Map(); // Map to store connection timestamps for timeout management const connectionTimestamps = new Map(); // Connection timeout in milliseconds (30 minutes) const CONNECTION_TIMEOUT = 30 * 60 * 1000; // Keepalive interval in milliseconds (5 minutes) const KEEPALIVE_INTERVAL = 5 * 60 * 1000; // Map to store keepalive intervals const keepaliveIntervals = new Map(); // Load server configuration (backward compatibility wrapper) function loadServerConfig() { // This function is kept for backward compatibility // The actual loading is done by configLoader during initialization return servers; } // Execute command with timeout - using child_process timeout for real kill async function execCommandWithTimeout(ssh, command, options = {}, timeoutMs = 30000) { // Pass through rawCommand if specified const { rawCommand, ...otherOptions } = options; // For commands that might hang, use the system's timeout command if available const useSystemTimeout = timeoutMs > 0 && timeoutMs < 300000 && !rawCommand; // Max 5 minutes, not for raw commands if (useSystemTimeout) { // Wrap command with timeout command (works on Linux/Mac) const timeoutSeconds = Math.ceil(timeoutMs / 1000); const wrappedCommand = `timeout ${timeoutSeconds} sh -c '${command.replace(/'/g, '\'\\\'\'')}'`; try { const result = await ssh.execCommand(wrappedCommand, otherOptions); // Check if timeout occurred (exit code 124 on Linux, 124 or 143 on Mac) if (result.code === 124 || result.code === 143) { throw new Error(`Command timeout after ${timeoutMs}ms: ${command.substring(0, 100)}...`); } return result; } catch (error) { // If timeout occurred, remove connection from pool if (error.message.includes('timeout')) { for (const [name, conn] of connections.entries()) { if (conn === ssh) { logger.warn(`Removing timed-out connection for ${name}`); connections.delete(name); connectionTimestamps.delete(name); if (keepaliveIntervals.has(name)) { clearInterval(keepaliveIntervals.get(name)); keepaliveIntervals.delete(name); } // Force close the connection ssh.dispose(); break; } } } throw error; } } else { // No timeout or very long timeout, execute normally return ssh.execCommand(command, { ...options, timeout: timeoutMs }); } } // Check if a connection is still valid async function isConnectionValid(ssh) { try { return await ssh.ping(); } catch (error) { logger.debug('Connection validation failed', { error: error.message }); return false; } } // Setup keepalive for a connection function setupKeepalive(serverName, ssh) { // Clear existing keepalive if any if (keepaliveIntervals.has(serverName)) { clearInterval(keepaliveIntervals.get(serverName)); } // Set up new keepalive interval const interval = setInterval(async () => { try { const isValid = await isConnectionValid(ssh); if (!isValid) { logger.warn(`Connection to ${serverName} lost, will reconnect on next use`); closeConnection(serverName); } else { // Update timestamp on successful keepalive connectionTimestamps.set(serverName, Date.now()); logger.debug('Keepalive successful', { server: serverName }); } } catch (error) { logger.error(`Keepalive failed for ${serverName}`, { error: error.message }); } }, KEEPALIVE_INTERVAL); keepaliveIntervals.set(serverName, interval); } // Close a connection and clean up function closeConnection(serverName) { const normalizedName = serverName.toLowerCase(); // Clear keepalive interval if (keepaliveIntervals.has(normalizedName)) { clearInterval(keepaliveIntervals.get(normalizedName)); keepaliveIntervals.delete(normalizedName); } // Close SSH connection const ssh = connections.get(normalizedName); if (ssh) { ssh.dispose(); connections.delete(normalizedName); } // Remove timestamp connectionTimestamps.delete(normalizedName); logger.logConnection(serverName, 'closed'); } // Clean up old connections function cleanupOldConnections() { const now = Date.now(); for (const [serverName, timestamp] of connectionTimestamps.entries()) { if (now - timestamp > CONNECTION_TIMEOUT) { logger.info(`Connection to ${serverName} timed out, closing`, { timeout: CONNECTION_TIMEOUT }); closeConnection(serverName); } } } // Get or create SSH connection with reconnection support async function getConnection(serverName) { const servers = loadServerConfig(); // Execute pre-connect hook await executeHook('pre-connect', { server: serverName }); // Try to resolve through aliases first const resolvedName = resolveServerName(serverName, servers); if (!resolvedName) { const availableServers = Object.keys(servers); const aliases = listAliases(); const aliasInfo = aliases.length > 0 ? ` Aliases: ${aliases.map(a => `${a.alias}->${a.target}`).join(', ')}` : ''; throw new Error( `Server "${serverName}" not found. Available servers: ${availableServers.join(', ') || 'none'}.${aliasInfo}` ); } const normalizedName = resolvedName; // Check if we have an existing connection if (connections.has(normalizedName)) { const existingSSH = connections.get(normalizedName); // Verify the connection is still valid const isValid = await isConnectionValid(existingSSH); if (isValid) { // Update timestamp and return existing connection connectionTimestamps.set(normalizedName, Date.now()); return existingSSH; } else { // Connection is dead, remove it logger.info(`Connection to ${serverName} lost, reconnecting`); closeConnection(normalizedName); } } // Create new connection const serverConfig = servers[normalizedName]; const ssh = new SSHManager(serverConfig); try { await ssh.connect(); connections.set(normalizedName, ssh); connectionTimestamps.set(normalizedName, Date.now()); // Setup keepalive setupKeepalive(normalizedName, ssh); logger.logConnection(serverName, 'established', { host: serverConfig.host, port: serverConfig.port, method: serverConfig.password ? 'password' : 'key' }); // Execute post-connect hook await executeHook('post-connect', { server: serverName }); } catch (error) { logger.logConnection(serverName, 'failed', { error: error.message }); // Execute error hook await executeHook('on-error', { server: serverName, error: error.message }); throw new Error(`Failed to connect to ${serverName}: ${error.message}`); } return connections.get(normalizedName); } // Create MCP server const server = new McpServer({ name: 'mcp-ssh-manager', version: '1.2.0', }); logger.info('MCP Server initialized', { version: '1.2.0' }); /** * Helper function to conditionally register tools based on configuration * @param {string} toolName - Name of the tool * @param {Object} schema - Tool schema * @param {Function} handler - Tool handler function */ function registerToolConditional(toolName, schema, handler) { if (isToolEnabled(toolName)) { server.registerTool(toolName, schema, handler); logger.debug(`Registered tool: ${toolName}`); } else { logger.debug(`Skipped disabled tool: ${toolName}`); } } // Register available tools registerToolConditional( 'ssh_execute', { description: 'Execute command on remote SSH server', inputSchema: { server: z.string().describe('Server name from configuration'), command: z.string().describe('Command to execute'), cwd: z.string().optional().describe('Working directory (optional, uses default if configured)'), timeout: z.number().optional().describe('Command timeout in milliseconds (default: 30000)') } }, async ({ server: serverName, command, cwd, timeout = 30000 }) => { try { const ssh = await getConnection(serverName); // Expand command aliases const expandedCommand = expandCommandAlias(command); // Execute hooks for bench commands if (expandedCommand.includes('bench update')) { await executeHook('pre-bench-update', { server: serverName, sshConnection: ssh, defaultDir: cwd }); } // Use provided cwd, or default_dir from config, or no cwd const servers = loadServerConfig(); const serverConfig = servers[serverName.toLowerCase()]; const workingDir = cwd || serverConfig?.default_dir; const fullCommand = workingDir ? `cd ${workingDir} && ${expandedCommand}` : expandedCommand; // Log command execution const startTime = logger.logCommand(serverName, fullCommand, workingDir); const result = await execCommandWithTimeout(ssh, fullCommand, {}, timeout); // Log command result logger.logCommandResult(serverName, fullCommand, startTime, result); // Execute post-hooks for bench commands if (expandedCommand.includes('bench update') && result.code === 0) { await executeHook('post-bench-update', { server: serverName, sshConnection: ssh, defaultDir: cwd }); } return { content: [ { type: 'text', text: JSON.stringify({ server: serverName, command: fullCommand, stdout: result.stdout, stderr: result.stderr, code: result.code, success: result.code === 0, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: 'text', text: `āŒ Error: ${error.message}`, }, ], }; } } ); registerToolConditional( 'ssh_upload', { description: 'Upload file to remote SSH server', inputSchema: { server: z.string().describe('Server name'), localPath: z.string().describe('Local file path'), remotePath: z.string().describe('Remote destination path') } }, async ({ server: serverName, localPath, remotePath }) => { try { const ssh = await getConnection(serverName); logger.logTransfer('upload', serverName, localPath, remotePath); const startTime = Date.now(); await ssh.putFile(localPath, remotePath); const fileStats = fs.statSync(localPath); logger.logTransfer('upload', serverName, localPath, remotePath, { success: true, size: fileStats.size, duration: `${Date.now() - startTime}ms` }); return { content: [ { type: 'text', text: `āœ… File uploaded successfully\nServer: ${serverName}\nLocal: ${localPath}\nRemote: ${remotePath}`, }, ], }; } catch (error) { logger.logTransfer('upload', serverName, localPath, remotePath, { success: false, error: error.message }); return { content: [ { type: 'text', text: `āŒ Upload error: ${error.message}`, }, ], }; } } ); registerToolConditional( 'ssh_download', { description: 'Download file from remote SSH server', inputSchema: { server: z.string().describe('Server name'), remotePath: z.string().describe('Remote file path'), localPath: z.string().describe('Local destination path') } }, async ({ server: serverName, remotePath, localPath }) => { try { const ssh = await getConnection(serverName); logger.logTransfer('download', serverName, remotePath, localPath); const startTime = Date.now(); await ssh.getFile(localPath, remotePath); const fileStats = fs.statSync(localPath); logger.logTransfer('download', serverName, remotePath, localPath, { success: true, size: fileStats.size, duration: `${Date.now() - startTime}ms` }); return { content: [ { type: 'text', text: `āœ… File downloaded successfully\nServer: ${serverName}\nRemote: ${remotePath}\nLocal: ${localPath}`, }, ], }; } catch (error) { logger.logTransfer('download', serverName, remotePath, localPath, { success: false, error: error.message }); return { content: [ { type: 'text', text: `āŒ Download error: ${error.message}`, }, ], }; } } ); registerToolConditional( 'ssh_sync', { description: 'Synchronize files/folders between local and remote via rsync', inputSchema: { server: z.string().describe('Server name from configuration'), source: z.string().describe('Source path (use "local:" or "remote:" prefix)'), destination: z.string().describe('Destination path (use "local:" or "remote:" prefix)'), exclude: z.array(z.string()).optional().describe('Patterns to exclude from sync'), dryRun: z.boolean().optional().describe('Perform dry run without actual changes'), delete: z.boolean().optional().describe('Delete files in destination not in source'), compress: z.boolean().optional().describe('Compress during transfer'), verbose: z.boolean().optional().describe('Show detailed progress'), checksum: z.boolean().optional().describe('Use checksum instead of timestamp for comparison'), timeout: z.number().optional().describe('Timeout in milliseconds (default: 30000)') } }, async ({ server: serverName, source, destination, exclude = [], dryRun = false, delete: deleteFiles = false, compress = true, verbose = false, checksum = false, timeout = 30000 }) => { try { const ssh = await getConnection(serverName); const servers = loadServerConfig(); const serverConfig = servers[serverName.toLowerCase()]; // Check if sshpass is available for password authentication if (!serverConfig.keypath && serverConfig.password) { // Check if sshpass is installed try { const { execSync } = await import('child_process'); execSync('which sshpass', { stdio: 'ignore' }); } catch (error) { return { content: [ { type: 'text', text: `āŒ Error: ssh_sync with password authentication requires sshpass.\n\nThe server '${serverName}' uses password authentication.\nPlease install sshpass: brew install hudochenkov/sshpass/sshpass (macOS) or apt-get install sshpass (Linux)\n\nAlternatively, use ssh_upload or ssh_download for single file transfers.` } ] }; } } // Determine sync direction based on source/destination prefixes const isLocalSource = source.startsWith('local:'); const isRemoteSource = source.startsWith('remote:'); const isLocalDest = destination.startsWith('local:'); const isRemoteDest = destination.startsWith('remote:'); // Clean paths const cleanSource = source.replace(/^(local:|remote:)/, ''); const cleanDest = destination.replace(/^(local:|remote:)/, ''); // Validate direction if ((isLocalSource && isLocalDest) || (isRemoteSource && isRemoteDest)) { throw new Error('Source and destination must be different (one local, one remote). Use prefixes: local: or remote:'); } // If no prefixes, assume old format (local source to remote dest) const direction = (isLocalSource || (!isLocalSource && !isRemoteSource)) ? 'push' : 'pull'; // Build rsync command let rsyncOptions = ['-avz']; if (!compress) { rsyncOptions = ['-av']; } if (checksum) { rsyncOptions.push('--checksum'); } if (deleteFiles) { rsyncOptions.push('--delete'); } if (dryRun) { rsyncOptions.push('--dry-run'); } if (verbose || logger.verbose) { // Only add stats, not progress to avoid blocking with too much output rsyncOptions.push('--stats'); } // Add exclude patterns exclude.forEach(pattern => { rsyncOptions.push('--exclude', pattern); }); let localPath; let remotePath; if (direction === 'push') { localPath = cleanSource; remotePath = cleanDest; // Check if local path exists if (!fs.existsSync(localPath)) { throw new Error(`Local path does not exist: ${localPath}`); } } else { localPath = cleanDest; remotePath = cleanSource; } // Add SSH options for non-interactive mode const sshOptions = []; // Different options based on authentication method if (serverConfig.keypath) { sshOptions.push('-o BatchMode=yes'); // No password prompts sshOptions.push('-o StrictHostKeyChecking=accept-new'); // Accept new keys, reject changed ones sshOptions.push('-o ConnectTimeout=10'); // Connection timeout const keyPath = serverConfig.keypath.replace('~', process.env.HOME); sshOptions.push(`-i ${keyPath}`); } else { // With sshpass, we don't use BatchMode sshOptions.push('-o StrictHostKeyChecking=accept-new'); // Accept new keys, reject changed ones sshOptions.push('-o ConnectTimeout=10'); } if (serverConfig.port && serverConfig.port !== '22') { sshOptions.push(`-p ${serverConfig.port}`); } logger.info(`Starting rsync ${direction}`, { server: serverName, source: direction === 'push' ? localPath : remotePath, destination: direction === 'push' ? remotePath : localPath, dryRun, deleteFiles }); const startTime = Date.now(); // Execute rsync via spawn for non-blocking streaming const { spawn } = await import('child_process'); return new Promise((resolve, reject) => { let output = ''; let errorOutput = ''; let killed = false; // Build command based on authentication method let rsyncCommand; let rsyncArgs = []; let processEnv = { ...process.env }; if (serverConfig.password) { // Use sshpass for password authentication rsyncCommand = 'sshpass'; rsyncArgs.push('-p', serverConfig.password); rsyncArgs.push('rsync'); // Add rsync options rsyncOptions.forEach(opt => rsyncArgs.push(opt)); // Add SSH command const sshCmd = `ssh ${sshOptions.join(' ')}`; rsyncArgs.push('-e', sshCmd); } else { // Direct rsync for key authentication rsyncCommand = 'rsync'; // Add rsync options rsyncOptions.forEach(opt => rsyncArgs.push(opt)); // Add SSH command with all options const sshCmd = `ssh ${sshOptions.join(' ')}`; rsyncArgs.push('-e', sshCmd); processEnv.SSH_ASKPASS = '/bin/false'; processEnv.DISPLAY = ''; } // Add source and destination if (direction === 'push') { rsyncArgs.push(localPath); rsyncArgs.push(`${serverConfig.user}@${serverConfig.host}:${remotePath}`); } else { rsyncArgs.push(`${serverConfig.user}@${serverConfig.host}:${remotePath}`); rsyncArgs.push(localPath); } const rsyncProcess = spawn(rsyncCommand, rsyncArgs, { stdio: ['ignore', 'pipe', 'pipe'], env: processEnv }); // Set timeout const timer = setTimeout(() => { killed = true; rsyncProcess.kill('SIGTERM'); reject(new Error(`Rsync timeout after ${timeout}ms`)); }, timeout); // Collect output with size limit rsyncProcess.stdout.on('data', (data) => { const chunk = data.toString(); output += chunk; // Limit output size to prevent memory issues if (output.length > 100000) { output = output.slice(-50000); } }); rsyncProcess.stderr.on('data', (data) => { const chunk = data.toString(); errorOutput += chunk; if (errorOutput.length > 50000) { errorOutput = errorOutput.slice(-25000); } }); rsyncProcess.on('error', (err) => { clearTimeout(timer); reject(new Error(`Failed to start rsync: ${err.message}`)); }); rsyncProcess.on('close', (code) => { clearTimeout(timer); if (killed) { return; // Already rejected due to timeout } const duration = Date.now() - startTime; if (code !== 0) { logger.error(`Rsync ${direction} failed`, { server: serverName, exitCode: code, error: errorOutput, duration: `${duration}ms` }); // Check if it's an SSH key error if (detectSSHKeyError(errorOutput)) { const hostInfo = extractHostFromSSHError(errorOutput); let errorMsg = `SSH host key verification failed for ${serverName}.\n`; if (hostInfo) { errorMsg += `Host: ${hostInfo.host}:${hostInfo.port}\n`; } errorMsg += '\nšŸ“ To fix this issue:\n'; errorMsg += '1. Verify the server identity\n'; errorMsg += '2. Use \'ssh_key_manage\' tool with action \'verify\' to check the key\n'; errorMsg += '3. Use \'ssh_key_manage\' tool with action \'accept\' to update the key if you trust the server\n'; errorMsg += `\nOriginal error:\n${errorOutput}`; reject(new Error(errorMsg)); } else { reject(new Error(`Rsync failed with exit code ${code}: ${errorOutput || 'Unknown error'}`)); } return; } // Parse rsync output for statistics let stats = { filesTransferred: 0, totalSize: 0, totalTime: duration }; // Extract statistics from rsync output const filesMatch = output.match(/Number of files transferred: (\d+)/); const sizeMatch = output.match(/Total transferred file size: ([\d,]+) bytes/); const speedMatch = output.match(/([\d.]+) bytes\/sec/); if (filesMatch) stats.filesTransferred = parseInt(filesMatch[1]); if (sizeMatch) stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, '')); if (speedMatch) stats.speed = parseFloat(speedMatch[1]); logger.info(`Rsync ${direction} completed`, { server: serverName, direction, duration: `${duration}ms`, filesTransferred: stats.filesTransferred, totalSize: stats.totalSize, dryRun }); // Format output let resultText = dryRun ? 'šŸ” Dry run completed\n' : 'āœ… Sync completed successfully\n'; resultText += `Direction: ${direction === 'push' ? 'Local → Remote' : 'Remote → Local'}\n`; resultText += `Server: ${serverName}\n`; resultText += `Source: ${direction === 'push' ? localPath : remotePath}\n`; resultText += `Destination: ${direction === 'push' ? remotePath : localPath}\n`; if (stats.filesTransferred > 0) { resultText += `Files transferred: ${stats.filesTransferred}\n`; if (stats.totalSize > 0) { const sizeKB = (stats.totalSize / 1024).toFixed(2); resultText += `Total size: ${sizeKB} KB\n`; } if (stats.speed) { const speedKB = (stats.speed / 1024).toFixed(2); resultText += `Average speed: ${speedKB} KB/s\n`; } } else { resultText += 'No files needed to be transferred\n'; } resultText += `Time: ${(duration / 1000).toFixed(2)} seconds\n`; if (verbose && output.length < 5000) { resultText += '\nšŸ“‹ Sync statistics:\n'; // Only show relevant stats lines const statsLines = output.split('\n').filter(line => line.includes('Number of') || line.includes('Total') || line.includes('sent') || line.includes('received') ); if (statsLines.length > 0) { resultText += statsLines.join('\n'); } } resolve({ content: [ { type: 'text', text: resultText } ] }); }); }); } catch (error) { return { content: [ { type: 'text', text: `āŒ Sync error: ${error.message}` } ] }; } } ); registerToolConditional( 'ssh_tail', { description: 'Tail remote log files in real-time', inputSchema: { server: z.string().describe('Server name from configuration'), file: z.string().describe('Path to the log file to tail'), lines: z.number().optional().describe('Number of lines to show initially (default: 10)'), follow: z.boolean().optional().describe('Follow file for new content (default: true)'), grep: z.string().optional().describe('Filter lines with grep pattern') } }, async ({ server: serverName, file, lines = 10, follow = true, grep }) => { try { const ssh = await getConnection(serverName); // Build tail command let command = `tail -n ${lines}`; if (follow) { command += ' -f'; } command += ` "${file}"`; // Add grep filter if specified if (grep) { command += ` | grep "${grep}"`; } logger.info(`Starting tail on ${serverName}`, { file, lines, follow, grep }); // For follow mode, we need to handle streaming if (follow) { // Create a unique session ID for this tail const sessionId = `tail_${Date.now()}`; // Store the SSH stream for later cleanup const stream = await ssh.execCommandStream(command, { onStdout: (chunk) => { // In a real implementation, this would stream to the client console.error(`[${serverName}:${file}] ${chunk}`); }, onStderr: (chunk) => { console.error(`[ERROR] ${chunk}`); } }); return { content: [ { type: 'text', text: `šŸ“œ Tailing ${file} on ${serverName}\nSession ID: ${sessionId}\nShowing last ${lines} lines${grep ? ` (filtered: ${grep})` : ''}\n\nāš ļø Note: In follow mode, output is streamed to stderr.\nTo stop tailing, you'll need to kill the session.` } ] }; } else { // Non-follow mode - just get the output const result = await execCommandWithTimeout(ssh, command, {}, 15000); if (result.code !== 0) { throw new Error(result.stderr || 'Failed to tail file'); } logger.info(`Tail completed on ${serverName}`, { file, lines: result.stdout.split('\n').length }); return { content: [ { type: 'text', text: `šŸ“œ Last ${lines} lines of ${file} on ${serverName}${grep ? ` (filtered: ${grep})` : ''}:\n\n${result.stdout}` } ] }; } } catch (error) { logger.error(`Tail failed on ${serverName}`, { file, error: error.message }); return { content: [ { type: 'text', text: `āŒ Tail error: ${error.message}` } ] }; } } ); registerToolConditional( 'ssh_monitor', { description: 'Monitor system resources (CPU, RAM, disk) on remote server', inputSchema: { server: z.string().describe('Server name from configuration'), type: z.enum(['overview', 'cpu', 'memory', 'disk', 'network', 'process']).optional().describe('Type of monitoring (default: overview)'), interval: z.number().optional().describe('Update interval in seconds for continuous monitoring'), duration: z.number().optional().describe('Duration in seconds for continuous monitoring') } }, async ({ server: serverName, type = 'overview', interval, duration }) => { try { const ssh = await getConnection(serverName); logger.info(`Starting system monitoring on ${serverName}`, { type, interval, duration }); let commands = {}; let output = {}; // Define monitoring commands based on type switch (type) { case 'cpu': commands.cpu = 'top -bn1 | head -20'; commands.load = 'uptime'; commands.cores = 'nproc'; break; case 'memory': commands.memory = 'free -h'; commands.swap = 'swapon --show'; commands.top_mem = 'ps aux --sort=-%mem | head -10'; break; case 'disk': commands.disk = 'df -h'; commands.inodes = 'df -i'; commands.io = 'iostat -x 1 2 | tail -n +4'; break; case 'network': commands.interfaces = 'ip -s link show'; commands.connections = 'ss -tunap | head -20'; commands.netstat = 'netstat -i'; break; case 'process': commands.process = 'ps aux --sort=-%cpu | head -20'; commands.count = 'ps aux | wc -l'; commands.zombies = 'ps aux | grep -c defunct || echo 0'; break; case 'overview': default: commands.uptime = 'uptime'; commands.cpu = 'mpstat 1 1 2>/dev/null || top -bn1 | grep \'Cpu\''; commands.memory = 'free -h'; commands.disk = 'df -h | grep -E \'^/dev/\' | head -5'; commands.load = 'cat /proc/loadavg'; commands.processes = 'ps aux | wc -l'; break; } // Execute all monitoring commands const startTime = Date.now(); for (const [key, cmd] of Object.entries(commands)) { try { const result = await execCommandWithTimeout(ssh, cmd, {}, 10000); if (result.code === 0) { output[key] = result.stdout.trim(); } else { output[key] = `Error: ${result.stderr || 'Command failed'}`; } } catch (err) { output[key] = `Error: ${err.message}`; } } const monitoringDuration = Date.now() - startTime; // Format the output based on type let formattedOutput = `šŸ“Š System Monitor - ${serverName}\n`; formattedOutput += `Type: ${type} | Time: ${new Date().toISOString()}\n`; formattedOutput += `Collection time: ${monitoringDuration}ms\n`; formattedOutput += '━'.repeat(50) + '\n\n'; switch (type) { case 'overview': formattedOutput += `ā±ļø UPTIME\n${output.uptime || 'N/A'}\n\n`; formattedOutput += `šŸ’» CPU\n${output.cpu || 'N/A'}\n\n`; formattedOutput += `šŸ“ˆ LOAD AVERAGE\n${output.load || 'N/A'}\n\n`; formattedOutput += `šŸ’¾ MEMORY\n${output.memory || 'N/A'}\n\n`; formattedOutput += `šŸ’æ DISK USAGE\n${output.disk || 'N/A'}\n\n`; formattedOutput += `šŸ“ PROCESSES: ${output.processes || 'N/A'}\n`; break; case 'cpu': formattedOutput += `šŸ–„ļø CPU CORES: ${output.cores || 'N/A'}\n\n`; formattedOutput += `šŸ“Š LOAD\n${output.load || 'N/A'}\n\n`; formattedOutput += `šŸ“ˆ TOP PROCESSES\n${output.cpu || 'N/A'}\n`; break; case 'memory': formattedOutput += `šŸ’¾ MEMORY USAGE\n${output.memory || 'N/A'}\n\n`; formattedOutput += `šŸ”„ SWAP\n${output.swap || 'No swap configured'}\n\n`; formattedOutput += `šŸ“Š TOP MEMORY CONSUMERS\n${output.top_mem || 'N/A'}\n`; break; case 'disk': formattedOutput += `šŸ’æ DISK SPACE\n${output.disk || 'N/A'}\n\n`; formattedOutput += `šŸ“ INODE USAGE\n${output.inodes || 'N/A'}\n\n`; formattedOutput += `⚔ I/O STATS\n${output.io || 'N/A'}\n`; break; case 'network': formattedOutput += `🌐 NETWORK INTERFACES\n${output.interfaces || 'N/A'}\n\n`; formattedOutput += `šŸ”Œ CONNECTIONS\n${output.connections || 'N/A'}\n\n`; formattedOutput += `šŸ“Š INTERFACE STATS\n${output.netstat || 'N/A'}\n`; break; case 'process': formattedOutput += `šŸ“ PROCESS COUNT: ${output.count || 'N/A'}\n`; formattedOutput += `āš ļø ZOMBIE PROCESSES: ${output.zombies || '0'}\n\n`; formattedOutput += `šŸ“Š TOP PROCESSES BY CPU\n${output.process || 'N/A'}\n`; break; } // Log monitoring results logger.info(`System monitoring completed on ${serverName}`, { type, duration: `${monitoringDuration}ms`, metrics: Object.keys(output).length }); // If continuous monitoring requested if (interval && duration) { formattedOutput += `\n\nā° Continuous monitoring: Every ${interval}s for ${duration}s\n`; formattedOutput += '(Not implemented in this version - would require streaming support)'; } return { content: [ { type: 'text', text: formattedOutput } ] }; } catch (error) { logger.error(`Monitoring failed on ${serverName}`, { type, error: error.message }); return { content: [ { type: 'text', text: `āŒ Monitor error: ${error.message}` } ] }; } } ); registerToolConditional( 'ssh_history', { description: 'View SSH command history', inputSchema: { limit: z.number().optional().describe('Number of commands to show (default: 20)'), server: z.string().optional().describe('Filter by server name'), success: z.boolean().optional().describe('Filter by success/failure'), search: z.string().optional().describe('Search in commands') } }, async ({ limit = 20, server, success, search }) => { try { // Get history from logger let history = logger.getHistory(limit * 2); // Get more to account for filtering // Apply filters if (server) { history = history.filter(h => h.server?.toLowerCase().includes(server.toLowerCase())); } if (success !== undefined) { history = history.filter(h => h.success === success); } if (search) { history = history.filter(h => h.command?.toLowerCase().includes(search.toLowerCase())); } // Limit results history = history.slice(-limit); // Format output let output = 'šŸ“œ SSH Command History\n'; output += `Showing last ${history.length} commands`; const filters = []; if (server) filters.push(`server: ${server}`); if (success !== undefined) filters.push(success ? 'successful only' : 'failed only'); if (search) filters.push(`search: ${search}`); if (filters.length > 0) { output += ` (filtered: ${filters.join(', ')})`; } output += '\n' + '━'.repeat(60) + '\n\n'; if (history.length === 0) { output += 'No commands found matching the criteria.\n'; } else { history.forEach((entry, index) => { const time = new Date(entry.timestamp).toLocaleString(); const status = entry.success ? 'āœ…' : 'āŒ'; const duration = entry.duration || 'N/A'; output += `${history.length - index}. ${status} [${time}]\n`; output += ` Server: ${entry.server || 'unknown'}\n`; output += ` Command: ${entry.command?.substring(0, 100) || 'N/A'}`; if (entry.command && entry.command.length > 100) { output += '...'; } output += '\n'; output += ` Duration: ${duration}`; if (!entry.success && entry.error) { output += `\n Error: ${entry.error}`; } output += '\n\n'; }); } output += '━'.repeat(60) + '\n'; output += `Total commands in history: ${logger.getHistory(1000).length}\n`; logger.info('Command history retrieved', { limit, filters: filters.length, results: history.length }); return { content: [ { type: 'text', text: output } ] }; } catch (error) { return { content: [ { type: 'text', text: `āŒ Error retrieving history: ${error.message}` } ] }; } } ); // SSH Session Management Tools registerToolConditional( 'ssh_session_start', { description: 'Start a persistent SSH session that maintains state and context', inputSchema: { server: z.string().describe('Server name from configuration'), name: z.string().optional().describe('Optional session name for identification') } }, async ({ server: serverName, name }) => { try { const ssh = await getConnection(serverName); const session = await createSession(serverName, ssh); const sessionName = name || `Session on ${serverName}`; logger.info('SSH session started', { id: session.id, server: serverName, name: sessionName }); return { content: [ { type: 'text', text: `šŸš€ SSH Session Started\n\nSession ID: ${session.id}\nServer: ${serverName}\nName: ${sessionName}\nState: ${session.state}\nWorking Directory: ${session.context.cwd}\n\nUse ssh_session_send to execute commands in this session.\nUse ssh_session_close to terminate the session.` } ] }; } catch (error) { logger.error('Failed to start SSH session', { server: serverName, error: error.message }); return { content: [ { type: 'text', text: `āŒ Failed to start session: ${error.message}` } ] }; } } ); registerToolConditional( 'ssh_session_send', { description: 'Send a command to an existing SSH session', inputSchema: { session: z.string().describe('Session ID from ssh_session_start'), command: z.string().describe('Command to execute in the session'), timeout: z.number().optional().describe('Command timeout in milliseconds (default: 30000)') } }, async ({ session: sessionId, command, timeout = 30000 }) => { try { const session = getSession(sessionId); const startTime = Date.now(); const result = await session.execute(command, { timeout }); const duration = Date.now() - startTime; logger.info('Session command executed', { session: sessionId, command: command.substring(0, 50), success: result.success, duration: `${duration}ms` }); let output = `šŸ“Ÿ Session: ${sessionId}\n`; output += `Server: ${session.serverName}\n`; output += `Working Directory: ${session.context.cwd}\n`; output += `Command: ${command}\n`; output += `Duration: ${duration}ms\n`; output += '━'.repeat(60) + '\n\n'; if (result.success) { output += 'āœ… Output:\n' + result.output; } else { output += 'āŒ Error:\n' + (result.error || result.output); } // Add session state info output += '\n\n' + '━'.repeat(60) + '\n'; output += `Session State: ${session.state}\n`; output += `Commands Executed: ${session.context.history.length}\n`; return { content: [ { type: 'text', text: output } ] }; } catch (error) { logger.error('Failed to send command to session', { session: sessionId, command, error: error.message }); return { content: [ { type: 'text', text: `āŒ Session error: ${error.message}` } ] }; } } ); registerToolConditional( 'ssh_session_list', { description: 'List all active SSH sessions', inputSchema: { server: z.string().optional().describe('Filter by server name') } }, async ({ server }) => { try { let sessions = listSessions(); // Filter by server if specified if (server) { sessions = sessions.filter(s => s.server.toLowerCase().includes(server.toLowerCase()) ); } let output = 'šŸ“‹ Active SSH Sessions\n'; output += '━'.repeat(60) + '\n\n'; if (sessions.length === 0) { output += 'No active sessions'; if (server) { output += ` for server "${server}"`; } output += '.\n'; } else { sessions.forEach((session, index) => { const age = Math.floor((Date.now() - new Date(session.created).getTime()) / 1000); const idle = Math.floor((Date.now() - new Date(session.lastActivity).getTime()) / 1000); output += `${index + 1}. Session: ${session.id}\n`; output += ` Server: ${session.server}\n`; output += ` State: ${session.state}\n`; output += ` Working Dir: ${session.cwd || 'unknown'}\n`; output += ` Commands Run: ${session.historyCount}\n`; output += ` Age: ${formatDuration(age)}\n`; output += ` Idle: ${formatDuration(idle)}\n`; if (session.variables.length > 0) { output += ` Variables: ${session.variables.join(', ')}\n`; } output += '\n'; }); } output += '━'.repeat(60) + '\n'; output += `Total Active Sessions: ${sessions.length}\n`; logger.info('Listed SSH sessions', { total: sessions.length, filter: server }); return { content: [ { type: 'text', text: output } ] }; } catch (error) { return { content: [ { type: 'text', text: `āŒ Error listing sessions: ${error.message}` } ] }; } } ); registerToolConditional( 'ssh_session_close', { description: 'Close an SSH session', inputSchema: { session: z.string().describe('Session ID to close (or "all" to close all sessions)') } }, async ({ session: sessionId }) => { try { if (sessionId === 'all') { const sessions = listSessions(); const count = sessions.length; sessions.forEach(s => { try { closeSession(s.id); } catch (err) { // Ignore individual close errors } }); logger.info('Closed all SSH sessions', { count }); return { content: [ { type: 'text', text: `šŸ”š Closed ${count} SSH sessions` } ] }; } else { closeSession(sessionId); logger.info('SSH session closed', { session: sessionId }); return { content: [ { type: 'text', text: `šŸ”š Session closed: ${sessionId}` } ] }; } } catch (error) { logger.error('Failed to close session', { session: sessionId, error: error.message }); return { content: [ { type: 'text', text: `āŒ Failed to close session: ${error.message}` } ] }; } } ); // Helper function to format duration function formatDuration(seconds) { if (seconds < 60) { return `${seconds}s`; } else if (seconds < 3600) { return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; } else { return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; } } // Server Group Management Tools registerToolConditional( 'ssh_execute_group', { description: 'Execute command on a group of servers', inputSchema: { group: z.string().describe('Group name (e.g., "production", "staging", "all")'), command: z.string().describe('Command to execute'), strategy: z.enum(['parallel', 'sequential', 'rolling']).optional().describe('Execution strategy'),