#!/usr/bin/env python3 """SSH MCP server (JSON-RPC 2.0 over stdio). Exposes SSH tools that operate on remote hosts with **the same output format** as Skald's native filesystem tools (`read_file`, `list_files`, `grep_files`, `edit_file`, `replace_lines`, `exec`). The only thing the LLM sees differently is the first `alias` argument selecting the host. Tool names here are bare (`read_file`, `exec`, …); Skald prepends the `mcp__ssh__` prefix automatically. Hosts are addressed by alias — hostname and credentials never appear in tool calls. Aliases live in ``secrets/ssh_aliases.json`` (auto-managed, never edited by hand). No secret is ever stored in that file. Login auth (``auth`` per alias, set on ``add_alias``): * ``key`` — SSH key / ssh-agent only (default). If the chosen private key is encrypted, its passphrase is requested on demand via **MCP elicitation** (lazy: only when paramiko reports the key needs one). ``SSH_MCP_KEY_PASSPHRASE`` still works as a non-interactive override. * ``password`` — login password requested on demand via **MCP elicitation** (Skald shows a masked field in the Agent Inbox); agent/key auth is skipped. Elicited login secrets are kept only in this process's RAM with a short TTL (``SSH_MCP_LOGIN_PW_TTL``), never sent to the LLM and never written to disk; they are dropped on an authentication failure so the next attempt re-prompts. sudo (two methods per alias, set on ``add_alias``): * ``nopasswd`` — ``sudo -n``: non-interactive, fails fast if NOPASSWD is not configured on the host (no hung channel). No secret stored anywhere. * ``prompt`` — ``sudo -S``: the password is requested on demand via **MCP elicitation** (Skald shows a masked field in the Agent Inbox), fed to sudo's stdin, kept only in this process's RAM with a short TTL, never sent to the LLM and never written to disk. Connections are pooled per alias with lazy TTL eviction. Host keys are verified against ``~/.ssh/known_hosts`` (unknown hosts are rejected unless the alias was added with ``accept_new_host_key=true``). Run with: python3 scripts/ssh_mcp_server.py Dependency: paramiko>=3.4 (in requirements.txt; installed into .venv by run.sh). """ from __future__ import annotations import itertools import json import os import posixpath import re import shlex import socket import stat import sys import time from typing import Any # ── Config ─────────────────────────────────────────────────────────────────── _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ALIASES_FILE = os.path.join(_ROOT, "secrets", "ssh_aliases.json") POOL_TTL = int(os.environ.get("SSH_MCP_POOL_TTL", "300")) # idle connection eviction SUDO_PW_TTL = int(os.environ.get("SSH_MCP_SUDO_PW_TTL", "300")) # in-RAM sudo password cache LOGIN_PW_TTL = int(os.environ.get("SSH_MCP_LOGIN_PW_TTL", "300")) # in-RAM login/passphrase cache CONNECT_TIMEOUT = int(os.environ.get("SSH_MCP_CONNECT_TIMEOUT", "15")) DEFAULT_CMD_TIMEOUT = int(os.environ.get("SSH_MCP_COMMAND_TIMEOUT", "120")) # Mirror the native list_files skip set so remote listings match local ones. SKIP_DIRS = {"target", ".git", "node_modules", ".venv", "__pycache__", "secrets"} # Match the native read_file cap. MAX_READ_LINES = 2000 def log(msg: str) -> None: """Log to stderr; stdout is reserved for JSON-RPC.""" print(f"[ssh_mcp] {msg}", file=sys.stderr, flush=True) class ToolError(Exception): """Expected, user-facing failure. Surfaced as ``Error: ``.""" # ── stdio JSON-RPC I/O (single readline path so elicit() can re-enter) ───────── def send(obj: dict) -> None: sys.stdout.write(json.dumps(obj) + "\n") sys.stdout.flush() def readline() -> dict | None: """Blocking read of one non-empty JSON-RPC message; None on EOF.""" while True: line = sys.stdin.readline() if not line: return None line = line.strip() if not line: continue try: return json.loads(line) except json.JSONDecodeError as e: log(f"invalid JSON input: {e}") continue _eid = itertools.count(1) def elicit(message: str, requested_schema: dict) -> dict: """Send an ``elicitation/create`` request and block until the reply arrives. Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}). While waiting, any other inbound message is ignored (v1: serial processing). """ eid = f"ssh-elicit-{next(_eid)}" send({ "jsonrpc": "2.0", "id": eid, "method": "elicitation/create", "params": {"message": message, "requestedSchema": requested_schema}, }) while True: msg = readline() if msg is None: return {"action": "cancel"} if msg.get("id") == eid: return msg.get("result", {"action": "cancel"}) log(f"ignoring inbound while awaiting elicitation: {msg.get('method') or msg.get('id')}") def _ok(req_id: Any, result: Any) -> dict: return {"jsonrpc": "2.0", "id": req_id, "result": result} def _text_result(req_id: Any, text: str, is_error: bool = False) -> dict: res: dict = {"content": [{"type": "text", "text": text}]} if is_error: res["isError"] = True return {"jsonrpc": "2.0", "id": req_id, "result": res} # ── Alias store (auto-managed, 0600) ─────────────────────────────────────────── def _load_aliases() -> dict: try: with open(ALIASES_FILE) as f: return json.load(f) except FileNotFoundError: return {"aliases": []} except Exception as e: log(f"failed to read aliases: {e}") return {"aliases": []} def _save_aliases(data: dict) -> None: os.makedirs(os.path.dirname(ALIASES_FILE), exist_ok=True) tmp = f"{ALIASES_FILE}.tmp.{os.getpid()}" with open(tmp, "w") as f: json.dump(data, f, indent=2) os.replace(tmp, ALIASES_FILE) try: os.chmod(ALIASES_FILE, 0o600) except OSError: pass def _find_alias(name: str) -> dict | None: for a in _load_aliases().get("aliases", []): if a.get("alias") == name: return a return None # ── Connection pool (paramiko) ───────────────────────────────────────────────── _pool: dict[str, dict] = {} # alias -> {client, sftp, last_used} _sudo_pw_cache: dict[str, tuple] = {} # alias -> (password, ts) _login_pw_cache: dict[str, tuple] = {} # "alias:login" | "alias:passphrase" -> (secret, ts) def _login_password(alias: str, kind: str = "login") -> str | None: """Return the SSH login password (``kind="login"``) or private-key passphrase (``kind="passphrase"``) for ``alias`` from the RAM cache, or elicit it. Never persisted. Returns None if the user declines/cancels/times out. """ now = time.time() key = f"{alias}:{kind}" cached = _login_pw_cache.get(key) if cached and (now - cached[1] <= LOGIN_PW_TTL): return cached[0] if kind == "passphrase": message = f"Enter the passphrase for the private key of SSH alias '{alias}'." title = f"key passphrase — {alias}" else: message = f"Enter the SSH login password for alias '{alias}'." title = f"SSH password — {alias}" result = elicit( message, { "type": "object", "properties": { "password": {"type": "string", "format": "password", "title": title} }, "required": ["password"], }, ) if result.get("action") == "accept": pw = (result.get("content") or {}).get("password", "") _login_pw_cache[key] = (pw, now) return pw return None def _clear_login_pw(alias: str) -> None: """Drop any cached login password / passphrase for ``alias``.""" for k in [k for k in _login_pw_cache if k.startswith(f"{alias}:")]: _login_pw_cache.pop(k, None) def _is_auth_failure(paramiko, e: Exception) -> bool: """True if ``e`` is an SSH auth rejection a login password could resolve. ``AuthenticationException`` (wrong/refused key) always qualifies. A plain ``SSHException`` qualifies only when its message says paramiko had no method to try — e.g. a password-only host with no key/agent: *"No authentication methods available"*. Other SSH errors (banner, host key, protocol) do not. """ if isinstance(e, paramiko.AuthenticationException): return True msg = str(e).lower() return "authentication method" in msg or "no authentication" in msg def _require_paramiko(): try: import paramiko # type: ignore return paramiko except ImportError: raise ToolError( "paramiko not installed — add 'paramiko>=3.4' to requirements.txt " "and reinstall the .venv (uv pip install -r requirements.txt)." ) def _connect(cfg: dict, paramiko): alias = cfg.get("alias", "") auth = (cfg.get("auth") or "key").lower() identity = cfg.get("identity_file") identity = os.path.expanduser(identity) if identity else None password = None if auth == "password": password = _login_password(alias, "login") if password is None: raise ToolError( f"login password required for alias '{alias}' (user declined or timed out)" ) def attempt(passphrase): # With a password in hand, skip agent/key probing so paramiko goes # straight to password auth instead of failing on keys first. use_pw = password is not None client = paramiko.SSHClient() client.load_system_host_keys() known = os.path.expanduser("~/.ssh/known_hosts") if os.path.exists(known): try: client.load_host_keys(known) except Exception as e: log(f"could not load known_hosts: {e}") if cfg.get("accept_new_host_key"): client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) else: client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect( hostname=cfg["hostname"], port=int(cfg.get("port", 22)), username=cfg.get("username"), password=password, key_filename=identity, passphrase=passphrase, allow_agent=not use_pw, look_for_keys=not use_pw, timeout=CONNECT_TIMEOUT, ) return client passphrase = os.environ.get("SSH_MCP_KEY_PASSPHRASE") or None try: return attempt(passphrase) except paramiko.PasswordRequiredException: # Encrypted private key with no passphrase supplied — ask for it (lazy). if passphrase is not None: raise # we already had one and it was rejected; don't loop passphrase = _login_password(alias, "passphrase") if passphrase is None: raise ToolError( f"key passphrase required for alias '{alias}' (user declined or timed out)" ) return attempt(passphrase) except (paramiko.AuthenticationException, paramiko.SSHException) as e: # Key/agent auth was rejected, or the host offers no method paramiko # could try (e.g. a password-only host: "No authentication methods # available"). If we haven't tried a password yet, elicit one and retry. # Declining re-raises the original error. Covers aliases left as the # default auth=key that actually need a login password. if password is not None or not _is_auth_failure(paramiko, e): raise password = _login_password(alias, "login") if password is None: raise return attempt(passphrase) def _close(alias: str) -> None: entry = _pool.pop(alias, None) if not entry: return try: if entry.get("sftp"): entry["sftp"].close() except Exception: pass try: entry["client"].close() except Exception: pass def _get_client(alias: str): cfg = _find_alias(alias) if not cfg: raise ToolError(f"unknown alias '{alias}'") paramiko = _require_paramiko() now = time.time() entry = _pool.get(alias) if entry: t = entry["client"].get_transport() if (now - entry["last_used"] <= POOL_TTL) and t is not None and t.is_active(): entry["last_used"] = now return entry["client"] _close(alias) try: client = _connect(cfg, paramiko) except paramiko.AuthenticationException: _clear_login_pw(alias) # wrong password/passphrase → re-prompt next time raise ToolError(f"authentication failed for alias '{alias}' (check key/agent/password)") except paramiko.BadHostKeyException: raise ToolError( f"host key mismatch for alias '{alias}' (possible MITM) — fix ~/.ssh/known_hosts" ) except paramiko.SSHException as e: if "not found in known_hosts" in str(e): raise ToolError( f"unknown host key for alias '{alias}' — re-add it with " f"accept_new_host_key=true to trust it on first connect" ) raise ToolError(f"SSH error for alias '{alias}': {e}") except (OSError, socket.error) as e: raise ToolError(f"connection to alias '{alias}' failed: {e}") _pool[alias] = {"client": client, "sftp": None, "last_used": now} return client def _get_sftp(alias: str): client = _get_client(alias) entry = _pool[alias] if entry.get("sftp") is None: entry["sftp"] = client.open_sftp() return entry["sftp"] def _run_with_stdin(client, command: str, timeout: int, stdin_data: str | None = None): """Run a remote command; return (stdout, stderr, exit_code). Raises on timeout.""" try: chan_in, chan_out, chan_err = client.exec_command(command, timeout=timeout) if stdin_data is not None: try: chan_in.write(stdin_data) chan_in.flush() except Exception: pass out = chan_out.read().decode("utf-8", "replace") err = chan_err.read().decode("utf-8", "replace") code = chan_out.channel.recv_exit_status() return out, err, code except socket.timeout: raise ToolError(f"command timed out after {timeout}s") # ── sudo ─────────────────────────────────────────────────────────────────────── def _sudo_password(alias: str) -> str | None: """Return the sudo password for ``alias`` from RAM cache, or elicit it. Never persisted. Returns None if the user declines/cancels/times out. """ now = time.time() cached = _sudo_pw_cache.get(alias) if cached and (now - cached[1] <= SUDO_PW_TTL): return cached[0] result = elicit( f"Enter the sudo password for SSH alias '{alias}'.", { "type": "object", "properties": { "password": { "type": "string", "format": "password", "title": f"sudo password — {alias}", } }, "required": ["password"], }, ) if result.get("action") == "accept": pw = (result.get("content") or {}).get("password", "") _sudo_pw_cache[alias] = (pw, now) return pw return None def _sudo_prefix(alias: str, cfg: dict, sudo_user: str | None): """Build the sudo prefix for ``cfg``. Returns (prefix, stdin_password). Raises ToolError when sudo is disabled or the password is unavailable. """ method = (cfg.get("sudo") or {}).get("method", "prompt") u = f"-u {shlex.quote(sudo_user)} " if sudo_user else "" if method == "none": raise ToolError(f"sudo is disabled for alias '{alias}'") if method == "nopasswd": return f"sudo -n {u}", None pw = _sudo_password(alias) if pw is None: raise ToolError("sudo password required (user declined or timed out)") return f"sudo -S -p '' {u}", pw # ── SFTP helpers ─────────────────────────────────────────────────────────────── def _sftp_read_text(sftp, path: str) -> str: with sftp.open(path, "r") as f: data = f.read() return data.decode("utf-8", "replace") if isinstance(data, (bytes, bytearray)) else data def _sftp_write_atomic(sftp, path: str, content: str) -> None: """Write atomically: temp file in the same dir + posix_rename. Preserve mode.""" d = posixpath.dirname(path) or "." base = posixpath.basename(path) tmp = posixpath.join(d, f".{base}.tmp.{os.getpid()}") mode = None try: mode = stat.S_IMODE(sftp.stat(path).st_mode) except IOError: pass with sftp.open(tmp, "w") as f: f.write(content) if mode is not None: try: sftp.chmod(tmp, mode) except IOError: pass try: sftp.posix_rename(tmp, path) except (IOError, AttributeError): try: sftp.remove(path) except IOError: pass sftp.rename(tmp, path) def _sftp_mkdirs(sftp, d: str) -> None: if not d or d in ("/", "."): return try: sftp.stat(d) return except IOError: pass parent = posixpath.dirname(d) if parent and parent != d: _sftp_mkdirs(sftp, parent) try: sftp.mkdir(d) except IOError: pass def _relpath(root: str, full: str) -> str: r = root.rstrip("/") or "/" return posixpath.relpath(full, r) # ── Tools: aliases ───────────────────────────────────────────────────────────── def _tool_list_aliases(args: dict) -> str: out = [] for a in _load_aliases().get("aliases", []): out.append({ "alias": a.get("alias"), "hostname": a.get("hostname"), "port": a.get("port", 22), "username": a.get("username"), "auth": a.get("auth", "key"), "sudo_method": (a.get("sudo") or {}).get("method", "prompt"), "description": a.get("description", ""), }) return json.dumps(out, indent=2) def _tool_add_alias(args: dict) -> str: name = args.get("alias") if not name: return "Error: missing required argument: alias" if not args.get("hostname"): return "Error: missing required argument: hostname" sudo = args.get("sudo") method = sudo.get("method") if isinstance(sudo, dict) else (sudo or "prompt") if method not in ("nopasswd", "prompt", "none"): return f"Error: invalid sudo method '{method}' (use nopasswd|prompt|none)" auth = (args.get("auth") or "key").lower() if auth not in ("key", "password"): return f"Error: invalid auth method '{auth}' (use key|password)" entry = { "alias": name, "hostname": args["hostname"], "port": int(args.get("port", 22)), "username": args.get("username"), "identity_file": args.get("identity_file"), "description": args.get("description", ""), "auth": auth, "sudo": {"method": method}, "accept_new_host_key": bool(args.get("accept_new_host_key", False)), } data = _load_aliases() aliases = data.setdefault("aliases", []) prev = None for i, a in enumerate(aliases): if a.get("alias") == name: prev = a aliases[i] = entry break else: aliases.append(entry) _save_aliases(data) _close(name) # config may have changed — drop any pooled connection _sudo_pw_cache.pop(name, None) _clear_login_pw(name) target = f"{entry.get('username')}@{entry['hostname']}:{entry['port']}" if prev: return f"Updated alias '{name}' → {target} (auth: {auth}, sudo: {method})." return f"Added alias '{name}' → {target} (auth: {auth}, sudo: {method})." def _tool_remove_alias(args: dict) -> str: name = args.get("alias") if not name: return "Error: missing required argument: alias" data = _load_aliases() aliases = data.get("aliases", []) kept = [a for a in aliases if a.get("alias") != name] if len(kept) == len(aliases): return f"Error: alias '{name}' not found" data["aliases"] = kept _save_aliases(data) _close(name) _sudo_pw_cache.pop(name, None) _clear_login_pw(name) return f"Removed alias '{name}'." # ── Tools: filesystem (native output format) ─────────────────────────────────── def _tool_read_file(args: dict) -> str: alias, path = args.get("alias"), args.get("path") if not alias or not path: return "Error: 'alias' and 'path' are required" sftp = _get_sftp(alias) try: content = _sftp_read_text(sftp, path) except IOError as e: raise ToolError(f"cannot read {path}: {e}") lines = content.splitlines() total = len(lines) limit = args.get("limit") limit = min(int(limit), MAX_READ_LINES) if limit is not None else None start = max(int(args["start_line"]) - 1, 0) if args.get("start_line") is not None else 0 if args.get("end_line") is not None: end = min(int(args["end_line"]), total) elif limit is not None: end = min(start + limit, total) else: end = total if start >= total and total > 0: return f"(file has only {total} lines; start_line {start + 1} is out of range)" end = max(end, start) width = max(len(str(total)), 3) return "\n".join( f"{start + i + 1:>{width}} | {line}" for i, line in enumerate(lines[start:end]) ) def _tool_list_files(args: dict) -> str: alias, path = args.get("alias"), args.get("path") if not alias or not path: return "Error: 'alias' and 'path' are required" max_depth = int(args.get("depth", 3)) dirs_only = bool(args.get("dirs_only", False)) sftp = _get_sftp(alias) out: list[str] = [] def walk(d: str, depth: int) -> None: try: entries = sftp.listdir_attr(d) except IOError: return for a in entries: full = posixpath.join(d, a.filename) if stat.S_ISDIR(a.st_mode): if a.filename in SKIP_DIRS: continue if dirs_only: out.append(_relpath(path, full)) if depth + 1 < max_depth: walk(full, depth + 1) elif stat.S_ISREG(a.st_mode) and not dirs_only: out.append(_relpath(path, full)) try: sftp.listdir_attr(path) except IOError as e: raise ToolError(f"cannot list {path}: {e}") walk(path, 0) out.sort() return json.dumps(out) def _grep_flags(args: dict) -> str: flags = "" if not bool(args.get("case_sensitive", False)): flags += "-i " inc = args.get("include_glob") if inc: flags += f"--include={shlex.quote(inc)} " return flags def _tool_grep_files(args: dict) -> str: alias, path, pattern = args.get("alias"), args.get("path"), args.get("pattern") if not alias or not path or pattern is None: return "Error: 'alias', 'path' and 'pattern' are required" mode = args.get("output_mode", "content") ctx = min(int(args.get("context_lines", 0) or 0), 10) maxr = int(args.get("max_results", 100)) client = _get_client(alias) flags = _grep_flags(args) qpat, qpath = shlex.quote(pattern), shlex.quote(path) root_prefix = path.rstrip("/") + "/" def rel(p: str) -> str: return p[len(root_prefix):] if p.startswith(root_prefix) else p if mode == "files_only": cmd = f"grep -rlIZ {flags}-E -e {qpat} -- {qpath}" out, err, code = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) if code >= 2 and not out: raise ToolError(err.strip() or "grep failed") files = [rel(f) for f in out.split("\0") if f][:maxr] if not files: return f'No files match "{pattern}" in {path}.' return f"{len(files)} file(s):\n" + "\n".join(files) if mode == "count": cmd = f"grep -rcI {flags}-E -e {qpat} -- {qpath}" out, err, code = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) if code >= 2 and not out: raise ToolError(err.strip() or "grep failed") items = [] for line in out.splitlines(): f, _, c = line.rpartition(":") # rpartition: count is numeric at end if f and c.isdigit() and int(c) > 0: items.append((rel(f), int(c))) items = items[:maxr] if not items: return f'No matches for "{pattern}" in {path}.' return f"{len(items)} file(s):\n" + "\n".join(f"{f}: {c}" for f, c in items) # content mode cflag = f"-C {ctx} " if ctx else "" cmd = f"grep -rnIZ {cflag}{flags}-E -e {qpat} -- {qpath}" out, err, code = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) if code >= 2 and not out: raise ToolError(err.strip() or "grep failed") entries: list[str] = [] if ctx == 0: for line in out.split("\n"): if not line: continue if "\0" in line: f, _, rest = line.partition("\0") else: f, _, rest = line.partition(":") lineno, _, body = rest.partition(":") entries.append(f"{rel(f)}:{lineno}: {body}") if len(entries) >= maxr: break else: prev_file = None for line in out.split("\n"): if not line: continue if line == "--": if prev_file is not None and len(entries) < maxr: entries.append(f"{rel(prev_file)}:---") continue if "\0" in line: f, _, rest = line.partition("\0") else: f, _, rest = line.partition(":") m = re.match(r"(\d+)([:-])(.*)$", rest, re.S) if not m: continue lineno, sep, body = m.group(1), m.group(2), m.group(3) marker = ">" if sep == ":" else " " entries.append(f"{marker}{rel(f)}: {lineno}: {body}") prev_file = f if len(entries) >= maxr: break if not entries: return f'No matches for "{pattern}" in {path}.' return f"{len(entries)} match(es):\n" + "\n".join(entries) def _tool_edit_file(args: dict) -> str: alias, path = args.get("alias"), args.get("path") old, new = args.get("old"), args.get("new") if not alias or not path: return "Error: 'alias' and 'path' are required" if old is None or new is None: return "Error: 'old' and 'new' are required" replace_all = bool(args.get("replace_all", False)) sftp = _get_sftp(alias) try: content = _sftp_read_text(sftp, path) except IOError as e: raise ToolError(f"cannot read {path}: {e}") not_found = ( f"Error: Text not found in {path}. " f"Call read_file first and copy the text exactly as shown after the '| ' prefix." ) if replace_all: if old not in content: return not_found updated = content.replace(old, new) else: cnt = content.count(old) if cnt > 1: return ( f"Error: Text found {cnt} times in {path}. " f"Include more surrounding context in `old` to make it unique, " f"or set replace_all=true." ) if cnt == 0: return not_found updated = content.replace(old, new, 1) _sftp_write_atomic(sftp, path, updated) return f"Edited {path}." def _tool_replace_lines(args: dict) -> str: alias, path = args.get("alias"), args.get("path") if not alias or not path: return "Error: 'alias' and 'path' are required" if args.get("from_line") is None or args.get("to_line") is None or args.get("new") is None: return "Error: 'from_line', 'to_line' and 'new' are required" from_line = int(args["from_line"]) to_line = int(args["to_line"]) new = args["new"] if from_line < 1: return "Error: from_line must be >= 1" if to_line < from_line: return "Error: to_line must be >= from_line" sftp = _get_sftp(alias) try: content = _sftp_read_text(sftp, path) except IOError as e: raise ToolError(f"cannot read {path}: {e}") lines = content.splitlines() total = len(lines) if from_line > total: return f"Error: from_line {from_line} exceeds file length ({total} lines)" to_clamped = min(to_line, total) new_lines = new.splitlines() lines[from_line - 1:to_clamped] = new_lines updated = "\n".join(lines) if content.endswith("\n"): updated += "\n" _sftp_write_atomic(sftp, path, updated) return f"Replaced lines {from_line}–{to_clamped} in {path} with {len(new_lines)} new lines." # ── Tools: exec / sudo / systemd ──────────────────────────────────────────────── def _tool_exec(args: dict) -> str: alias, command = args.get("alias"), args.get("command") if not alias or command is None: return "Error: 'alias' and 'command' are required" sudo = bool(args.get("sudo", False)) sudo_user = args.get("sudo_user") timeout = int(args.get("timeout_sec", DEFAULT_CMD_TIMEOUT)) cfg = _find_alias(alias) if not cfg: return f"Error: unknown alias '{alias}'" pw = None wrapped = command if sudo: prefix, pw = _sudo_prefix(alias, cfg, sudo_user) wrapped = prefix + command client = _get_client(alias) try: chan_in, chan_out, chan_err = client.exec_command(wrapped, timeout=timeout) if pw is not None: try: chan_in.write(pw + "\n") chan_in.flush() except Exception: pass out = chan_out.read().decode("utf-8", "replace") err = chan_err.read().decode("utf-8", "replace") code = chan_out.channel.recv_exit_status() except socket.timeout: return f"Error: command timed out after {timeout}s" return json.dumps({"stdout": out, "stderr": err, "exit_code": code}) def _tool_systemd(args: dict) -> str: alias, service, action = args.get("alias"), args.get("service"), args.get("action") if not alias or not service or not action: return "Error: 'alias', 'service' and 'action' are required" allowed = {"status", "start", "stop", "restart", "reload", "enable", "disable"} if action not in allowed: return f"Error: invalid action '{action}' (allowed: {', '.join(sorted(allowed))})" cfg = _find_alias(alias) if not cfg: return f"Error: unknown alias '{alias}'" qsvc = shlex.quote(service) client = _get_client(alias) parts: list[str] = [] if action != "status": prefix, pw = _sudo_prefix(alias, cfg, None) out, err, code = _run_with_stdin( client, f"{prefix}systemctl {action} {qsvc}", DEFAULT_CMD_TIMEOUT, (pw + "\n") if pw else None, ) parts.append(f"$ systemctl {action} {service} (exit {code})") if out.strip(): parts.append(out.strip()) if err.strip(): parts.append(err.strip()) status, _, _ = _run_with_stdin( client, f"systemctl status {qsvc} --no-pager 2>&1 | head -n 20", DEFAULT_CMD_TIMEOUT) parts.append("── status ──") parts.append(status.strip()) journal, _, _ = _run_with_stdin( client, f"journalctl -u {qsvc} -n 10 --no-pager 2>&1", DEFAULT_CMD_TIMEOUT) parts.append("── journal (last 10) ──") parts.append(journal.strip()) return "\n".join(parts) # ── Tools: transfer / diagnostics ─────────────────────────────────────────────── def _tool_upload(args: dict) -> str: alias = args.get("alias") local_path, remote_path = args.get("local_path"), args.get("remote_path") if not alias or not local_path or not remote_path: return "Error: 'alias', 'local_path' and 'remote_path' are required" if not os.path.exists(local_path): return f"Error: local path not found: {local_path}" sftp = _get_sftp(alias) count = total = 0 dest_shown = remote_path if os.path.isdir(local_path): for root, _dirs, files in os.walk(local_path): relroot = os.path.relpath(root, local_path) rdir = remote_path if relroot == "." else posixpath.join( remote_path, relroot.replace(os.sep, "/")) _sftp_mkdirs(sftp, rdir) for fn in files: lf = os.path.join(root, fn) sftp.put(lf, posixpath.join(rdir, fn)) count += 1 total += os.path.getsize(lf) else: # scp/rsync semantics: a trailing-slash or existing-directory remote_path # means "upload the file INTO that directory". paramiko's sftp.put needs a # full destination FILE path — handed a directory path it fails with a # generic "Failure" — so append the local basename in that case. into_dir = remote_path.endswith("/") if not into_dir: try: into_dir = stat.S_ISDIR(sftp.stat(remote_path).st_mode) except IOError: into_dir = False if into_dir: target_dir = remote_path.rstrip("/") or "/" _sftp_mkdirs(sftp, target_dir) dest = posixpath.join(target_dir, os.path.basename(local_path)) else: dest = remote_path parent = posixpath.dirname(dest) if parent: _sftp_mkdirs(sftp, parent) sftp.put(local_path, dest) count, total = 1, os.path.getsize(local_path) dest_shown = dest return f"Uploaded {count} file(s), {total} bytes → {dest_shown}" def _tool_download(args: dict) -> str: alias = args.get("alias") remote_path, local_path = args.get("remote_path"), args.get("local_path") if not alias or not remote_path or not local_path: return "Error: 'alias', 'remote_path' and 'local_path' are required" sftp = _get_sftp(alias) try: st = sftp.stat(remote_path) except IOError as e: raise ToolError(f"remote path not found: {remote_path} ({e})") count = total = 0 if stat.S_ISDIR(st.st_mode): def rec(rdir: str, ldir: str) -> None: nonlocal count, total os.makedirs(ldir, exist_ok=True) for a in sftp.listdir_attr(rdir): rf = posixpath.join(rdir, a.filename) lf = os.path.join(ldir, a.filename) if stat.S_ISDIR(a.st_mode): rec(rf, lf) elif stat.S_ISREG(a.st_mode): sftp.get(rf, lf) count += 1 total += a.st_size or os.path.getsize(lf) rec(remote_path, local_path) else: parent = os.path.dirname(local_path) if parent: os.makedirs(parent, exist_ok=True) sftp.get(remote_path, local_path) count, total = 1, os.path.getsize(local_path) return f"Downloaded {count} file(s), {total} bytes → {local_path}" def _tool_sysinfo(args: dict) -> str: alias = args.get("alias") if not alias: return "Error: 'alias' is required" client = _get_client(alias) cmd = ( "echo OS=$(uname -s 2>/dev/null); " "echo KERNEL=$(uname -r 2>/dev/null); " "echo CPU=$(nproc 2>/dev/null); " "echo MEMTOTAL=$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null); " "echo MEMAVAIL=$(awk '/MemAvailable/{print $2}' /proc/meminfo 2>/dev/null); " "echo DISKTOTAL=$(df -kP / 2>/dev/null | tail -1 | awk '{print $2}'); " "echo DISKAVAIL=$(df -kP / 2>/dev/null | tail -1 | awk '{print $4}'); " "echo UPTIME=$(uptime -p 2>/dev/null || uptime 2>/dev/null)" ) out, _, _ = _run_with_stdin(client, cmd, DEFAULT_CMD_TIMEOUT) kv: dict[str, str] = {} for line in out.splitlines(): if "=" in line: k, _, v = line.partition("=") kv[k.strip()] = v.strip() def gb(key: str): try: return round(int(kv.get(key, "")) / 1024 / 1024, 2) except (ValueError, TypeError): return None info = { "os": kv.get("OS", ""), "kernel": kv.get("KERNEL", ""), "cpu_count": int(kv["CPU"]) if kv.get("CPU", "").isdigit() else None, "ram_total_gb": gb("MEMTOTAL"), "ram_free_gb": gb("MEMAVAIL"), "disk_total_gb": gb("DISKTOTAL"), "disk_free_gb": gb("DISKAVAIL"), "uptime": kv.get("UPTIME", ""), } return json.dumps(info, indent=2) # ── Tool registry ──────────────────────────────────────────────────────────────── _ALIAS = {"type": "string", "description": "Host alias registered via add_alias."} _SFTP_NOTE = ( " Runs as the login user (no sudo): for paths needing root, use exec with " "sudo=true (e.g. tee/install)." ) TOOLS = [ { "name": "list_aliases", "description": "List configured SSH host aliases (never reveals keys or sudo passwords).", "inputSchema": {"type": "object", "properties": {}}, }, { "name": "add_alias", "description": "Register or update an SSH host alias. Login via SSH key/agent (default) or login password asked on demand via elicitation.", "inputSchema": { "type": "object", "properties": { "alias": {"type": "string", "description": "Short name used to address the host."}, "hostname": {"type": "string", "description": "Host or IP."}, "port": {"type": "integer", "description": "SSH port (default 22)."}, "username": {"type": "string", "description": "Login user."}, "identity_file": {"type": "string", "description": "Path to private key (optional; ssh-agent is also tried). An encrypted key's passphrase is asked via elicitation."}, "description": {"type": "string", "description": "Free-text note."}, "auth": {"type": "string", "enum": ["key", "password"], "description": "Login auth. key: SSH key/agent (default). password: login password asked on demand via elicitation, kept only in RAM."}, "sudo": {"type": "string", "enum": ["nopasswd", "prompt", "none"], "description": "How sudo authenticates. Use 'prompt' unless you KNOW otherwise — it is the safe default: runs 'sudo -S' and asks the user for the sudo password on demand via elicitation, so it works on any host where the login user is a normal sudoer. Only pick 'nopasswd' when the remote /etc/sudoers actually grants THIS user passwordless sudo (a NOPASSWD: rule): it runs 'sudo -n' and NEVER prompts, so on a normal host every sudo call fails immediately with 'a password is required'. 'none' disables sudo. Default prompt."}, "accept_new_host_key": {"type": "boolean", "description": "Trust the host key on first connect (TOFU). Default false."}, }, "required": ["alias", "hostname", "username"], }, }, { "name": "remove_alias", "description": "Remove a host alias and close its pooled connection.", "inputSchema": {"type": "object", "properties": {"alias": _ALIAS}, "required": ["alias"]}, }, { "name": "read_file", "description": "Read a remote file with 1-based line numbers (same format as the local read_file)." + _SFTP_NOTE, "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "path": {"type": "string", "description": "Absolute remote path."}, "start_line": {"type": "integer", "description": "First line (1-based, inclusive)."}, "end_line": {"type": "integer", "description": "Last line (1-based, inclusive)."}, "limit": {"type": "integer", "description": "Max lines to read (cap 2000)."}, }, "required": ["alias", "path"], }, }, { "name": "list_files", "description": "List files/dirs under a remote path; returns a JSON array of relative paths (same as local list_files).", "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "path": {"type": "string", "description": "Absolute remote directory."}, "depth": {"type": "integer", "description": "Max recursion depth (default 3; 1 = immediate contents)."}, "dirs_only": {"type": "boolean", "description": "Only directories (default false)."}, }, "required": ["alias", "path"], }, }, { "name": "grep_files", "description": "Search a remote path with a regex; output matches the local grep_files (uses remote grep -E).", "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "path": {"type": "string", "description": "Remote file or directory."}, "pattern": {"type": "string", "description": "Regex (case-insensitive by default)."}, "case_sensitive": {"type": "boolean", "description": "Default false."}, "include_glob": {"type": "string", "description": "Restrict to files matching this glob, e.g. '*.rs'."}, "output_mode": {"type": "string", "enum": ["content", "files_only", "count"], "description": "Default 'content'."}, "context_lines": {"type": "integer", "description": "Lines of context per match (default 0, max 10)."}, "max_results": {"type": "integer", "description": "Stop after N results (default 100)."}, }, "required": ["alias", "path", "pattern"], }, }, { "name": "edit_file", "description": "Find & replace in a remote file (atomic). `old` must match exactly once unless replace_all." + _SFTP_NOTE, "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "path": {"type": "string", "description": "Absolute remote path."}, "old": {"type": "string", "description": "Exact text to replace."}, "new": {"type": "string", "description": "Replacement text."}, "replace_all": {"type": "boolean", "description": "Replace every occurrence (default false)."}, }, "required": ["alias", "path", "old", "new"], }, }, { "name": "replace_lines", "description": "Replace a 1-based inclusive line range in a remote file (atomic)." + _SFTP_NOTE, "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "path": {"type": "string", "description": "Absolute remote path."}, "from_line": {"type": "integer", "description": "First line (1-based, inclusive)."}, "to_line": {"type": "integer", "description": "Last line (1-based, inclusive)."}, "new": {"type": "string", "description": "Replacement text."}, }, "required": ["alias", "path", "from_line", "to_line", "new"], }, }, { "name": "exec", "description": "Run a command on the remote host. Set sudo=true to run via sudo (method per alias).", "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "command": {"type": "string", "description": "Shell command."}, "sudo": {"type": "boolean", "description": "Run via sudo (default false)."}, "sudo_user": {"type": "string", "description": "Target user for sudo -u (optional)."}, "timeout_sec": {"type": "integer", "description": "Kill after N seconds (default 120)."}, }, "required": ["alias", "command"], }, }, { "name": "upload", "description": "Upload a local file or directory (recursive) to the remote host via SFTP." + _SFTP_NOTE, "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "local_path": {"type": "string", "description": "Local file or directory."}, "remote_path": {"type": "string", "description": "Remote destination. For a single file: a trailing '/' (or an existing remote directory) uploads the file INTO that directory keeping its name; otherwise it is the exact destination file path (parent dirs are created)."}, }, "required": ["alias", "local_path", "remote_path"], }, }, { "name": "download", "description": "Download a remote file or directory (recursive) to the local host via SFTP.", "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "remote_path": {"type": "string", "description": "Remote file or directory."}, "local_path": {"type": "string", "description": "Local destination path."}, }, "required": ["alias", "remote_path", "local_path"], }, }, { "name": "sysinfo", "description": "Report OS, kernel, CPU count, RAM and root-disk usage, and uptime.", "inputSchema": {"type": "object", "properties": {"alias": _ALIAS}, "required": ["alias"]}, }, { "name": "systemd", "description": "Manage a systemd service (status/start/stop/restart/reload/enable/disable) + last 10 journal lines. Mutating actions use sudo.", "inputSchema": { "type": "object", "properties": { "alias": _ALIAS, "service": {"type": "string", "description": "Service/unit name."}, "action": {"type": "string", "enum": ["status", "start", "stop", "restart", "reload", "enable", "disable"]}, }, "required": ["alias", "service", "action"], }, }, ] TOOL_DISPATCH = { "list_aliases": _tool_list_aliases, "add_alias": _tool_add_alias, "remove_alias": _tool_remove_alias, "read_file": _tool_read_file, "list_files": _tool_list_files, "grep_files": _tool_grep_files, "edit_file": _tool_edit_file, "replace_lines": _tool_replace_lines, "exec": _tool_exec, "upload": _tool_upload, "download": _tool_download, "sysinfo": _tool_sysinfo, "systemd": _tool_systemd, } # ── JSON-RPC dispatch ──────────────────────────────────────────────────────────── def handle_message(msg: dict) -> dict | None: method = msg.get("method", "") req_id = msg.get("id") if method == "initialize": return _ok(req_id, { "protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "ssh", "version": "1.0.0"}, }) if method == "notifications/initialized": return None if method == "tools/list": return _ok(req_id, {"tools": TOOLS}) if method == "tools/call": params = msg.get("params", {}) name = params.get("name", "") targs = params.get("arguments", {}) or {} handler = TOOL_DISPATCH.get(name) if handler is None: return _text_result(req_id, f"Error: Unknown tool: {name}", True) try: text = handler(targs) except ToolError as e: text = f"Error: {e}" except Exception as e: log(f"unhandled exception in tool '{name}': {e}") text = f"Error: internal error in '{name}': {e}" return _text_result(req_id, text, text.startswith("Error:")) if req_id is not None: return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Method not found: {method}"}} return None def main() -> None: log("starting SSH MCP server") try: while True: msg = readline() if msg is None: break resp = handle_message(msg) if resp is not None: send(resp) except KeyboardInterrupt: pass if __name__ == "__main__": main()