@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
JavaScript
#!/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'),