UNPKG

ssh-mcp

Version:

MCP server exposing SSH control for Linux and Windows systems via Model Context Protocol.

659 lines (658 loc) 26.5 kB
#!/usr/bin/env node import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { Client } from 'ssh2'; import { z } from 'zod'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; // Example usage: node build/index.js --host=1.2.3.4 --port=22 --user=root --password=pass --key=path/to/key --timeout=5000 --disableSudo function parseArgv() { const args = process.argv.slice(2); const config = {}; for (const arg of args) { if (arg.startsWith('--')) { const equalIndex = arg.indexOf('='); if (equalIndex === -1) { // Flag without value config[arg.slice(2)] = null; } else { // Key=value pair config[arg.slice(2, equalIndex)] = arg.slice(equalIndex + 1); } } } return config; } const isTestMode = process.env.SSH_MCP_TEST === '1'; const isCliEnabled = process.env.SSH_MCP_DISABLE_MAIN !== '1'; const argvConfig = (isCliEnabled || isTestMode) ? parseArgv() : {}; const HOST = argvConfig.host; const PORT = argvConfig.port ? parseInt(argvConfig.port) : 22; const USER = argvConfig.user; const PASSWORD = argvConfig.password; const SUPASSWORD = argvConfig.suPassword; const SUDOPASSWORD = argvConfig.sudoPassword; const DISABLE_SUDO = argvConfig.disableSudo !== undefined; const KEY = argvConfig.key; const DEFAULT_TIMEOUT = argvConfig.timeout ? parseInt(argvConfig.timeout) : 60000; // 60 seconds default timeout // Max characters configuration: // - Default: 1000 characters // - When set via --maxChars: // * a positive integer enforces that limit // * 0 or a negative value disables the limit (no max) // * the string "none" (case-insensitive) disables the limit (no max) const MAX_CHARS_RAW = argvConfig.maxChars; const MAX_CHARS = (() => { if (typeof MAX_CHARS_RAW === 'string') { const lowered = MAX_CHARS_RAW.toLowerCase(); if (lowered === 'none') return Infinity; const parsed = parseInt(MAX_CHARS_RAW); if (isNaN(parsed)) return 1000; if (parsed <= 0) return Infinity; return parsed; } return 1000; })(); function validateConfig(config) { const errors = []; if (!config.host) errors.push('Missing required --host'); if (!config.user) errors.push('Missing required --user'); if (config.port && isNaN(Number(config.port))) errors.push('Invalid --port'); if (errors.length > 0) { throw new Error('Configuration error:\n' + errors.join('\n')); } } if (isCliEnabled) { validateConfig(argvConfig); } // Command sanitization and validation export function sanitizeCommand(command) { if (typeof command !== 'string') { throw new McpError(ErrorCode.InvalidParams, 'Command must be a string'); } const trimmedCommand = command.trim(); if (!trimmedCommand) { throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty'); } // Length check if (Number.isFinite(MAX_CHARS) && trimmedCommand.length > MAX_CHARS) { throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${MAX_CHARS} characters)`); } return trimmedCommand; } function sanitizePassword(password) { if (typeof password !== 'string') return undefined; // minimal check, do not log or modify content if (password.length === 0) return undefined; return password; } // Escape command for use in shell contexts (like pkill) export function escapeCommandForShell(command) { // Replace single quotes with escaped single quotes return command.replace(/'/g, "'\"'\"'"); } export class SSHConnectionManager { conn = null; sshConfig; isConnecting = false; connectionPromise = null; suShell = null; // Store the elevated shell session suPromise = null; isElevated = false; // Track if we're in su mode constructor(config) { this.sshConfig = config; } async connect() { if (this.conn && this.isConnected()) { return; // Already connected } if (this.isConnecting && this.connectionPromise) { return this.connectionPromise; // Wait for ongoing connection } this.isConnecting = true; this.connectionPromise = new Promise((resolve, reject) => { this.conn = new Client(); const timeoutId = setTimeout(() => { this.conn?.end(); this.conn = null; this.isConnecting = false; this.connectionPromise = null; reject(new McpError(ErrorCode.InternalError, 'SSH connection timeout')); }, 30000); // 30 seconds connection timeout this.conn.on('ready', async () => { clearTimeout(timeoutId); this.isConnecting = false; // In test mode, don't wait for su elevation during connection setup, as it // may cause JSON-RPC server initialization to hang. Instead, elevation will // be triggered on-demand when a command is executed. // In production, elevation during connection is desirable for robustness. if (this.sshConfig.suPassword && !process.env.SSH_MCP_TEST) { try { await this.ensureElevated(); } catch (err) { // Do not reject the connection; just log the error. Subsequent commands // will either use the su shell if available or fall back to normal execution. } } resolve(); }); this.conn.on('error', (err) => { clearTimeout(timeoutId); this.conn = null; this.isConnecting = false; this.connectionPromise = null; reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`)); }); this.conn.on('end', () => { console.error('SSH connection ended'); this.conn = null; this.isConnecting = false; this.connectionPromise = null; }); this.conn.on('close', () => { console.error('SSH connection closed'); this.conn = null; this.isConnecting = false; this.connectionPromise = null; }); this.conn.connect(this.sshConfig); }); return this.connectionPromise; } isConnected() { return this.conn !== null && this.conn._sock && !this.conn._sock.destroyed; } getSudoPassword() { return this.sshConfig.sudoPassword; } getSuPassword() { return this.sshConfig.suPassword; } async setSuPassword(pwd) { this.sshConfig.suPassword = pwd; if (pwd) { try { await this.ensureElevated(); } catch (err) { console.error('setSuPassword: failed to elevate to su shell:', err); } } else { // If clearing suPassword, drop any existing suShell if (this.suShell) { try { this.suShell.end(); } catch (e) { /* ignore */ } this.suShell = null; this.isElevated = false; } } } async ensureElevated() { if (this.isElevated && this.suShell) return; if (!this.sshConfig.suPassword) return; if (this.suPromise) return this.suPromise; this.suPromise = new Promise((resolve, reject) => { const conn = this.getConnection(); // Add a safety timeout so elevation doesn't hang forever const timeoutId = setTimeout(() => { this.suPromise = null; reject(new McpError(ErrorCode.InternalError, 'su elevation timed out')); }, 10000); // 10 second timeout for elevation conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => { if (err) { clearTimeout(timeoutId); this.suPromise = null; reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`)); return; } let buffer = ''; let passwordSent = false; const cleanup = () => { try { stream.removeAllListeners('data'); } catch (e) { /* ignore */ } }; const onData = (data) => { const text = data.toString(); buffer += text; // If we haven't sent the password yet, look for the password prompt if (!passwordSent && /password[: ]/i.test(buffer)) { passwordSent = true; stream.write(this.sshConfig.suPassword + '\n'); // Don't return; keep looking for root prompt } // After password is sent, look for any root indicator // Look for '#' which indicates root prompt (may be followed by spaces, escape codes, etc) if (passwordSent) { if (/#/.test(buffer)) { clearTimeout(timeoutId); cleanup(); this.suShell = stream; this.isElevated = true; this.suPromise = null; resolve(); return; } } // Detect authentication failure messages if (/authentication failure|incorrect password|su: .*failed|su: failure/i.test(buffer)) { clearTimeout(timeoutId); cleanup(); this.suPromise = null; reject(new McpError(ErrorCode.InternalError, `su authentication failed: ${buffer}`)); return; } }; stream.on('data', onData); stream.on('close', () => { clearTimeout(timeoutId); if (!this.isElevated) { this.suPromise = null; reject(new McpError(ErrorCode.InternalError, 'su shell closed before elevation completed')); } }); // Kick off the su command stream.write('su -\n'); }); }); return this.suPromise; } async ensureConnected() { if (!this.isConnected()) { await this.connect(); } } getConnection() { if (!this.conn) { throw new McpError(ErrorCode.InternalError, 'SSH connection not established'); } return this.conn; } close() { if (this.conn) { if (this.suShell) { try { this.suShell.end(); } catch (e) { /* ignore */ } this.suShell = null; this.isElevated = false; } this.conn.end(); this.conn = null; } } } let connectionManager = null; const server = new McpServer({ name: 'SSH MCP Server', version: '1.4.0', capabilities: { resources: {}, tools: {}, }, }); server.tool("exec", "Execute a shell command on the remote SSH server and return the output.", { command: z.string().describe("Shell command to execute on the remote SSH server"), }, async ({ command }) => { // Sanitize command input const sanitizedCommand = sanitizeCommand(command); try { // Initialize connection manager if not already done if (!connectionManager) { if (!HOST || !USER) { throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username'); } const sshConfig = { host: HOST, port: PORT, username: USER, }; if (PASSWORD) { sshConfig.password = PASSWORD; } else if (KEY) { const fs = await import('fs/promises'); sshConfig.privateKey = await fs.readFile(KEY, 'utf8'); } if (SUPASSWORD !== null && SUPASSWORD !== undefined) { sshConfig.suPassword = sanitizePassword(SUPASSWORD); } connectionManager = new SSHConnectionManager(sshConfig); } // Ensure connection is active (reconnect if needed) await connectionManager.ensureConnected(); // If a suPassword was provided, explicitly wait for elevation before executing. // This is critical: ensureElevated is idempotent and will return immediately if // already elevated, so this ensures we have a su shell before we try to use it. if (connectionManager.getSuPassword && connectionManager.getSuPassword()) { try { const elevationPromise = connectionManager.ensureElevated(); // Add a short timeout for elevation to complete await Promise.race([ elevationPromise, new Promise((_, reject) => setTimeout(() => reject(new Error('Elevation timeout')), 5000)) ]); } catch (err) { // Log but don't fail; fall back to non-elevated execution if elevation times out } } const result = await execSshCommandWithConnection(connectionManager, sanitizedCommand); return result; } catch (err) { // Wrap unexpected errors if (err instanceof McpError) throw err; throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`); } }); // Expose sudo-exec tool unless explicitly disabled if (!DISABLE_SUDO) { server.tool("sudo-exec", "Execute a shell command on the remote SSH server using sudo. Will use sudo password if provided, otherwise assumes passwordless sudo.", { command: z.string().describe("Shell command to execute with sudo on the remote SSH server"), }, async ({ command }) => { const sanitizedCommand = sanitizeCommand(command); try { if (!connectionManager) { if (!HOST || !USER) { throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username'); } const sshConfig = { host: HOST, port: PORT || 22, username: USER, }; if (PASSWORD) { sshConfig.password = PASSWORD; } else if (KEY) { const fs = await import('fs/promises'); sshConfig.privateKey = await fs.readFile(KEY, 'utf8'); } if (SUPASSWORD !== null && SUPASSWORD !== undefined) { sshConfig.suPassword = sanitizePassword(SUPASSWORD); } if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) { sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD); } connectionManager = new SSHConnectionManager(sshConfig); } await connectionManager.ensureConnected(); // If suPassword or sudoPassword were provided on this call but the // existing connection manager was created earlier without them, // update the manager's values so the subsequent sudo-exec call uses // the latest passwords. if (SUPASSWORD !== null && SUPASSWORD !== undefined) { await connectionManager.setSuPassword(sanitizePassword(SUPASSWORD)); } if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) { // update sudoPassword on the manager instance connectionManager.sshConfig = { ...connectionManager.sshConfig, sudoPassword: sanitizePassword(SUDOPASSWORD) }; } let wrapped; const sudoPassword = connectionManager.getSudoPassword(); if (!sudoPassword) { // No password provided, use -n to fail if sudo requires a password wrapped = `sudo -n sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`; } else { // Password provided — pipe it into sudo using printf. This avoids complex // PTY/stdin handling on the SSH channel and is simpler and more reliable. const pwdEscaped = sudoPassword.replace(/'/g, "'\\''"); wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`; } return await execSshCommandWithConnection(connectionManager, wrapped); } catch (err) { if (err instanceof McpError) throw err; throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`); } }); } // New function that uses persistent connection export async function execSshCommandWithConnection(manager, command, stdin) { return new Promise((resolve, reject) => { let timeoutId; let isResolved = false; const conn = manager.getConnection(); const shell = manager.suShell; // Use su shell if available // Set up timeout timeoutId = setTimeout(() => { if (!isResolved) { isResolved = true; reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`)); } }, DEFAULT_TIMEOUT); // If we have an active su shell, use it directly (commands run as root in session) if (shell) { let buffer = ''; const dataHandler = (data) => { const text = data.toString(); buffer += text; // Wait for root prompt (#) to know command is complete // Match # which indicates root prompt (may be followed by spaces, escape codes, etc) if (/#/.test(buffer)) { if (!isResolved) { isResolved = true; clearTimeout(timeoutId); // Extract output: remove the command echo and final prompt const lines = buffer.split('\n'); // First line is often the echoed command; last line is the prompt let output = lines.slice(1, -1).join('\n'); resolve({ content: [{ type: 'text', text: output + (output ? '\n' : ''), }], }); } shell.removeListener('data', dataHandler); } }; shell.on('data', dataHandler); // Send command immediately; shell is ready after elevation shell.write(command + '\n'); return; } // No persistent su shell; use normal exec with optional password piping conn.exec(command, (err, stream) => { if (err) { if (!isResolved) { isResolved = true; clearTimeout(timeoutId); reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`)); } return; } let stdout = ''; let stderr = ''; // If stdin provided (e.g., sudo password), write it if (stdin && stdin.length > 0) { try { stream.write(stdin); } catch (e) { console.error('Error writing to stdin:', e); } } try { stream.end(); } catch (e) { /* ignore */ } stream.on('data', (data) => { stdout += data.toString(); }); stream.stderr.on('data', (data) => { stderr += data.toString(); }); stream.on('close', (code, signal) => { if (!isResolved) { isResolved = true; clearTimeout(timeoutId); if (stderr) { reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`)); } else { resolve({ content: [{ type: 'text', text: stdout, }], }); } } }); }); }); } // Keep the old function for backward compatibility (used in tests) export async function execSshCommand(sshConfig, command, stdin) { return new Promise((resolve, reject) => { const conn = new Client(); let timeoutId; let isResolved = false; // Set up timeout timeoutId = setTimeout(() => { if (!isResolved) { isResolved = true; // Try to abort the running command before closing connection const abortTimeout = setTimeout(() => { // If abort command itself times out, force close connection conn.end(); }, 5000); // 5 second timeout for abort command conn.exec('timeout 3s pkill -f \'' + escapeCommandForShell(command) + '\' 2>/dev/null || true', (err, abortStream) => { if (abortStream) { abortStream.on('close', () => { clearTimeout(abortTimeout); conn.end(); }); } else { clearTimeout(abortTimeout); conn.end(); } }); reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`)); } }, DEFAULT_TIMEOUT); conn.on('ready', () => { conn.exec(command, (err, stream) => { if (err) { if (!isResolved) { isResolved = true; clearTimeout(timeoutId); reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`)); } conn.end(); return; } // If stdin provided, write it to the stream and end stdin if (stdin && stdin.length > 0) { try { stream.write(stdin); } catch (e) { // ignore } } try { stream.end(); } catch (e) { /* ignore */ } let stdout = ''; let stderr = ''; stream.on('close', (code, signal) => { if (!isResolved) { isResolved = true; clearTimeout(timeoutId); conn.end(); if (stderr) { reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`)); } else { resolve({ content: [{ type: 'text', text: stdout, }], }); } } }); stream.on('data', (data) => { stdout += data.toString(); }); stream.stderr.on('data', (data) => { stderr += data.toString(); }); }); }); conn.on('error', (err) => { if (!isResolved) { isResolved = true; clearTimeout(timeoutId); reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`)); } }); conn.connect(sshConfig); }); } async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("SSH MCP Server running on stdio"); // Handle graceful shutdown const cleanup = () => { console.error("Shutting down SSH MCP Server..."); if (connectionManager) { connectionManager.close(); connectionManager = null; } process.exit(0); }; process.on('SIGINT', cleanup); process.on('SIGTERM', cleanup); process.on('exit', () => { if (connectionManager) { connectionManager.close(); } }); } // Initialize server in test mode for automated tests if (isTestMode) { const transport = new StdioServerTransport(); server.connect(transport).catch(error => { console.error("Fatal error connecting server:", error); process.exit(1); }); } // Start server in CLI mode else if (isCliEnabled) { main().catch((error) => { console.error("Fatal error in main():", error); if (connectionManager) { connectionManager.close(); } process.exit(1); }); } export { parseArgv, validateConfig };