UNPKG

@light-merlin-dark/vssh

Version:

MCP-native SSH proxy for AI agents. CLI & MCP Server, plugin system, AI safety guards.

372 lines (370 loc) 14.5 kB
#!/usr/bin/env node "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const zod_1 = require("zod"); const fs = __importStar(require("fs")); const ssh_1 = require("./services/ssh"); const command_guard_service_1 = require("./services/command-guard-service"); const proxy_service_1 = require("./services/proxy-service"); const config_1 = require("./config"); const plugins_1 = require("./plugins"); const docker_1 = __importDefault(require("./plugins/builtin/docker")); const coolify_1 = __importDefault(require("./plugins/builtin/coolify")); const proxy_1 = __importDefault(require("./plugins/builtin/proxy")); const grafana_1 = __importDefault(require("./plugins/builtin/grafana")); const file_editor_1 = __importDefault(require("./plugins/builtin/file-editor")); // 1. Boot the CLI in "server mode" const server = new mcp_js_1.McpServer({ name: "vssh", version: "1.3.0", description: "AI-friendly SSH proxy with plugin support and safety guard (MCP tool)" }); // Initialize plugin system let registry = null; let commandGuard = null; // Build dynamic MCP description from enabled plugins function buildRunCommandDescription() { let description = `SSH command to execute. Examples: BASIC USAGE: • vssh ls -la # Simple command • vssh "docker ps -a" # Command with args • vssh 'ps aux | grep node' # Pipes (use single quotes) CORE COMMANDS: • vssh --help # Show help • vssh local-mode on # Enable local execution • vssh local-mode off # Disable local execution`; if (registry) { const enabledPlugins = registry.getEnabledPlugins(); for (const plugin of enabledPlugins) { if (plugin.mcpContext && plugin.mcpContext.commands.length > 0) { description += `\n\n${plugin.mcpContext.section}:`; for (const cmd of plugin.mcpContext.commands) { description += `\n• ${cmd.command.padEnd(28)} # ${cmd.description}`; } } } } description += `\n\nDangerous commands are automatically blocked for safety.`; return description; } async function initializePlugins(config) { const logger = { info: (msg) => console.error(`[INFO] ${msg}`), warn: (msg) => console.error(`[WARN] ${msg}`), error: (msg) => console.error(`[ERROR] ${msg}`), debug: (msg) => console.error(`[DEBUG] ${msg}`) }; const sshService = new ssh_1.SSHService(config); commandGuard = new command_guard_service_1.CommandGuardService(); const proxyService = new proxy_service_1.ProxyService(config, sshService, commandGuard); registry = new plugins_1.PluginRegistry(sshService, commandGuard, config, logger, proxyService, false); // Load built-in plugins await registry.loadPlugin(proxy_1.default); await registry.loadPlugin(docker_1.default); await registry.loadPlugin(coolify_1.default); await registry.loadPlugin(grafana_1.default); await registry.loadPlugin(file_editor_1.default); // Apply command guard extensions commandGuard.addExtensions(registry.getCommandGuardExtensions()); // Register plugin tools with MCP for (const plugin of registry.getEnabledPlugins()) { for (const command of plugin.commands) { if (command.mcpName) { // Register tool with MCP server server.tool(command.mcpName, { args: zod_1.z.array(zod_1.z.string()).default([]).describe("Command arguments") }, async ({ args = [] }) => { const positional = [...args]; // 1️⃣ safe clone const parsedArgs = { _: [command.name, ...positional], ...parseFlags(positional) // 2️⃣ mutate the clone, not the original }; try { // Create a string buffer to capture output let output = ''; const captureLogger = { ...logger, info: (msg) => { output += msg + '\n'; }, warn: (msg) => { output += msg + '\n'; }, error: (msg) => { output += msg + '\n'; }, debug: (msg) => { output += msg + '\n'; } }; // Override console.log to capture output const originalLog = console.log; const originalError = console.error; console.log = (...args) => { output += args.join(' ') + '\n'; }; console.error = (...args) => { output += args.join(' ') + '\n'; }; const context = { sshService, commandGuard: commandGuard, config, logger: captureLogger, proxyService: registry.context.proxyService, isLocalExecution: registry.context.isLocalExecution, getPlugin: (name) => registry.getPlugin(name) }; // Validate input if schema is provided let validatedArgs = parsedArgs; if (command.inputSchema) { try { validatedArgs = command.inputSchema.parse(parsedArgs); } catch (error) { return { isError: true, content: [{ type: "text", text: `Input validation error: ${error.message}` }] }; } } await command.handler(context, validatedArgs); // Restore console console.log = originalLog; console.error = originalError; return { content: [{ type: "text", text: output.trim() || "Command completed successfully" }] }; } catch (error) { return { isError: true, content: [{ type: "text", text: `Plugin command error: ${error.message}` }] }; } }); } } } } function parseFlags(args) { const flags = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith('--')) { const key = arg.slice(2); const next = args[i + 1]; if (next && !next.startsWith('-')) { flags[key] = next; i++; } else { flags[key] = true; } } } return flags; } // 2. === TOOLS ================================================================ // Define the run_command tool handler separately so we can access it const runCommandHandler = async ({ command }) => { // Initialize plugins if not already done if (!registry || !commandGuard) { let cfg = (0, config_1.loadConfig)(); if (!cfg) { return { isError: true, content: [{ type: "text", text: "No SSH configuration found. Please run 'vssh --setup' first." }] }; } await initializePlugins(cfg); } // Guard rails const guard = commandGuard.checkCommand(command); // Collect any warnings const warnings = guard.reasons.filter(reason => reason.startsWith('⚠️')); if (guard.isBlocked) return { isError: true, content: [ { type: "text", text: `Command blocked – ${guard.reasons.join(", ")}` + `\nRule: ${guard.rule}` } ] }; // Config (load or interactive first-run) let cfg = (0, config_1.loadConfig)(); if (!cfg) { return { isError: true, content: [ { type: "text", text: "No SSH configuration found. Please run 'vssh --setup' first." } ] }; } const ssh = new ssh_1.SSHService(cfg); const start = Date.now(); try { const output = await ssh.executeCommand(command); const responseText = warnings.length > 0 ? `${warnings.join('\n')}\n\n${output.trim() || "(no stdout/stderr)"}` : output.trim() || "(no stdout/stderr)"; return { content: [ { type: "text", text: responseText } ], annotations: { durationMs: Date.now() - start, ...(warnings.length > 0 && { warnings: warnings.length }) } }; } catch (error) { return { isError: true, content: [ { type: "text", text: `SSH execution error: ${error.message}` } ], annotations: { durationMs: Date.now() - start } }; } }; // Function to get the command schema with dynamic description function getRunCommandSchema() { return { command: zod_1.z.string().min(1, "command is required").describe(buildRunCommandDescription()) }; } /** Tool: run_command – execute a *single* shell command on the remote host */ server.tool("run_command", getRunCommandSchema(), runCommandHandler); /** Tool: get_local_mode – returns the current local mode status from the config */ server.tool("get_local_mode", {}, async () => { try { const cfg = (0, config_1.loadConfig)(); if (!cfg) { return { isError: true, content: [{ type: "text", text: "No SSH configuration found. Please run 'vssh --setup' first." }] }; } return { content: [{ type: "text", text: `Local mode is currently ${cfg.localMode ? 'enabled' : 'disabled'}` }] }; } catch (error) { return { isError: true, content: [{ type: "text", text: `Error reading configuration: ${error.message}` }] }; } }); /** Tool: set_local_mode – sets the local mode in the config and saves it */ server.tool("set_local_mode", { enabled: zod_1.z.boolean().describe("Whether to enable or disable local mode") }, async ({ enabled }) => { try { const cfg = (0, config_1.loadConfig)(); if (!cfg) { return { isError: true, content: [{ type: "text", text: "No SSH configuration found. Please run 'vssh --setup' first." }] }; } // Update the config cfg.localMode = enabled; // Save the updated config to disk fs.writeFileSync(config_1.CONFIG_PATH, JSON.stringify(cfg, null, 2)); return { content: [{ type: "text", text: `Local mode has been ${enabled ? 'enabled' : 'disabled'} successfully` }] }; } catch (error) { return { isError: true, content: [{ type: "text", text: `Error updating configuration: ${error.message}` }] }; } }); // 3. Start the server over stdio (Claude's recommended default) async function main() { // Load config and initialize plugins const cfg = (0, config_1.loadConfig)(); if (cfg) { await initializePlugins(cfg); } await server.connect(new stdio_js_1.StdioServerTransport()); } main().catch((error) => { console.error("MCP Server error:", error); process.exit(1); }); //# sourceMappingURL=mcp-server.js.map