@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
JavaScript
;
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