Skip to main content
Glama
GUEPARD98

SSH-PowerShell MCP Server

by GUEPARD98
index.js22.8 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { spawn } from 'child_process'; import chalk from 'chalk'; import dotenv from 'dotenv'; import shellEscape from 'shell-escape'; import os from 'os'; import path from 'path'; import fs from 'fs'; // Cargar variables de entorno desde config/ - con múltiples opciones de configuración const configPaths = [ path.join(process.cwd(), 'config', '.env'), path.join(process.cwd(), 'config', `.env.${process.env.NODE_ENV || 'development'}`), path.join(process.cwd(), '.env') ]; for (const configPath of configPaths) { if (fs.existsSync(configPath)) { dotenv.config({ path: configPath }); break; } } // Configuración por defecto const DEFAULT_CONFIG = { SSH_TIMEOUT: 30000, COMMAND_TIMEOUT: 30000, LOG_LEVEL: 'info', DEBUG_MODE: false, SSH_STRICT_HOST_KEY_CHECKING: 'no' }; // Función de logging mejorada function log(level, message, data = null) { const timestamp = new Date().toISOString(); const logLevel = process.env.LOG_LEVEL || DEFAULT_CONFIG.LOG_LEVEL; const isDebug = process.env.DEBUG_MODE === 'true' || DEFAULT_CONFIG.DEBUG_MODE; const levels = { debug: 0, info: 1, warn: 2, error: 3 }; if (levels[level] < levels[logLevel] && !isDebug) return; const colors = { debug: chalk.gray, info: chalk.blue, warn: chalk.yellow, error: chalk.red }; const prefix = colors[level](`[${timestamp}] [${level.toUpperCase()}]`); console.error(`${prefix} ${message}`); if (data && isDebug) { console.error(chalk.gray(JSON.stringify(data, null, 2))); } } // Función para obtener ruta de clave SSH cross-platform function getSSHKeyPath(customPath = null) { if (customPath && fs.existsSync(customPath)) { return customPath; } const envPath = process.env.SSH_KEY_PATH; if (envPath) { // Expandir variables de entorno en Windows (%USERPROFILE%) y Unix (~) let expandedPath = envPath; if (os.platform() === 'win32') { expandedPath = expandedPath.replace(/%([^%]+)%/g, (_, key) => process.env[key] || ''); } else { expandedPath = expandedPath.replace(/^~/, os.homedir()); } if (fs.existsSync(expandedPath)) { return expandedPath; } } // Rutas por defecto según plataforma const defaultPaths = [ path.join(os.homedir(), '.ssh', 'id_rsa'), path.join(os.homedir(), '.ssh', 'id_ed25519'), path.join(os.homedir(), '.ssh', 'id_ecdsa') ]; for (const defaultPath of defaultPaths) { if (fs.existsSync(defaultPath)) { return defaultPath; } } throw new Error('No se encontró ninguna clave SSH válida. Configura SSH_KEY_PATH o genera una clave SSH.'); } const server = new Server( { name: 'ssh-powershell-mcp', version: '1.0.0', }, { capabilities: { tools: {}, }, } ); // Sistema de rate limiting simple class RateLimiter { constructor(maxRequests = 100, windowMs = 60000) { // 100 requests per minute by default this.maxRequests = parseInt(process.env.RATE_LIMIT_PER_MINUTE) || maxRequests; this.windowMs = windowMs; this.requests = []; } checkLimit() { const now = Date.now(); // Limpiar requests antiguos this.requests = this.requests.filter(time => now - time < this.windowMs); if (this.requests.length >= this.maxRequests) { return false; // Rate limit exceeded } this.requests.push(now); return true; } getRemainingRequests() { const now = Date.now(); this.requests = this.requests.filter(time => now - time < this.windowMs); return Math.max(0, this.maxRequests - this.requests.length); } } // Pool simple de conexiones SSH para reutilización class ConnectionPool { constructor(maxSize = 5) { this.maxSize = parseInt(process.env.CONNECTION_POOL_SIZE) || maxSize; this.connections = new Map(); // host+user -> { lastUsed, inUse } this.activeConnections = 0; } getConnectionKey(host, user) { return `${user}@${host}`; } canCreateConnection() { return this.activeConnections < this.maxSize; } markConnectionUsed(host, user) { const key = this.getConnectionKey(host, user); this.connections.set(key, { lastUsed: Date.now(), inUse: true }); this.activeConnections++; } releaseConnection(host, user) { const key = this.getConnectionKey(host, user); if (this.connections.has(key)) { this.connections.set(key, { lastUsed: Date.now(), inUse: false }); this.activeConnections = Math.max(0, this.activeConnections - 1); } } cleanup() { const now = Date.now(); const maxAge = 5 * 60 * 1000; // 5 minutes for (const [key, conn] of this.connections.entries()) { if (!conn.inUse && (now - conn.lastUsed) > maxAge) { this.connections.delete(key); } } } } // Instancias globales const rateLimiter = new RateLimiter(); const connectionPool = new ConnectionPool(); // Limpiar pool periódicamente setInterval(() => { connectionPool.cleanup(); }, 5 * 60 * 1000); // Cada 5 minutos // Configuración del servidor MCP // Función para detectar el ejecutable de PowerShell disponible function getPowerShellExecutable() { // En Windows, preferir powershell.exe, luego pwsh.exe // En Linux/macOS, usar pwsh (PowerShell Core) const platform = os.platform(); if (platform === 'win32') { return 'powershell'; // Windows PowerShell por defecto } else { return 'pwsh'; // PowerShell Core en Linux/macOS } } // Función para ejecutar comandos PowerShell con mejor manejo de errores function executePowerShell(command, timeout = null) { return new Promise((resolve, reject) => { const startTime = Date.now(); const actualTimeout = timeout || parseInt(process.env.COMMAND_TIMEOUT) || DEFAULT_CONFIG.COMMAND_TIMEOUT; log('debug', 'Ejecutando comando PowerShell', { command, timeout: actualTimeout }); // Validar comando if (!command || typeof command !== 'string') { return reject(new Error('Comando PowerShell inválido')); } const psExecutable = getPowerShellExecutable(); const childProcess = spawn(psExecutable, ['-Command', command], { stdio: ['pipe', 'pipe', 'pipe'], shell: false // Mejor seguridad sin shell wrapper }); let stdout = ''; let stderr = ''; let isResolved = false; childProcess.stdout.on('data', (data) => { stdout += data.toString(); }); childProcess.stderr.on('data', (data) => { stderr += data.toString(); }); childProcess.on('close', (code) => { if (isResolved) return; isResolved = true; const executionTime = Date.now() - startTime; const result = { success: code === 0, output: stdout, error: stderr || null, exitCode: code, executionTime }; log('debug', 'Comando PowerShell completado', result); if (code === 0) { resolve(result); } else { reject(result); } }); childProcess.on('error', (error) => { if (isResolved) return; isResolved = true; const result = { success: false, output: stdout, error: error.message, exitCode: -1, executionTime: Date.now() - startTime }; log('error', 'Error ejecutando PowerShell', result); reject(result); }); // Timeout mejorado const timeoutId = setTimeout(() => { if (isResolved) return; isResolved = true; try { childProcess.kill('SIGKILL'); } catch (e) { log('warn', 'Error matando proceso', e); } const result = { success: false, output: stdout, error: `Timeout de ${actualTimeout}ms excedido`, exitCode: -1, executionTime: actualTimeout }; log('warn', 'Timeout en comando PowerShell', result); reject(result); }, actualTimeout); childProcess.on('close', () => clearTimeout(timeoutId)); childProcess.on('error', () => clearTimeout(timeoutId)); }); } // Función para conectar SSH con autenticación por claves mejorada y pooling async function executeSSHCommand(host, user, command, keyPath = null, timeout = null) { const startTime = Date.now(); // Validar parámetros de entrada if (!host || typeof host !== 'string') { throw new Error('Parámetro host es requerido y debe ser una cadena válida'); } if (!user || typeof user !== 'string') { throw new Error('Parámetro user es requerido y debe ser una cadena válida'); } if (!command || typeof command !== 'string') { throw new Error('Parámetro command es requerido y debe ser una cadena válida'); } // Verificar pool de conexiones if (!connectionPool.canCreateConnection()) { throw new Error(`Máximo de conexiones SSH concurrentes alcanzado (${connectionPool.maxSize}). Intente más tarde.`); } log('info', 'Iniciando conexión SSH', { host, user, command: command.substring(0, 100) }); // Marcar conexión como en uso connectionPool.markConnectionUsed(host, user); try { // Obtener ruta de clave SSH const actualKeyPath = getSSHKeyPath(keyPath); const actualTimeout = timeout || parseInt(process.env.SSH_TIMEOUT) || DEFAULT_CONFIG.SSH_TIMEOUT; // Sanitizar comando usando shell-escape para seguridad const sanitizedCommand = shellEscape([command]); // Construir comando SSH seguro con parámetros de conexión const sshArgs = [ '-i', actualKeyPath, '-o', `StrictHostKeyChecking=${process.env.SSH_STRICT_HOST_KEY_CHECKING || DEFAULT_CONFIG.SSH_STRICT_HOST_KEY_CHECKING}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=3', `${user}@${host}`, sanitizedCommand ]; log('debug', 'Comando SSH construido', { args: sshArgs }); // Ejecutar comando SSH const sshCommand = `ssh ${sshArgs.map(arg => shellEscape([arg])).join(' ')}`; const result = await executePowerShell(sshCommand, actualTimeout); // Agregar metadata específica de SSH const sshResult = { ...result, metadata: { host, user, keyPath: actualKeyPath, sanitizedCommand, executionTime: Date.now() - startTime, connectionPoolStatus: { active: connectionPool.activeConnections, maxSize: connectionPool.maxSize } } }; log('info', 'Comando SSH ejecutado exitosamente', { host, executionTime: sshResult.metadata.executionTime, outputLength: result.output.length }); return sshResult; } catch (error) { const executionTime = Date.now() - startTime; log('error', 'Error ejecutando comando SSH', { host, user, error: error.message, executionTime }); throw new Error(`Error SSH en ${host}: ${error.message}`); } finally { // Liberar conexión del pool connectionPool.releaseConnection(host, user); } } // Lista de herramientas disponibles server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'ssh_execute', description: 'Ejecutar comandos en máquinas remotas vía SSH usando clave SSH', inputSchema: { type: 'object', properties: { host: { type: 'string', description: 'Dirección IP o hostname del servidor remoto' }, user: { type: 'string', description: 'Nombre de usuario para SSH' }, command: { type: 'string', description: 'Comando a ejecutar en el servidor remoto' }, keyPath: { type: 'string', description: 'Ruta a la clave SSH privada (opcional)' }, timeout: { type: 'number', description: 'Timeout en milisegundos (opcional)' } }, required: ['host', 'user', 'command'] } }, { name: 'powershell_execute', description: 'Ejecutar comandos PowerShell localmente', inputSchema: { type: 'object', properties: { command: { type: 'string', description: 'Comando PowerShell a ejecutar' }, timeout: { type: 'number', description: 'Timeout en milisegundos (opcional)' } }, required: ['command'] } }, { name: 'ssh_scan', description: 'Escanear red para encontrar hosts SSH disponibles', inputSchema: { type: 'object', properties: { target: { type: 'string', description: 'Red a escanear (ej: 192.168.1.0/24) o host individual' }, timeout: { type: 'number', description: 'Timeout en milisegundos (opcional)' } }, required: ['target'] } }, { name: 'ssh_keyscan', description: 'Obtener fingerprint de claves SSH de un host', inputSchema: { type: 'object', properties: { host: { type: 'string', description: 'Host para obtener claves SSH' }, port: { type: 'number', description: 'Puerto SSH (opcional, default: 22)', default: 22 }, timeout: { type: 'number', description: 'Timeout en milisegundos (opcional)' } }, required: ['host'] } } ] }; }); // Manejador de ejecución de herramientas con manejo de errores mejorado y rate limiting server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const startTime = Date.now(); // Verificar rate limiting if (!rateLimiter.checkLimit()) { const remainingRequests = rateLimiter.getRemainingRequests(); log('warn', `Rate limit excedido para herramienta: ${name}`, { remainingRequests }); return { content: [ { type: 'text', text: `⚠️ Rate limit excedido. Máximo ${rateLimiter.maxRequests} requests por minuto. Quedan: ${remainingRequests} requests disponibles.` } ], isError: true, metadata: { tool: name, error: 'Rate limit exceeded', rateLimitInfo: { maxRequests: rateLimiter.maxRequests, remainingRequests: remainingRequests, windowMs: rateLimiter.windowMs }, timestamp: new Date().toISOString() } }; } log('info', `Ejecutando herramienta: ${name}`, { args, remainingRequests: rateLimiter.getRemainingRequests() }); try { switch (name) { case 'ssh_execute': log('info', `🔗 Conectando SSH: ${args.user}@${args.host}`); const sshResult = await executeSSHCommand( args.host, args.user, args.command, args.keyPath, args.timeout ); return { content: [ { type: 'text', text: `✅ SSH ejecutado en ${args.host}:\n\n${sshResult.output}` } ], isError: false, metadata: { tool: 'ssh_execute', host: args.host, user: args.user, exitCode: sshResult.exitCode, executionTime: sshResult.executionTime || sshResult.metadata?.executionTime, connectionPoolStatus: sshResult.metadata?.connectionPoolStatus, rateLimitInfo: { remainingRequests: rateLimiter.getRemainingRequests() }, timestamp: new Date().toISOString() } }; case 'powershell_execute': log('info', `⚡ Ejecutando PowerShell: ${args.command}`); const psResult = await executePowerShell(args.command, args.timeout); return { content: [ { type: 'text', text: `✅ PowerShell ejecutado:\n\n${psResult.output}` } ], isError: false, metadata: { tool: 'powershell_execute', exitCode: psResult.exitCode, executionTime: psResult.executionTime, rateLimitInfo: { remainingRequests: rateLimiter.getRemainingRequests() }, timestamp: new Date().toISOString() } }; case 'ssh_scan': log('info', `🔍 Escaneando red: ${args.target}`); // Validar formato de red/host if (!args.target || !/^[\d\w\.\-\/]+$/.test(args.target)) { throw new Error('Formato de target inválido. Use formato IP (192.168.1.1) o CIDR (192.168.1.0/24)'); } // Usar nmap si está disponible, sino usar ping básico let scanCommand; try { // Verificar si nmap está disponible await executePowerShell('nmap --version', 5000); scanCommand = `nmap -p 22 --open ${args.target}`; } catch (e) { // Fallback a escaneo básico con ping log('warn', 'nmap no disponible, usando escaneo básico'); if (args.target.includes('/')) { throw new Error('Escaneo de rango CIDR requiere nmap. Instale nmap o especifique un host individual.'); } scanCommand = `Test-NetConnection -ComputerName ${args.target} -Port 22 -WarningAction SilentlyContinue | Select-Object ComputerName, TcpTestSucceeded, RemotePort`; } const scanResult = await executePowerShell(scanCommand, args.timeout); return { content: [ { type: 'text', text: `🔍 Escaneo SSH de ${args.target}:\n\n${scanResult.output}` } ], isError: false, metadata: { tool: 'ssh_scan', target: args.target, exitCode: scanResult.exitCode, executionTime: scanResult.executionTime, rateLimitInfo: { remainingRequests: rateLimiter.getRemainingRequests() }, timestamp: new Date().toISOString() } }; case 'ssh_keyscan': log('info', `🔑 Obteniendo claves SSH: ${args.host}`); // Validar formato de host if (!args.host || !/^[\w\.\-]+$/.test(args.host)) { throw new Error('Formato de host inválido'); } const port = args.port || 22; const keyCommand = `ssh-keyscan -p ${port} ${args.host}`; const keyResult = await executePowerShell(keyCommand, args.timeout); return { content: [ { type: 'text', text: `🔑 Claves SSH de ${args.host}:${port}:\n\n${keyResult.output}` } ], isError: false, metadata: { tool: 'ssh_keyscan', host: args.host, port: port, exitCode: keyResult.exitCode, executionTime: keyResult.executionTime, rateLimitInfo: { remainingRequests: rateLimiter.getRemainingRequests() }, timestamp: new Date().toISOString() } }; default: throw new Error(`Herramienta desconocida: ${name}`); } } catch (error) { const executionTime = Date.now() - startTime; log('error', `❌ Error en ${name}`, { error: error.message, args, executionTime }); return { content: [ { type: 'text', text: `❌ Error ejecutando ${name}: ${error.message}` } ], isError: true, metadata: { tool: name, error: error.message, executionTime, rateLimitInfo: { remainingRequests: rateLimiter.getRemainingRequests() }, timestamp: new Date().toISOString(), args: args } }; } }); // Iniciar servidor con manejo de errores mejorado async function main() { try { // Verificar configuración crítica al inicio log('info', 'Iniciando SSH-PowerShell MCP Server...'); // Verificar disponibilidad de comandos críticos const checks = []; try { await executePowerShell('$PSVersionTable.PSVersion', 5000); checks.push('✅ PowerShell disponible'); } catch (e) { checks.push('❌ PowerShell no disponible'); throw new Error(`PowerShell no está disponible: ${e.message}`); } try { await executePowerShell('ssh -V', 5000); checks.push('✅ SSH client disponible'); } catch (e) { checks.push('⚠️ SSH client no disponible - funciones SSH deshabilitadas'); log('warn', 'SSH client no disponible, algunas funciones estarán limitadas'); } // Mostrar información de configuración log('info', 'Verificaciones del sistema:', checks); log('info', `Configuración cargada: timeout=${process.env.COMMAND_TIMEOUT || DEFAULT_CONFIG.COMMAND_TIMEOUT}ms, log_level=${process.env.LOG_LEVEL || DEFAULT_CONFIG.LOG_LEVEL}`); log('info', `Rate limiting: ${rateLimiter.maxRequests} requests/min, Connection pool: ${connectionPool.maxSize} conexiones`); log('info', `PowerShell executable: ${getPowerShellExecutable()}`); const transport = new StdioServerTransport(); await server.connect(transport); log('info', '🚀 SSH-PowerShell MCP Server iniciado correctamente'); // Configurar manejadores de señales para shutdown graceful process.on('SIGINT', () => { log('info', 'Recibida señal SIGINT, cerrando servidor...'); process.exit(0); }); process.on('SIGTERM', () => { log('info', 'Recibida señal SIGTERM, cerrando servidor...'); process.exit(0); }); } catch (error) { log('error', '❌ Error fatal al iniciar servidor', { error: error.message }); process.exit(1); } } main().catch((error) => { log('error', '❌ Error fatal no capturado', { error: error.message, stack: error.stack }); process.exit(1); });

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/GUEPARD98/MCP-POWERSHELL'

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