Files
2026-07-10 15:02:09 +01:00

1286 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: <message>``."""
# ── 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()