Skip to main content
Glama

MCP Server: SSH Rails Runner

by tadasant
sshRailsClient.ts6.51 kB
import { NodeSSH } from "node-ssh"; import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; export class SSHRailsClient { private ssh: NodeSSH; private connected: boolean; private workingDir: string; constructor() { this.ssh = new NodeSSH(); this.connected = false; this.workingDir = ""; } async connect(config: { host: string; username: string; workingDir: string; privateKeyPath?: string; password?: string; }): Promise<void> { try { await this.ssh.connect(config); // Check if directory exists before storing it const checkDir = await this.ssh.execCommand( `test -d "${config.workingDir}" && echo "exists"` ); if (!checkDir.stdout.includes("exists")) { throw new Error( `Working directory '${config.workingDir}' does not exist` ); } this.workingDir = config.workingDir; this.connected = true; } catch (error) { this.connected = false; throw new Error(`Failed to connect to Rails server: ${error}`); } } private async executeCommand(localFilePath: string): Promise<string> { if (!this.connected) { throw new Error("Not connected to Rails server"); } // Use timestamp and random string for unique filename const uniqueSuffix = Date.now() + "_" + Math.random().toString(36).slice(2); const remoteFileName = `runner_${uniqueSuffix}.rb`; const remoteFilePath = `/tmp/${remoteFileName}`; // Use /tmp for temporary files // Add a clear delimiter for our output const OUTPUT_DELIMITER = "===RAILS_OUTPUT_DELIMITER_" + Math.random().toString(36).slice(2) + "==="; try { // 1. SCP the file to the remote server's /tmp directory await this.ssh.putFile(localFilePath, remoteFilePath); // 2. Execute the file using rails runner // Ensure proper quoting for paths that might contain spaces const command = `cd "${this.workingDir}" && echo "${OUTPUT_DELIMITER}" && RAILS_ENV=production bundle exec rails runner "${remoteFilePath}" && echo "${OUTPUT_DELIMITER}"`; const result = await this.ssh.execCommand(command); // Add debug logging console.error("Command execution result:", { code: result.code, stdout: result.stdout, stderr: result.stderr, }); // If there's a real error (non-zero exit code), throw it // stderr might contain Rails stack traces which are useful if (result.code !== 0) { // Attempt to parse stderr for a cleaner error message if possible let errorMessage = `Command failed with exit code ${result.code}.`; if (result.stderr) { errorMessage += `\nSTDERR: ${result.stderr}`; } // Include stdout as it might contain partial output or clues if (result.stdout) { errorMessage += `\nSTDOUT: ${result.stdout}`; } throw new Error(errorMessage); } return result.stdout; } catch (error) { // Rethrow any error caught during SCP or execution throw new Error(`Failed during remote execution: ${error}`); } finally { // 3. Clean up the remote file regardless of success or failure try { // Ensure proper quoting for path await this.ssh.execCommand(`rm "${remoteFilePath}"`); } catch (cleanupError) { console.error( `Failed to clean up remote file ${remoteFilePath}:`, cleanupError ); // Log cleanup error but don't throw, as the primary operation might have succeeded/failed already } } } async execute(filePath: string): Promise<string> { const result = await this.executeCommand(filePath); return this.parseResult(result); } async verifyReadOnly(code: string): Promise<boolean> { // TODO: this is unreliable, could be better with sampling // List of keywords that indicate mutations // const mutationKeywords = [ // "update", // "delete", // "destroy", // "save", // "create", // "insert", // "alter", // "drop", // ]; // // Check for mutation keywords // const containsMutation = mutationKeywords.some((keyword) => // code.toLowerCase().includes(keyword) // ); // if (containsMutation) { // return false; // } // Additional analysis could be performed here return true; } async executeReadOnly(filePath: string): Promise<string> { // Note: With rails runner, we lose the sandbox guarantee provided by `rails c`. // Any mutation WILL be executed. The distinction is now purely semantic // for the caller, indicating intent rather than enforcement. const result = await this.executeCommand(filePath); return this.parseResult(result); } async executeMutate(filePath: string): Promise<string> { // Assumes caller (executeCodeSnippetMutate tool) has confirmed user intent. const result = await this.executeCommand(filePath); return this.parseResult(result); } private parseResult(result: string): string { try { // Find the first delimiter and take everything after it up to the last delimiter const delimiterRegex = /===RAILS_OUTPUT_DELIMITER_[a-z0-9]+===/g; const matches = [...result.matchAll(delimiterRegex)]; if (matches.length >= 2) { const startIndex = matches[0].index! + matches[0][0].length; const endIndex = matches[matches.length - 1].index!; let output = result.substring(startIndex, endIndex).trim(); // Attempt to clean common Rails inspection outputs like leading/trailing quotes if (output.startsWith('\"') && output.endsWith('\"')) { // It looks like a string inspect output, try to unescape it try { // Replace escaped quotes and backslashes output = output .slice(1, -1) .replace(/\\"/g, '"') .replace(/\\\\/g, '\\'); } catch (e) { // If unescaping fails, just return the sliced string console.warn("Failed to unescape string output:", e); output = output.slice(1, -1); } } else if (output === "nil") { return ""; // Represent nil as an empty string } // TODO: Potentially handle other inspect formats like arrays, hashes if needed return output; } else { // If delimiters aren't found as expected, return the trimmed raw output console.warn("Could not find expected delimiters in output. Raw:", result); return result.trim(); } } catch (error) { console.error("Parse error:", error); console.error("Raw result:", result); // If anything goes wrong, return the original trimmed string return result.trim(); } } async disconnect(): Promise<void> { if (this.connected) { await this.ssh.dispose(); this.connected = false; } } }

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/tadasant/mcp-server-ssh-rails-runner'

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