From 63b4d84c7b0baf72e742dc3353663fc5b756cb22 Mon Sep 17 00:00:00 2001 From: yuriy Date: Mon, 16 Mar 2026 13:53:52 -0700 Subject: [PATCH 1/4] Add optional OpenSSH alias mode --- README.md | 46 +++++- index.js | 312 ++++++++++++++++++++++++++++++++++++++++- lib/openssh-client.js | 82 +++++++++++ lib/remote-commands.js | 240 +++++++++++++++++++++++++++++++ 4 files changed, 672 insertions(+), 8 deletions(-) create mode 100644 lib/openssh-client.js create mode 100644 lib/remote-commands.js diff --git a/README.md b/README.md index 4e26ed4..433f854 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,34 @@ Add to your Claude Desktop MCP configuration file: **That's it!** Claude can now execute SSH commands on your remote servers. +### Optional OpenSSH Alias Mode +If you already have trusted hosts configured in `~/.ssh/config`, you can run the server in an alias-restricted mode that only allows approved SSH aliases and reuses your local OpenSSH configuration: + +```json +{ + "mcpServers": { + "ssh": { + "command": "npx", + "args": ["-y", "@idletoaster/ssh-mcp-server@latest"], + "env": { + "SSH_MCP_MODE": "openssh-alias", + "SSH_MCP_ALLOWED_ALIASES": "host123,staging-box" + } + } + } +} +``` + +In `openssh-alias` mode: +- Tools accept `hostAlias` instead of raw `host`, `user`, `port`, and `privateKeyPath` +- The server shells out to your local `ssh` binary, so `Host`, `User`, `IdentityFile`, `ProxyJump`, and known-hosts behavior come from your OpenSSH configuration +- Only aliases in `SSH_MCP_ALLOWED_ALIASES` are permitted + +Optional environment variables: +- `SSH_MCP_OPENSSH_BINARY` - path to the SSH binary (default: `ssh`) +- `SSH_MCP_OPENSSH_CONFIG` - explicit SSH config path (default: OpenSSH default lookup) +- `SSH_MCP_OPENSSH_TIMEOUT_MS` - per-command timeout in milliseconds (default: `300000`) + --- ## 💬 Usage Examples @@ -76,6 +104,18 @@ Once configured, Claude can help you with commands like: } ``` +Alias mode example: + +```json +{ + "tool": "remote-ssh", + "arguments": { + "hostAlias": "host123", + "command": "uname -a" + } +} +``` + --- ## 🔧 Configuration @@ -166,7 +206,9 @@ ssh-mcp-server/ ├── package.json # NPM configuration & dependencies ├── index.js # Main MCP server (Official SDK) ├── lib/ -│ └── ssh-client.js # SSH connection management +│ ├── ssh-client.js # Direct ssh2 connection management +│ ├── openssh-client.js # OpenSSH alias-mode transport +│ └── remote-commands.js# Shared remote command builders for alias mode ├── README.md # Documentation ├── LICENSE # MIT license └── .gitignore # Node.js gitignore @@ -176,6 +218,7 @@ ssh-mcp-server/ - **Runtime**: Node.js 18+ with ES Modules - **MCP SDK**: @modelcontextprotocol/sdk (Official) - **SSH**: ssh2 library for Node.js +- **Optional SSH Alias Mode**: System OpenSSH client for ~/.ssh/config host aliases - **Distribution**: NPM with direct NPX execution --- @@ -366,4 +409,3 @@ Enhanced with 4 powerful tools inspired by Desktop Commander for optimal token u } } ``` - diff --git a/index.js b/index.js index 7ae7cc1..6f0ac3a 100644 --- a/index.js +++ b/index.js @@ -9,14 +9,25 @@ import { McpError, } from '@modelcontextprotocol/sdk/types.js'; import { SSHClient } from './lib/ssh-client.js'; +import { OpenSSHClient } from './lib/openssh-client.js'; +import { + buildEditBlockCommand, + buildReadLinesCommand, + buildSearchCodeCommand, + buildWriteChunkCommand, + validateEditBlockArgs, + validateReadLinesArgs, + validateSearchCodeArgs, + validateWriteChunkArgs, +} from './lib/remote-commands.js'; /** - * SSH MCP Server v2.1.7 - Enhanced with Desktop Commander functionality + * SSH MCP Server v2.1.8 - Enhanced with Desktop Commander functionality * Enables AI assistants to execute SSH commands with token-efficient file operations * Built with official Model Context Protocol SDK */ -const SSH_TOOLS = [ +const DIRECT_SSH_TOOLS = [ { name: 'remote-ssh', description: 'Execute SSH commands on remote servers with private key authentication', @@ -125,12 +136,147 @@ const SSH_TOOLS = [ } ]; +const OPENSSH_ALIAS_TOOLS = [ + { + name: 'remote-ssh', + description: 'Execute SSH commands on approved OpenSSH host aliases using your local SSH configuration', + inputSchema: { + type: 'object', + properties: { + hostAlias: { + type: 'string', + description: 'Approved SSH host alias from your local ~/.ssh/config' + }, + command: { + type: 'string', + description: 'Command to execute on the remote host' + } + }, + required: ['hostAlias', 'command'] + } + }, + { + name: 'ssh-edit-block', + description: 'Edit specific text blocks in remote files via approved OpenSSH host aliases', + inputSchema: { + type: 'object', + properties: { + hostAlias: { type: 'string', description: 'Approved SSH host alias from your local ~/.ssh/config' }, + filePath: { type: 'string', description: 'Path to file on remote server' }, + oldText: { type: 'string', description: 'Text to find and replace' }, + newText: { type: 'string', description: 'Replacement text' }, + expectedReplacements: { type: 'number', description: 'Exact number of replacements required', optional: true, default: 1 } + }, + required: ['hostAlias', 'filePath', 'oldText', 'newText'] + } + }, + { + name: 'ssh-read-lines', + description: 'Read specific lines from remote files via approved OpenSSH host aliases', + inputSchema: { + type: 'object', + properties: { + hostAlias: { type: 'string', description: 'Approved SSH host alias from your local ~/.ssh/config' }, + filePath: { type: 'string', description: 'Path to file on remote server' }, + startLine: { type: 'number', description: 'Starting line number (1-based)', optional: true, default: 1 }, + endLine: { type: 'number', description: 'Ending line number (optional)', optional: true }, + maxLines: { type: 'number', description: 'Maximum lines to read', optional: true, default: 100 } + }, + required: ['hostAlias', 'filePath'] + } + }, + { + name: 'ssh-search-code', + description: 'Search for literal text patterns in remote files via approved OpenSSH host aliases', + inputSchema: { + type: 'object', + properties: { + hostAlias: { type: 'string', description: 'Approved SSH host alias from your local ~/.ssh/config' }, + path: { type: 'string', description: 'Directory path to search on remote server' }, + pattern: { type: 'string', description: 'Literal text pattern to search for' }, + filePattern: { type: 'string', description: 'File pattern (for example, "*.js")', optional: true }, + ignoreCase: { type: 'boolean', description: 'Case-insensitive search', optional: true, default: false }, + maxResults: { type: 'number', description: 'Maximum number of results', optional: true, default: 50 }, + contextLines: { type: 'number', description: 'Lines of context around matches', optional: true, default: 2 } + }, + required: ['hostAlias', 'path', 'pattern'] + } + }, + { + name: 'ssh-write-chunk', + description: 'Write content to remote files via approved OpenSSH host aliases', + inputSchema: { + type: 'object', + properties: { + hostAlias: { type: 'string', description: 'Approved SSH host alias from your local ~/.ssh/config' }, + filePath: { type: 'string', description: 'Path to file on remote server' }, + content: { type: 'string', description: 'Content to write' }, + mode: { type: 'string', description: 'Write mode: "rewrite" or "append"', enum: ['rewrite', 'append'], default: 'rewrite' } + }, + required: ['hostAlias', 'filePath', 'content'] + } + } +]; + +const SERVER_MODES = { + DIRECT: 'direct', + OPENSSH_ALIAS: 'openssh-alias', +}; + +function parseAllowedAliases(value) { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parsePositiveIntegerEnv(value, name) { + const parsed = Number.parseInt(value, 10); + + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${name} must be a positive integer`); + } + + return parsed; +} + +function getRuntimeConfig(env) { + const mode = env.SSH_MCP_MODE === SERVER_MODES.OPENSSH_ALIAS + ? SERVER_MODES.OPENSSH_ALIAS + : SERVER_MODES.DIRECT; + + if (mode === SERVER_MODES.OPENSSH_ALIAS) { + const allowedAliases = parseAllowedAliases(env.SSH_MCP_ALLOWED_ALIASES || ''); + + if (allowedAliases.length === 0) { + throw new Error( + 'SSH_MCP_ALLOWED_ALIASES must be set to a comma-separated allowlist when SSH_MCP_MODE=openssh-alias' + ); + } + + return { + mode, + allowedAliases: new Set(allowedAliases), + sshBinary: env.SSH_MCP_OPENSSH_BINARY || 'ssh', + sshConfigPath: env.SSH_MCP_OPENSSH_CONFIG || null, + sshTimeoutMs: parsePositiveIntegerEnv(env.SSH_MCP_OPENSSH_TIMEOUT_MS || '300000', 'SSH_MCP_OPENSSH_TIMEOUT_MS'), + toolDefinitions: OPENSSH_ALIAS_TOOLS, + }; + } + + return { + mode, + toolDefinitions: DIRECT_SSH_TOOLS, + }; +} + class SSHMCPServer { constructor() { + this.runtimeConfig = getRuntimeConfig(process.env); this.server = new Server( { name: 'ssh-mcp-server', - version: '2.1.7', + version: '2.1.8', }, { capabilities: { @@ -147,7 +293,7 @@ class SSHMCPServer { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { - tools: SSH_TOOLS, + tools: this.runtimeConfig.toolDefinitions, }; }); @@ -183,6 +329,54 @@ class SSHMCPServer { } async executeSSHCommand(args) { + if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { + const { hostAlias, command } = args; + + if (!hostAlias || !command) { + throw new Error('Missing required parameters: hostAlias, command'); + } + + this.assertAllowedAlias(hostAlias); + + const sshClient = new OpenSSHClient({ + sshBinary: this.runtimeConfig.sshBinary, + sshConfigPath: this.runtimeConfig.sshConfigPath, + timeoutMs: this.runtimeConfig.sshTimeoutMs, + }); + + try { + const result = await sshClient.executeCommand({ hostAlias, command }); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + output: result.output, + error: result.error || null, + exitCode: result.exitCode || 0, + hostAlias, + command, + }, null, 2) + }] + }; + } catch (error) { + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: false, + output: '', + error: error.message, + exitCode: 1, + hostAlias, + command, + }, null, 2) + }] + }; + } + } + const { host, user, command, privateKeyPath, port = 22 } = args; if (!host || !user || !command) { @@ -221,6 +415,27 @@ class SSHMCPServer { } async executeEditBlock(args) { + if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { + const { hostAlias } = args; + validateEditBlockArgs(args); + + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } + + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildEditBlockCommand(args); + const result = await sshClient.executeCommand({ hostAlias, command }); + + return { + content: [{ + type: 'text', + text: `Edit block completed on ${args.filePath}\n${result.output}\nExit code: ${result.exitCode}` + }] + }; + } + const { host, user, filePath, oldText, newText, expectedReplacements = 1, privateKeyPath, port = 22 } = args; if (!host || !user || !filePath || !oldText || !newText) { @@ -283,6 +498,27 @@ class SSHMCPServer { } async executeReadLines(args) { + if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { + const { hostAlias } = args; + validateReadLinesArgs(args); + + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } + + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildReadLinesCommand(args); + const result = await sshClient.executeCommand({ hostAlias, command }); + + return { + content: [{ + type: 'text', + text: result.output + }] + }; + } + const { host, user, filePath, startLine = 1, endLine, maxLines = 100, privateKeyPath, port = 22 } = args; if (!host || !user || !filePath) { @@ -335,6 +571,27 @@ class SSHMCPServer { } async executeSearchCode(args) { + if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { + const { hostAlias } = args; + validateSearchCodeArgs(args); + + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } + + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildSearchCodeCommand(args); + const result = await sshClient.executeCommand({ hostAlias, command }); + + return { + content: [{ + type: 'text', + text: result.output || 'No matches found' + }] + }; + } + const { host, user, path, pattern, filePattern, ignoreCase = false, maxResults = 50, contextLines = 2, privateKeyPath, port = 22 } = args; if (!host || !user || !path || !pattern) { @@ -386,6 +643,27 @@ class SSHMCPServer { } async executeWriteChunk(args) { + if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { + const { hostAlias } = args; + validateWriteChunkArgs(args); + + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } + + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildWriteChunkCommand(args); + const result = await sshClient.executeCommand({ hostAlias, command }); + + return { + content: [{ + type: 'text', + text: result.output + }] + }; + } + const { host, user, filePath, content, mode = 'rewrite', privateKeyPath, port = 22 } = args; if (!host || !user || !filePath || !content) { @@ -459,10 +737,32 @@ class SSHMCPServer { async start() { const transport = new StdioServerTransport(); await this.server.connect(transport); - console.error('SSH MCP Server v2.1.7 running on stdio (Enhanced with token-efficient tools)'); + console.error(`SSH MCP Server v2.1.8 running on stdio (${this.runtimeConfig.mode} mode)`); + } + + createOpenSSHClient() { + return new OpenSSHClient({ + sshBinary: this.runtimeConfig.sshBinary, + sshConfigPath: this.runtimeConfig.sshConfigPath, + timeoutMs: this.runtimeConfig.sshTimeoutMs, + }); + } + + assertAllowedAlias(hostAlias) { + if (typeof hostAlias !== 'string' || hostAlias.length === 0) { + throw new Error('hostAlias must be a non-empty string'); + } + + if (hostAlias.startsWith('-') || /\s/.test(hostAlias)) { + throw new Error('hostAlias must be a plain SSH alias without leading dashes or whitespace'); + } + + if (!this.runtimeConfig.allowedAliases?.has(hostAlias)) { + throw new Error(`hostAlias is not in the configured allowlist: ${hostAlias}`); + } } } // Start the server const server = new SSHMCPServer(); -server.start().catch(console.error); \ No newline at end of file +server.start().catch(console.error); diff --git a/lib/openssh-client.js b/lib/openssh-client.js new file mode 100644 index 0000000..be7c7d8 --- /dev/null +++ b/lib/openssh-client.js @@ -0,0 +1,82 @@ +import { spawn } from 'child_process'; + +const DEFAULT_COMMAND_TIMEOUT_MS = 300_000; + +export class OpenSSHClient { + constructor({ sshBinary = 'ssh', sshConfigPath = null, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = {}) { + this.sshBinary = sshBinary; + this.sshConfigPath = sshConfigPath; + this.timeoutMs = timeoutMs; + } + + async executeCommand({ hostAlias, command }) { + return new Promise((resolve, reject) => { + const args = []; + + if (this.sshConfigPath) { + args.push('-F', this.sshConfigPath); + } + + args.push( + '-o', 'BatchMode=yes', + '-o', 'ForwardAgent=no', + '-o', 'ClearAllForwardings=yes', + '-T', + hostAlias, + command + ); + + const child = spawn(this.sshBinary, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + let settled = false; + let timer = null; + + if (this.timeoutMs > 0) { + timer = setTimeout(() => { + cleanup(); + child.kill('SIGTERM'); + reject(new Error(`SSH command timed out after ${this.timeoutMs}ms`)); + }, this.timeoutMs); + } + + const cleanup = () => { + settled = true; + if (timer) { + clearTimeout(timer); + } + }; + + child.on('error', (error) => { + if (settled) { + return; + } + cleanup(); + reject(new Error(`OpenSSH execution failed: ${error.message}`)); + }); + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + if (settled) { + return; + } + cleanup(); + resolve({ + output: stdout.trim(), + error: stderr.trim() || null, + exitCode: code ?? 1, + }); + }); + }); + } +} diff --git a/lib/remote-commands.js b/lib/remote-commands.js new file mode 100644 index 0000000..5048318 --- /dev/null +++ b/lib/remote-commands.js @@ -0,0 +1,240 @@ +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; +} + +function ensureNonEmptyString(value, name) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Missing required parameter: ${name}`); + } + + return value; +} + +function ensurePositiveInteger(value, name, { allowUndefined = false } = {}) { + if (allowUndefined && (value === undefined || value === null)) { + return undefined; + } + + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer`); + } + + return value; +} + +function uniqueHereDocTag(content, prefix) { + let counter = 0; + let tag = `${prefix}_EOF_${counter}`; + + while (content.includes(tag)) { + counter += 1; + tag = `${prefix}_EOF_${counter}`; + } + + return tag; +} + +function buildHereDoc(content, prefix) { + const tag = uniqueHereDocTag(content, prefix); + return { tag, content }; +} + +export function validateReadLinesArgs({ filePath, startLine = 1, endLine, maxLines = 100 }) { + const safeFilePath = ensureNonEmptyString(filePath, 'filePath'); + const safeStartLine = ensurePositiveInteger(startLine, 'startLine'); + const safeEndLine = ensurePositiveInteger(endLine, 'endLine', { allowUndefined: true }); + const safeMaxLines = ensurePositiveInteger(maxLines, 'maxLines'); + + if (safeEndLine !== undefined && safeEndLine < safeStartLine) { + throw new Error('endLine must be greater than or equal to startLine'); + } + + return { + filePath: safeFilePath, + startLine: safeStartLine, + endLine: safeEndLine, + maxLines: safeMaxLines, + }; +} + +export function buildReadLinesCommand(args) { + const { filePath, startLine, endLine, maxLines } = validateReadLinesArgs(args); + const quotedFilePath = shellQuote(filePath); + const rangeLabel = endLine === undefined ? 'end' : String(endLine); + const readCommand = endLine === undefined + ? `sed -n '${startLine},$p' ${quotedFilePath} | head -n ${maxLines}` + : `sed -n '${startLine},${endLine}p' ${quotedFilePath}`; + + return ` +if [ ! -f ${quotedFilePath} ]; then + echo "Error: File ${filePath} not found" + exit 1 +fi + +total_lines=$(wc -l < ${quotedFilePath}) +echo "File: ${filePath} (\${total_lines} total lines)" +echo "Reading lines ${startLine}-${rangeLabel} (max ${maxLines})" +echo "--- Content ---" +${readCommand} +`.trim(); +} + +export function validateSearchCodeArgs({ path, pattern, filePattern, maxResults = 50, contextLines = 2 }) { + const safePath = ensureNonEmptyString(path, 'path'); + const safePattern = ensureNonEmptyString(pattern, 'pattern'); + const safeFilePattern = filePattern === undefined || filePattern === null + ? undefined + : ensureNonEmptyString(filePattern, 'filePattern'); + const safeMaxResults = ensurePositiveInteger(maxResults, 'maxResults'); + + if (!Number.isInteger(contextLines) || contextLines < 0) { + throw new Error('contextLines must be a non-negative integer'); + } + + return { + path: safePath, + pattern: safePattern, + filePattern: safeFilePattern, + maxResults: safeMaxResults, + contextLines, + }; +} + +export function buildSearchCodeCommand(args) { + const { path, pattern, filePattern, ignoreCase = false, maxResults, contextLines } = validateSearchCodeArgs(args); + const quotedPath = shellQuote(path); + const quotedPattern = shellQuote(pattern); + const findPattern = filePattern ? `-name ${shellQuote(filePattern)}` : '-type f'; + let grepOptions = '-Fn'; + + if (ignoreCase) { + grepOptions += 'i'; + } + + if (contextLines > 0) { + grepOptions += ` -C${contextLines}`; + } + + return ` +if [ ! -d ${quotedPath} ]; then + echo "Error: Directory ${path} not found" + exit 1 +fi + +echo "Searching in: ${path}" +echo "Pattern: ${pattern}" +echo "File pattern: ${filePattern || 'all files'}" +echo "--- Results ---" + +find ${quotedPath} ${findPattern} -exec grep ${grepOptions} -- ${quotedPattern} {} + 2>/dev/null | head -n ${maxResults} +`.trim(); +} + +export function validateWriteChunkArgs({ filePath, content, mode = 'rewrite' }) { + const safeFilePath = ensureNonEmptyString(filePath, 'filePath'); + const safeContent = ensureNonEmptyString(content, 'content'); + + if (mode !== 'rewrite' && mode !== 'append') { + throw new Error('mode must be "rewrite" or "append"'); + } + + return { + filePath: safeFilePath, + content: safeContent, + mode, + }; +} + +export function buildWriteChunkCommand(args) { + const { filePath, content, mode } = validateWriteChunkArgs(args); + const quotedFilePath = shellQuote(filePath); + const redirect = mode === 'append' ? '>>' : '>'; + const hereDoc = buildHereDoc(content, 'SSH_MCP_WRITE'); + + return ` +mkdir -p "$(dirname ${quotedFilePath})" + +cat <<'${hereDoc.tag}' ${redirect} ${quotedFilePath} +${hereDoc.content} +${hereDoc.tag} + +if [ $? -eq 0 ]; then + echo "Successfully wrote to ${filePath} (mode: ${mode})" + echo "File size: $(wc -c < ${quotedFilePath}) bytes" + echo "Line count: $(wc -l < ${quotedFilePath}) lines" +else + echo "Failed to write to ${filePath}" + exit 1 +fi +`.trim(); +} + +export function validateEditBlockArgs({ filePath, oldText, newText, expectedReplacements = 1 }) { + const safeFilePath = ensureNonEmptyString(filePath, 'filePath'); + const safeOldText = ensureNonEmptyString(oldText, 'oldText'); + const safeNewText = ensureNonEmptyString(newText, 'newText'); + const safeExpectedReplacements = ensurePositiveInteger(expectedReplacements, 'expectedReplacements'); + + return { + filePath: safeFilePath, + oldText: safeOldText, + newText: safeNewText, + expectedReplacements: safeExpectedReplacements, + }; +} + +export function buildEditBlockCommand(args) { + const { filePath, oldText, newText, expectedReplacements } = validateEditBlockArgs(args); + const quotedFilePath = shellQuote(filePath); + const encodedOldText = Buffer.from(oldText, 'utf8').toString('base64'); + const encodedNewText = Buffer.from(newText, 'utf8').toString('base64'); + + return ` +if [ ! -f ${quotedFilePath} ]; then + echo "Error: File ${filePath} not found" + exit 1 +fi + +if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN=python3 +elif command -v python >/dev/null 2>&1; then + PYTHON_BIN=python +else + echo "Error: python3 or python is required for ssh-edit-block" + exit 1 +fi + +"$PYTHON_BIN" - ${quotedFilePath} ${expectedReplacements} ${shellQuote(encodedOldText)} ${shellQuote(encodedNewText)} <<'PYTHON' +from pathlib import Path +import base64 +import shutil +import sys +import time + +file_path = Path(sys.argv[1]) +expected = int(sys.argv[2]) +old_text = base64.b64decode(sys.argv[3]).decode("utf-8") +new_text = base64.b64decode(sys.argv[4]).decode("utf-8") +content = file_path.read_text(encoding="utf-8") + +count = content.count(old_text) +print(f"Occurrences found: {count}") +print(f"Expected: {expected}") + +if count == 0: + print("Warning: Pattern not found in file") + sys.exit(2) + +if count != expected: + print(f"Error: Expected {expected} replacements but found {count}") + sys.exit(3) + +backup_path = file_path.with_name(f"{file_path.name}.backup.{int(time.time())}") +shutil.copy2(file_path, backup_path) +file_path.write_text(content.replace(old_text, new_text), encoding="utf-8") + +print(f"Backup created: {backup_path}") +print(f"Replacements made: {count}") +PYTHON +`.trim(); +} From 63d486590fa0d7de190349cab3e83604d5f81f81 Mon Sep 17 00:00:00 2001 From: yuriy Date: Wed, 18 Mar 2026 13:46:59 -0700 Subject: [PATCH 2/4] Improve SSH MCP lifecycle and logging --- index.js | 601 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 425 insertions(+), 176 deletions(-) diff --git a/index.js b/index.js index 6f0ac3a..247a313 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,10 @@ #!/usr/bin/env node +import { appendFileSync } from 'node:fs'; +import http from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { CallToolRequestSchema, ErrorCode, @@ -240,6 +243,26 @@ function parsePositiveIntegerEnv(value, name) { return parsed; } +function summarizeForLog(value, maxLength = 240) { + if (typeof value === 'string') { + return value.length > maxLength + ? `${value.slice(0, maxLength)}...` + : value; + } + + if (Array.isArray(value)) { + return value.map((item) => summarizeForLog(item, maxLength)); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, summarizeForLog(entry, maxLength)]) + ); + } + + return value; +} + function getRuntimeConfig(env) { const mode = env.SSH_MCP_MODE === SERVER_MODES.OPENSSH_ALIAS ? SERVER_MODES.OPENSSH_ALIAS @@ -273,6 +296,7 @@ function getRuntimeConfig(env) { class SSHMCPServer { constructor() { this.runtimeConfig = getRuntimeConfig(process.env); + this.logFile = process.env.SSH_MCP_LOG_FILE || null; this.server = new Server( { name: 'ssh-mcp-server', @@ -300,6 +324,11 @@ class SSHMCPServer { // Handle tool execution this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + this.logEvent('tool.call', { + name, + mode: this.runtimeConfig.mode, + args: summarizeForLog(args), + }); try { switch (name) { @@ -320,6 +349,11 @@ class SSHMCPServer { ); } } catch (error) { + this.logEvent('tool.error', { + name, + error: error.message, + args: summarizeForLog(args), + }); throw new McpError( ErrorCode.InternalError, `SSH operation failed: ${error.message}` @@ -328,52 +362,105 @@ class SSHMCPServer { }); } - async executeSSHCommand(args) { - if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { - const { hostAlias, command } = args; + createTextResult(text, isError = false) { + return { + content: [{ + type: 'text', + text, + }], + isError, + }; + } - if (!hostAlias || !command) { - throw new Error('Missing required parameters: hostAlias, command'); - } + createJsonResult(payload, isError = false) { + return this.createTextResult(JSON.stringify(payload, null, 2), isError); + } - this.assertAllowedAlias(hostAlias); + logEvent(event, payload = {}) { + const entry = JSON.stringify({ + ts: new Date().toISOString(), + event, + ...payload, + }); - const sshClient = new OpenSSHClient({ - sshBinary: this.runtimeConfig.sshBinary, - sshConfigPath: this.runtimeConfig.sshConfigPath, - timeoutMs: this.runtimeConfig.sshTimeoutMs, - }); + console.error(entry); + + if (!this.logFile) { + return; + } + + try { + appendFileSync(this.logFile, `${entry}\n`, 'utf8'); + } catch (error) { + console.error(`[MCP Log Error] ${error.message}`); + } + } + + normalizeCommandResult(result) { + return { + output: result.output || '', + error: result.error || null, + exitCode: result.exitCode ?? 1, + }; + } + + formatCommandDetails(result) { + const lines = []; + + if (result.output) { + lines.push(result.output); + } + + if (result.error) { + lines.push(result.error); + } + + lines.push(`Exit code: ${result.exitCode}`); + return lines.join('\n'); + } + + async executeSSHCommand(args) { + if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { + const { hostAlias, command } = args; try { - const result = await sshClient.executeCommand({ hostAlias, command }); - - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - output: result.output, - error: result.error || null, - exitCode: result.exitCode || 0, - hostAlias, - command, - }, null, 2) - }] - }; + if (!hostAlias || !command) { + throw new Error('Missing required parameters: hostAlias, command'); + } + this.assertAllowedAlias(hostAlias); + + const sshClient = new OpenSSHClient({ + sshBinary: this.runtimeConfig.sshBinary, + sshConfigPath: this.runtimeConfig.sshConfigPath, + timeoutMs: this.runtimeConfig.sshTimeoutMs, + }); + + const result = this.normalizeCommandResult(await sshClient.executeCommand({ hostAlias, command })); + this.logEvent('remote-ssh.result', { + hostAlias, + command: summarizeForLog(command), + exitCode: result.exitCode, + output: summarizeForLog(result.output), + error: summarizeForLog(result.error), + }); + + return this.createJsonResult({ + success: result.exitCode === 0, + output: result.output, + error: result.error, + exitCode: result.exitCode, + hostAlias, + command, + }, result.exitCode !== 0); } catch (error) { - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: false, - output: '', - error: error.message, - exitCode: 1, - hostAlias, - command, - }, null, 2) - }] - }; + return this.createJsonResult({ + success: false, + output: '', + error: error.message, + exitCode: 1, + hostAlias, + command, + }, true); } } @@ -386,54 +473,61 @@ class SSHMCPServer { const sshClient = new SSHClient(); try { - const result = await sshClient.executeCommand({ + const result = this.normalizeCommandResult(await sshClient.executeCommand({ host, user, command, privateKeyPath: privateKeyPath || process.env.SSH_PRIVATE_KEY, port + })); + this.logEvent('remote-ssh.result', { + host, + user, + command: summarizeForLog(command), + exitCode: result.exitCode, + output: summarizeForLog(result.output), + error: summarizeForLog(result.error), }); - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: true, - output: result.output, - error: result.error || null, - exitCode: result.exitCode || 0, - host, command - }, null, 2) - }] - }; + return this.createJsonResult({ + success: result.exitCode === 0, + output: result.output, + error: result.error, + exitCode: result.exitCode, + host, + command, + }, result.exitCode !== 0); } catch (error) { - return { - content: [{ - type: 'text', - text: JSON.stringify({ - success: false, output: '', error: error.message, exitCode: 1, host, command - }, null, 2) - }] - }; + return this.createJsonResult({ + success: false, + output: '', + error: error.message, + exitCode: 1, + host, + command, + }, true); } } async executeEditBlock(args) { if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { const { hostAlias } = args; - validateEditBlockArgs(args); - if (!hostAlias) { - throw new Error('Missing required parameter: hostAlias'); - } + try { + validateEditBlockArgs(args); - this.assertAllowedAlias(hostAlias); - const sshClient = this.createOpenSSHClient(); - const command = buildEditBlockCommand(args); - const result = await sshClient.executeCommand({ hostAlias, command }); + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } - return { - content: [{ - type: 'text', - text: `Edit block completed on ${args.filePath}\n${result.output}\nExit code: ${result.exitCode}` - }] - }; + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildEditBlockCommand(args); + const result = this.normalizeCommandResult(await sshClient.executeCommand({ hostAlias, command })); + + return this.createTextResult( + `${result.exitCode === 0 ? 'Edit block completed' : 'Edit block failed'} on ${args.filePath}\n${this.formatCommandDetails(result)}`, + result.exitCode !== 0 + ); + } catch (error) { + return this.createTextResult(`Edit block failed: ${error.message}`, true); + } } const { host, user, filePath, oldText, newText, expectedReplacements = 1, privateKeyPath, port = 22 } = args; @@ -477,46 +571,42 @@ class SSHMCPServer { const sshClient = new SSHClient(); try { - const result = await sshClient.executeCommand({ + const result = this.normalizeCommandResult(await sshClient.executeCommand({ host, user, command: sedCommand, privateKeyPath: privateKeyPath || process.env.SSH_PRIVATE_KEY, port - }); + })); - return { - content: [{ - type: 'text', - text: `Edit block completed on ${filePath}\n${result.output}\nExit code: ${result.exitCode}` - }] - }; + return this.createTextResult( + `${result.exitCode === 0 ? 'Edit block completed' : 'Edit block failed'} on ${filePath}\n${this.formatCommandDetails(result)}`, + result.exitCode !== 0 + ); } catch (error) { - return { - content: [{ - type: 'text', - text: `Edit block failed: ${error.message}` - }] - }; + return this.createTextResult(`Edit block failed: ${error.message}`, true); } } async executeReadLines(args) { if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { const { hostAlias } = args; - validateReadLinesArgs(args); - if (!hostAlias) { - throw new Error('Missing required parameter: hostAlias'); - } + try { + validateReadLinesArgs(args); - this.assertAllowedAlias(hostAlias); - const sshClient = this.createOpenSSHClient(); - const command = buildReadLinesCommand(args); - const result = await sshClient.executeCommand({ hostAlias, command }); + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } - return { - content: [{ - type: 'text', - text: result.output - }] - }; + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildReadLinesCommand(args); + const result = this.normalizeCommandResult(await sshClient.executeCommand({ hostAlias, command })); + + return this.createTextResult( + result.exitCode === 0 ? result.output : this.formatCommandDetails(result), + result.exitCode !== 0 + ); + } catch (error) { + return this.createTextResult(`Read lines failed: ${error.message}`, true); + } } const { host, user, filePath, startLine = 1, endLine, maxLines = 100, privateKeyPath, port = 22 } = args; @@ -550,46 +640,42 @@ class SSHMCPServer { const sshClient = new SSHClient(); try { - const result = await sshClient.executeCommand({ + const result = this.normalizeCommandResult(await sshClient.executeCommand({ host, user, command, privateKeyPath: privateKeyPath || process.env.SSH_PRIVATE_KEY, port - }); + })); - return { - content: [{ - type: 'text', - text: result.output - }] - }; + return this.createTextResult( + result.exitCode === 0 ? result.output : this.formatCommandDetails(result), + result.exitCode !== 0 + ); } catch (error) { - return { - content: [{ - type: 'text', - text: `Read lines failed: ${error.message}` - }] - }; + return this.createTextResult(`Read lines failed: ${error.message}`, true); } } async executeSearchCode(args) { if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { const { hostAlias } = args; - validateSearchCodeArgs(args); - if (!hostAlias) { - throw new Error('Missing required parameter: hostAlias'); - } + try { + validateSearchCodeArgs(args); - this.assertAllowedAlias(hostAlias); - const sshClient = this.createOpenSSHClient(); - const command = buildSearchCodeCommand(args); - const result = await sshClient.executeCommand({ hostAlias, command }); + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } - return { - content: [{ - type: 'text', - text: result.output || 'No matches found' - }] - }; + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildSearchCodeCommand(args); + const result = this.normalizeCommandResult(await sshClient.executeCommand({ hostAlias, command })); + + return this.createTextResult( + result.exitCode === 0 ? (result.output || 'No matches found') : this.formatCommandDetails(result), + result.exitCode !== 0 + ); + } catch (error) { + return this.createTextResult(`Search failed: ${error.message}`, true); + } } const { host, user, path, pattern, filePattern, ignoreCase = false, maxResults = 50, contextLines = 2, privateKeyPath, port = 22 } = args; @@ -622,46 +708,42 @@ class SSHMCPServer { const sshClient = new SSHClient(); try { - const result = await sshClient.executeCommand({ + const result = this.normalizeCommandResult(await sshClient.executeCommand({ host, user, command: searchCommand, privateKeyPath: privateKeyPath || process.env.SSH_PRIVATE_KEY, port - }); + })); - return { - content: [{ - type: 'text', - text: result.output || 'No matches found' - }] - }; + return this.createTextResult( + result.exitCode === 0 ? (result.output || 'No matches found') : this.formatCommandDetails(result), + result.exitCode !== 0 + ); } catch (error) { - return { - content: [{ - type: 'text', - text: `Search failed: ${error.message}` - }] - }; + return this.createTextResult(`Search failed: ${error.message}`, true); } } async executeWriteChunk(args) { if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { const { hostAlias } = args; - validateWriteChunkArgs(args); - if (!hostAlias) { - throw new Error('Missing required parameter: hostAlias'); - } + try { + validateWriteChunkArgs(args); - this.assertAllowedAlias(hostAlias); - const sshClient = this.createOpenSSHClient(); - const command = buildWriteChunkCommand(args); - const result = await sshClient.executeCommand({ hostAlias, command }); + if (!hostAlias) { + throw new Error('Missing required parameter: hostAlias'); + } - return { - content: [{ - type: 'text', - text: result.output - }] - }; + this.assertAllowedAlias(hostAlias); + const sshClient = this.createOpenSSHClient(); + const command = buildWriteChunkCommand(args); + const result = this.normalizeCommandResult(await sshClient.executeCommand({ hostAlias, command })); + + return this.createTextResult( + result.exitCode === 0 ? result.output : this.formatCommandDetails(result), + result.exitCode !== 0 + ); + } catch (error) { + return this.createTextResult(`Write chunk failed: ${error.message}`, true); + } } const { host, user, filePath, content, mode = 'rewrite', privateKeyPath, port = 22 } = args; @@ -698,28 +780,25 @@ class SSHMCPServer { const sshClient = new SSHClient(); try { - const result = await sshClient.executeCommand({ + const result = this.normalizeCommandResult(await sshClient.executeCommand({ host, user, command, privateKeyPath: privateKeyPath || process.env.SSH_PRIVATE_KEY, port - }); + })); - return { - content: [{ - type: 'text', - text: result.output - }] - }; + return this.createTextResult( + result.exitCode === 0 ? result.output : this.formatCommandDetails(result), + result.exitCode !== 0 + ); } catch (error) { - return { - content: [{ - type: 'text', - text: `Write chunk failed: ${error.message}` - }] - }; + return this.createTextResult(`Write chunk failed: ${error.message}`, true); } } setupErrorHandling() { this.server.onerror = (error) => { + if (process.stdin.isTTY && error instanceof SyntaxError) { + return; + } + console.error('[MCP Error]', error); }; @@ -734,10 +813,180 @@ class SSHMCPServer { }); } + setupStdioLifecycle(transport) { + if (process.env.SSH_MCP_TRANSPORT === 'sse') { + return; + } + + let shuttingDown = false; + + const shutdown = async (reason) => { + if (shuttingDown) { + return; + } + + shuttingDown = true; + + try { + console.error(`SSH MCP Server shutting down: ${reason}`); + await this.server.close(); + } catch (error) { + console.error('[MCP Shutdown Error]', error); + } finally { + process.exit(0); + } + }; + + transport.onclose = () => { + void shutdown('transport closed'); + }; + + process.stdin.on('end', () => { + void shutdown('stdin ended'); + }); + + process.stdin.on('close', () => { + void shutdown('stdin closed'); + }); + + process.stdout.on('error', (error) => { + if (error?.code === 'EPIPE') { + void shutdown('stdout pipe closed'); + } + }); + } + async start() { - const transport = new StdioServerTransport(); - await this.server.connect(transport); - console.error(`SSH MCP Server v2.1.8 running on stdio (${this.runtimeConfig.mode} mode)`); + const transportMode = process.env.SSH_MCP_TRANSPORT || 'stdio'; + + if (transportMode === 'sse') { + await this.startSSE(); + } else { + const transport = new StdioServerTransport(); + this.setupStdioLifecycle(transport); + await this.server.connect(transport); + this.logEvent('server.start', { + transport: 'stdio', + mode: this.runtimeConfig.mode, + }); + console.error(`SSH MCP Server v2.1.8 running on stdio (${this.runtimeConfig.mode} mode)`); + if (process.stdin.isTTY) { + console.error('Interactive stdin detected; this server expects newline-delimited JSON-RPC messages on stdin.'); + } + } + } + + async startSSE() { + const port = parseInt(process.env.SSH_MCP_PORT || '3579', 10); + const host = process.env.SSH_MCP_HOST || '127.0.0.1'; + + // Track active SSE transports by session ID + const transports = new Map(); + + const httpServer = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + // CORS headers for local clients + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204).end(); + return; + } + + if (req.method === 'GET' && url.pathname === '/sse') { + // New SSE connection — create a fresh Server+transport pair + const server = this.createServerInstance(); + const transport = new SSEServerTransport('/messages', res); + transports.set(transport.sessionId, { server, transport }); + + transport.onclose = () => { + transports.delete(transport.sessionId); + server.close().catch(() => {}); + }; + + await server.connect(transport); + this.logEvent('sse.client_connected', { + sessionId: transport.sessionId, + }); + console.error(`SSE client connected: ${transport.sessionId}`); + return; + } + + if (req.method === 'POST' && url.pathname === '/messages') { + const sessionId = url.searchParams.get('sessionId'); + const entry = transports.get(sessionId); + + if (!entry) { + res.writeHead(404).end('Unknown session'); + return; + } + + await entry.transport.handlePostMessage(req, res); + return; + } + + // Health check + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', mode: this.runtimeConfig.mode, sessions: transports.size })); + return; + } + + res.writeHead(404).end('Not found'); + }); + + httpServer.listen(port, host, () => { + this.logEvent('server.start', { + transport: 'sse', + mode: this.runtimeConfig.mode, + url: `http://${host}:${port}/sse`, + }); + console.error(`SSH MCP Server v2.1.8 running on SSE http://${host}:${port}/sse (${this.runtimeConfig.mode} mode)`); + }); + } + + createServerInstance() { + const server = new Server( + { name: 'ssh-mcp-server', version: '2.1.8' }, + { capabilities: { tools: {} } } + ); + + // Re-register handlers on this new instance + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: this.runtimeConfig.toolDefinitions }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + switch (name) { + case 'remote-ssh': + return await this.executeSSHCommand(args); + case 'ssh-edit-block': + return await this.executeEditBlock(args); + case 'ssh-read-lines': + return await this.executeReadLines(args); + case 'ssh-search-code': + return await this.executeSearchCode(args); + case 'ssh-write-chunk': + return await this.executeWriteChunk(args); + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); + } + } catch (error) { + throw new McpError(ErrorCode.InternalError, `SSH operation failed: ${error.message}`); + } + }); + + server.onerror = (error) => { + console.error('[MCP Error]', error); + }; + + return server; } createOpenSSHClient() { From 11eb068ba8be36c67786cdce23110ff146164248 Mon Sep 17 00:00:00 2001 From: yuriy Date: Tue, 7 Apr 2026 00:12:53 -0700 Subject: [PATCH 3/4] Document macOS Local Network TCC workaround Covers the EHOSTUNREACH-only-on-LAN symptom, the private-node-copy fix that gives the server its own TCC identity, and the rpath/codesign maintenance steps for Homebrew node upgrades. Co-Authored-By: Claude Opus 4.6 (1M context) --- MACOS_LOCAL_NETWORK.md | 124 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 MACOS_LOCAL_NETWORK.md diff --git a/MACOS_LOCAL_NETWORK.md b/MACOS_LOCAL_NETWORK.md new file mode 100644 index 0000000..f6e1943 --- /dev/null +++ b/MACOS_LOCAL_NETWORK.md @@ -0,0 +1,124 @@ +# macOS Local Network Permission for ssh-mcp-server + +## Symptom + +SSH calls made through the MCP fail with: + +``` +ssh: connect to host 192.168.0.11 port 22: No route to host +``` + +...even though `ssh hass` from Terminal works fine, ping works, and the port is reachable. + +The failures hit only hosts on the **Mac's own LAN subnet** (e.g. 192.168.0.x). Hosts reached +through a gateway (192.168.37.x via pfSense) work, because that traffic leaves via the default +route and doesn't trip the local-network check. + +## Root cause + +Since macOS Sonoma, every process that wants to talk to devices on its own LAN needs the +**Local Network** privacy entitlement (System Settings → Privacy & Security → Local Network). +Processes launched by `launchd` as LaunchAgents cannot show UI prompts, so the grant must either +be pre-existing or triggered by a foreground launch. + +Denied access returns `EHOSTUNREACH` (rendered as "No route to host") — not "Permission denied", +which is why this is easy to misdiagnose as a networking problem. + +## Previous (too broad) fix + +Toggling `node` ON in Local Network settings works, but it grants LAN access to **every** Node.js +process on the Mac, not just ssh-mcp-server. That's more permission than we want. + +## Scoped fix: give the server its own binary identity + +macOS TCC attributes Local Network grants by signed binary (cdhash) and, for LaunchAgents, by the +plist's `Label`. By launching the MCP server via a **private copy of the Node binary** with its own +ad-hoc signature, we get a distinct TCC identity that appears in the Privacy UI as +`ssh-mcp-server` rather than `node`. + +### Steps performed + +1. Create `~/bin/` and copy the real Node binary (resolving the Homebrew symlink): + + ```bash + mkdir -p ~/bin + cp "$(readlink -f /opt/homebrew/bin/node)" ~/bin/ssh-mcp-server + ``` + +2. Homebrew's `node` is a thin launcher that loads `libnode.141.dylib` via an `@loader_path/../lib` + rpath, which won't resolve from `~/bin/`. Add an absolute rpath to the Cellar lib dir: + + ```bash + install_name_tool -add_rpath \ + /opt/homebrew/Cellar/node//lib \ + ~/bin/ssh-mcp-server + ``` + + `install_name_tool` invalidates the code signature, so re-sign ad-hoc: + + ```bash + codesign --force --sign - ~/bin/ssh-mcp-server + ``` + +3. Verify the copy still runs: + + ```bash + ~/bin/ssh-mcp-server --version + ``` + +4. Point the LaunchAgent at the new binary. Edit + `~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist`: + + ```xml + ProgramArguments + + /Users/yuriy/bin/ssh-mcp-server + /Users/yuriy/git/ssh-mcp-server/index.js + + ``` + +5. Reload the agent: + + ```bash + launchctl unload ~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist + launchctl load ~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist + ``` + +6. Confirm the running process is the renamed binary: + + ```bash + ps aux | grep ssh-mcp-server | grep -v grep + # → /Users/yuriy/bin/ssh-mcp-server /Users/yuriy/git/ssh-mcp-server/index.js + ``` + +7. Trigger an SSH call through MCP. If macOS hasn't seen this binary before, a Local Network + prompt should appear for **ssh-mcp-server** — click Allow. + + In practice, because the LaunchAgent `Label` (`com.idletoaster.ssh-mcp-server`) is unchanged, + macOS kept the pre-existing grant and no prompt was needed. + +8. Open **System Settings → Privacy & Security → Local Network**. Confirm `ssh-mcp-server` is + listed and enabled. The broad `node` entry can be toggled OFF (use the MCP to test a LAN host + like `hass` afterward to verify isolation). + +## Maintenance + +- **Homebrew node upgrades do NOT propagate** to `~/bin/ssh-mcp-server`. It's a frozen copy. When + the server is upgraded (or if something breaks), re-run steps 1–3 and reload the LaunchAgent. +- **The hardcoded rpath** (`/opt/homebrew/Cellar/node//lib`) breaks when the Cellar + version directory changes on upgrade. Re-copy or `install_name_tool -rpath `. +- **Ad-hoc signing is sufficient** for user-scope LaunchAgents; no notarization needed. + +## Diagnostic quick reference + +| Symptom | Meaning | +|---|---| +| `ssh: connect to host X port 22: No route to host` via MCP, but Terminal SSH works | macOS Local Network privacy gate | +| Fails for 192.168.0.x, works for 192.168.37.x | Same subnet = local net check; routed = no check | +| No entry for `ssh-mcp-server` in Privacy UI | Binary has never attempted local network from a prompt-capable context | +| Error is "Permission denied" not "No route to host" | NOT this bug — check SSH keys / config | + +## Files touched + +- `~/Library/LaunchAgents/com.idletoaster.ssh-mcp-server.plist` (ProgramArguments[0]) +- `~/bin/ssh-mcp-server` (new, ad-hoc signed copy of node) From d6e01b348be4c0b00fb1d4d2fe7ca5ab54768805 Mon Sep 17 00:00:00 2001 From: yuriy Date: Tue, 19 May 2026 10:47:24 -0700 Subject: [PATCH 4/4] Add scp_file tool for SCP file transfers Adds a new scp_file tool to both DIRECT and OPENSSH_ALIAS modes, allowing upload/download between local and remote machines via spawn (not exec, to avoid shell injection). Supports recursive transfers and respects the existing key/port/alias-allowlist config. Co-Authored-By: Claude Sonnet 4.6 --- index.js | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/index.js b/index.js index 247a313..7507572 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ import { appendFileSync } from 'node:fs'; import http from 'node:http'; +import { spawn } from 'node:child_process'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; @@ -136,6 +137,24 @@ const DIRECT_SSH_TOOLS = [ }, required: ['host', 'user', 'filePath', 'content'] } + }, + { + name: 'scp_file', + description: 'Copy files between local machine and remote server via SCP', + inputSchema: { + type: 'object', + properties: { + host: { type: 'string', description: 'Remote server hostname or IP address' }, + user: { type: 'string', description: 'SSH username' }, + localPath: { type: 'string', description: 'Path on the local machine' }, + remotePath: { type: 'string', description: 'Path on the remote server' }, + direction: { type: 'string', description: 'Transfer direction: "upload" (local → remote) or "download" (remote → local)', enum: ['upload', 'download'] }, + recursive: { type: 'boolean', description: 'Copy directories recursively', optional: true, default: false }, + privateKeyPath: { type: 'string', description: 'Path to SSH private key (optional)', optional: true }, + port: { type: 'number', description: 'SSH port (default: 22)', optional: true, default: 22 } + }, + required: ['host', 'user', 'localPath', 'remotePath', 'direction'] + } } ]; @@ -218,6 +237,21 @@ const OPENSSH_ALIAS_TOOLS = [ }, required: ['hostAlias', 'filePath', 'content'] } + }, + { + name: 'scp_file', + description: 'Copy files between local machine and remote server via SCP using approved OpenSSH host aliases', + inputSchema: { + type: 'object', + properties: { + hostAlias: { type: 'string', description: 'Approved SSH host alias from your local ~/.ssh/config' }, + localPath: { type: 'string', description: 'Path on the local machine' }, + remotePath: { type: 'string', description: 'Path on the remote server' }, + direction: { type: 'string', description: 'Transfer direction: "upload" (local → remote) or "download" (remote → local)', enum: ['upload', 'download'] }, + recursive: { type: 'boolean', description: 'Copy directories recursively', optional: true, default: false } + }, + required: ['hostAlias', 'localPath', 'remotePath', 'direction'] + } } ]; @@ -342,6 +376,8 @@ class SSHMCPServer { return await this.executeSearchCode(args); case 'ssh-write-chunk': return await this.executeWriteChunk(args); + case 'scp_file': + return await this.executeScpFile(args); default: throw new McpError( ErrorCode.MethodNotFound, @@ -793,6 +829,136 @@ class SSHMCPServer { } } + async executeScpFile(args) { + const { localPath, remotePath, direction, recursive = false } = args; + + if (!localPath || !remotePath || !direction) { + throw new Error('Missing required parameters: localPath, remotePath, direction'); + } + + return new Promise((resolve) => { + let scpArgs; + + if (this.runtimeConfig.mode === SERVER_MODES.OPENSSH_ALIAS) { + const { hostAlias } = args; + + try { + this.assertAllowedAlias(hostAlias); + } catch (error) { + resolve(this.createTextResult(`SCP failed: ${error.message}`, true)); + return; + } + + const baseArgs = []; + + if (this.runtimeConfig.sshConfigPath) { + baseArgs.push('-F', this.runtimeConfig.sshConfigPath); + } + + baseArgs.push('-o', 'BatchMode=yes', '-o', 'ForwardAgent=no'); + + if (recursive) { + baseArgs.push('-r'); + } + + const remoteSide = `${hostAlias}:${remotePath}`; + scpArgs = direction === 'upload' + ? [...baseArgs, localPath, remoteSide] + : [...baseArgs, remoteSide, localPath]; + + this.logEvent('scp_file.start', { hostAlias, localPath, remotePath, direction, recursive }); + + const child = spawn('scp', scpArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + + const timer = this.runtimeConfig.sshTimeoutMs > 0 + ? setTimeout(() => { child.kill('SIGTERM'); }, this.runtimeConfig.sshTimeoutMs) + : null; + + child.on('close', (code) => { + if (timer) clearTimeout(timer); + const success = code === 0; + this.logEvent('scp_file.result', { hostAlias, direction, exitCode: code }); + resolve(this.createJsonResult({ + success, + output: stdout.trim(), + error: stderr.trim() || null, + exitCode: code ?? 1, + hostAlias, + localPath, + remotePath, + direction, + }, !success)); + }); + + child.on('error', (error) => { + if (timer) clearTimeout(timer); + resolve(this.createTextResult(`SCP failed: ${error.message}`, true)); + }); + + return; + } + + // DIRECT mode + const { host, user, privateKeyPath, port = 22 } = args; + + if (!host || !user) { + resolve(this.createTextResult('Missing required parameters: host, user', true)); + return; + } + + const keyPath = privateKeyPath || process.env.SSH_PRIVATE_KEY; + const baseArgs = []; + + if (keyPath) { + baseArgs.push('-i', keyPath); + } + + baseArgs.push('-P', String(port), '-o', 'BatchMode=yes', '-o', 'ForwardAgent=no', '-o', 'StrictHostKeyChecking=accept-new'); + + if (recursive) { + baseArgs.push('-r'); + } + + const remoteSide = `${user}@${host}:${remotePath}`; + scpArgs = direction === 'upload' + ? [...baseArgs, localPath, remoteSide] + : [...baseArgs, remoteSide, localPath]; + + this.logEvent('scp_file.start', { host, user, localPath, remotePath, direction, recursive }); + + const child = spawn('scp', scpArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (d) => { stdout += d.toString(); }); + child.stderr.on('data', (d) => { stderr += d.toString(); }); + + child.on('close', (code) => { + const success = code === 0; + this.logEvent('scp_file.result', { host, direction, exitCode: code }); + resolve(this.createJsonResult({ + success, + output: stdout.trim(), + error: stderr.trim() || null, + exitCode: code ?? 1, + host, + localPath, + remotePath, + direction, + }, !success)); + }); + + child.on('error', (error) => { + resolve(this.createTextResult(`SCP failed: ${error.message}`, true)); + }); + }); + } + setupErrorHandling() { this.server.onerror = (error) => { if (process.stdin.isTTY && error instanceof SyntaxError) { @@ -974,6 +1140,8 @@ class SSHMCPServer { return await this.executeSearchCode(args); case 'ssh-write-chunk': return await this.executeWriteChunk(args); + case 'scp_file': + return await this.executeScpFile(args); default: throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); }