Files
skald-connectors/connectors/ssh/ssh_mcp_server.py
T
Daniele d84ce13dab ssh: fix sudo failing with "password required" (v6 / 1.1.0)
The connector asked for a sudo password on every privileged call and
failed whenever nobody answered it, which is every unattended run.

- Always probe `sudo -n` first, even for aliases set to sudo="prompt".
  `_sudo_prefix` used to elicit unconditionally, so a host granting this
  user NOPASSWD still opened an Agent Inbox prompt; with no human there
  it hit the client's 300s ELICITATION_DEADLINE, got back `cancel`, and
  surfaced as "sudo password required (user declined or timed out)".
  sudo refuses before running anything when it wants a password, so the
  probe is side-effect free.
- Strip a leading `sudo` from `command` and turn it into sudo=true.
  Agents write `exec(command="sudo systemctl restart x")`: with
  sudo=false that ran a tty-less sudo, with sudo=true it nested
  `sudo -S ... sudo ...` whose inner prompt had no tty either. Handles
  -u/-n/-S/-E/-H/-i/-k/-p/--; an unknown flag leaves the command alone.
  sudo_user now implies sudo=true.
- Run privileged commands as `sh -c '<command>'`, so `&&`, pipes and
  redirections are elevated too instead of only the first word.
- Add SSH_MCP_SUDO_PASSWORD (optional, secret) for unattended runs. It
  is consulted only after `sudo -n` proved a password is needed, so on a
  NOPASSWD host it never lands in the command's own stdin.
- Actionable errors for every sudo failure mode, and a `hint` on a
  nested sudo we could not peel off.

General review of the same server:

- Drain stdout and stderr together and make timeout_sec a real
  wall-clock deadline. Both streams share one SSH channel window, so
  reading stdout to EOF first stalled once a chatty stderr filled it.
  Command stdin is now closed after the optional password.
- Queue messages that arrive while awaiting an elicitation reply instead
  of discarding them, so a concurrent tools/call is not lost.
- Record the client's `elicitation` capability at initialize and fail
  fast when it is absent rather than blocking on a prompt nobody can
  answer.
- Tolerate null/string integer arguments (depth, max_results,
  context_lines, timeout_sec).
- Realign the version across both manifests: fragment.json said 5/1.0.4
  while connector.json said 2/1.0.1, so the feed was permanently ahead
  of the installed version and offered an update forever.

Also lands the pending docs work: CONNECTOR_MANIFEST_GUIDE.md as the
single source of truth, docs/connector.manifest_guide.md retired to a
pointer, CLAUDE.md audited against the repo, compile.py docstring fixed,
and an opencode.json config.
2026-09-03 23:48:00 +01:00

1520 lines
59 KiB
Python
Raw 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 ``~/.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`` only: non-interactive, fails fast with an
explicit message if NOPASSWD is not configured on the host (no hung
channel). No secret stored anywhere.
* ``prompt`` — ``sudo -n`` is **still tried first**; only if the host really
demands a password is one requested 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. ``SSH_MCP_SUDO_PASSWORD`` is a non-interactive override for
unattended runs (no human to answer the Inbox prompt).
``exec``/``systemd`` never nest sudo: a leading ``sudo`` (with its usual flags,
including ``-u USER``) is stripped from ``command`` and turned into
``sudo=true``, so an agent that writes ``sudo systemctl restart x`` gets the
same, working behaviour as ``sudo=true`` + ``systemctl restart x``. Under sudo
the command runs as ``sh -c '<command>'`` so pipes and redirections are also
privileged.
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 ───────────────────────────────────────────────────────────────────
ALIASES_FILE = os.path.expanduser("~/.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"))
# Non-interactive sudo password (unattended runs, where nobody can answer the
# elicitation prompt in the Agent Inbox). Empty/unset ⇒ elicitation only.
SUDO_PASSWORD_ENV = os.environ.get("SSH_MCP_SUDO_PASSWORD") or None
# 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)
# Messages received while blocked on an elicitation reply — replayed by the main
# loop instead of being dropped, so a concurrent tools/call is never lost.
_deferred: list[dict] = []
# Whether the client advertised the `elicitation` capability at initialize. A
# client without it can never supply a password, so we fail fast with a useful
# message instead of blocking on a request nobody will answer.
_client_can_elicit = True
def next_message() -> dict | None:
"""Next inbound message: deferred ones first, then stdin."""
if _deferred:
return _deferred.pop(0)
return readline()
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": {...}}). Other
inbound messages are queued in ``_deferred`` and handled once the reply
lands (v1: still serial, but nothing is discarded).
"""
if not _client_can_elicit:
return {"action": "cancel", "_reason": "unsupported"}
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", "_reason": "disconnected"}
if msg.get("id") == eid:
return msg.get("result", {"action": "cancel"})
log(f"deferring inbound while awaiting elicitation: {msg.get('method') or msg.get('id')}")
_deferred.append(msg)
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}
def _int(args: dict, key: str, default: int) -> int:
"""Integer argument tolerant of null / numeric strings (LLMs send both)."""
v = args.get(key)
if v is None or v == "":
return default
try:
return int(v)
except (TypeError, ValueError):
return default
# ── 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 _no_secret_reason() -> str:
"""Why an elicited secret never arrived — the two cases look identical to the
caller but need very different fixes from the user."""
return ("this MCP client does not support elicitation" if not _client_can_elicit
else "the user declined it or the request timed out")
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}' but none was provided "
f"({_no_secret_reason()})"
)
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}' but none was provided "
f"({_no_secret_reason()}) — set SSH_MCP_KEY_PASSPHRASE for unattended runs"
)
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).
stdout and stderr are drained **together**: they share one SSH channel
window, so reading stdout to EOF first stalls as soon as a chatty stderr
fills that window. ``timeout`` is a wall-clock deadline for the whole run
(not an idle timeout), and stdin is always closed so commands that read it
see EOF instead of hanging.
"""
chan = client.get_transport().open_session(timeout=CONNECT_TIMEOUT)
try:
chan.settimeout(timeout)
chan.exec_command(command)
try:
if stdin_data:
chan.sendall(stdin_data.encode())
chan.shutdown_write()
except Exception as e: # closed early by the remote end
log(f"stdin write failed: {e}")
out, err = bytearray(), bytearray()
deadline = time.time() + timeout
settled = 0
while True:
idle = True
while chan.recv_ready():
out += chan.recv(65536)
idle = False
while chan.recv_stderr_ready():
err += chan.recv_stderr(65536)
idle = False
if not idle:
settled = 0
continue
if chan.exit_status_ready():
# Exit status can arrive before the last data. Wait for the
# remote EOF, or — should it close without one — for three
# consecutive empty polls, rather than truncating the output.
if chan.eof_received or chan.closed or settled >= 3:
break
settled += 1
if time.time() > deadline:
raise ToolError(f"command timed out after {timeout}s")
time.sleep(0.02)
return (out.decode("utf-8", "replace"),
err.decode("utf-8", "replace"),
chan.recv_exit_status())
except socket.timeout:
raise ToolError(f"command timed out after {timeout}s")
finally:
try:
chan.close()
except Exception:
pass
# ── sudo ───────────────────────────────────────────────────────────────────────
def _cached_sudo_password(alias: str) -> str | None:
"""Live RAM-cache entry for ``alias``, if any.
Deliberately ignores ``SSH_MCP_SUDO_PASSWORD``: a cache hit lets ``_run_sudo``
skip its ``sudo -n`` probe, and on a NOPASSWD host sudo would then not read
the password line at all — feeding it straight into the command's stdin. A
RAM entry only ever exists because sudo already demanded a password once.
"""
cached = _sudo_pw_cache.get(alias)
if cached and (time.time() - cached[1] <= SUDO_PW_TTL):
return cached[0]
return None
def _sudo_password(alias: str) -> str | None:
"""Return the sudo password for ``alias`` from RAM cache / env, or elicit it.
Never persisted. Returns None if the user declines/cancels/times out, or if
the client cannot elicit at all.
"""
now = time.time()
cached = _cached_sudo_password(alias)
if cached is not None:
return cached
if SUDO_PASSWORD_ENV:
return SUDO_PASSWORD_ENV
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
# sudo refusing to run because it wants a password. sudo prints these *instead*
# of running the command, so probing with `-n` is always side-effect free.
_SUDO_NEEDS_PW = (
"a password is required", "no password was provided", "a terminal is required",
"no tty present", "askpass",
)
# sudo ran but the password we fed it was wrong.
_SUDO_BAD_PW = ("sorry, try again", "incorrect password attempt", "authentication failure")
def _sudo_says(err: str, code: int, markers: tuple) -> bool:
"""True if a `sudo:`-prefixed stderr line matches one of ``markers``."""
if code == 0:
return False
for line in err.splitlines():
low = line.strip().lower()
if low.startswith("sudo:") and any(m in low for m in markers):
return True
return False
# Leading `sudo` (plus its common flags) that an agent put in `command` itself.
# Anything unrecognised simply doesn't match and is left untouched.
_SUDO_OPT = (
r"""(?:-p\s*(?:'[^']*'|"[^"]*"|\S+)""" # -p PROMPT (takes an argument)
r"|--prompt(?:=|\s+)\S+"
r"|-[EHnSbik]+" # flag bundles without arguments
r"|--(?:preserve-env|set-home|non-interactive|stdin|login|shell|background|remove-timestamp))"
)
_LEADING_SUDO = re.compile(
rf"""^\s*(?:/usr/bin/|/bin/)?sudo
(?:\s+{_SUDO_OPT})*
(?:\s+(?:-u\s*|--user(?:=|\s+))(?P<user>[A-Za-z0-9_.\-]+))?
(?:\s+{_SUDO_OPT})*
(?:\s+--)?
\s+(?P<rest>\S.*)$""",
re.X | re.S,
)
def _split_leading_sudo(command: str) -> tuple[str, bool, str | None]:
"""Split a leading ``sudo …`` off ``command``.
Returns ``(command_without_sudo, had_sudo, sudo_user)``. Agents routinely
write ``sudo systemctl restart x``; running that through the sudo machinery
would nest a second sudo, whose password prompt has no tty and dies. So the
prefix is peeled off here and expressed as ``sudo=true`` instead.
"""
m = _LEADING_SUDO.match(command)
if not m:
return command, False, None
rest = m.group("rest").strip()
if rest.startswith("-"):
# A sudo option we don't know about — stripping here would hand sudo a
# mangled command line, so leave the whole thing untouched.
return command, False, None
return rest, True, m.group("user")
def _sudo_wrap(flags: str, sudo_user: str | None, command: str) -> str:
"""``sudo <flags> [-u user] sh -c '<command>'`` — the whole command line,
pipes and redirections included, runs with the elevated privileges."""
u = f"-u {shlex.quote(sudo_user)} " if sudo_user else ""
return f"sudo {flags} {u}sh -c {shlex.quote(command)}"
def _run_sudo(alias: str, cfg: dict, client, command: str,
sudo_user: str | None, timeout: int):
"""Run ``command`` under sudo. Returns (stdout, stderr, exit_code).
``sudo -n`` is always attempted first: on a host that grants this user
NOPASSWD it succeeds outright, so no password is ever requested — which is
the only thing that works in unattended runs, where nobody is watching the
Agent Inbox. A password is elicited only when the host actually demands one.
"""
method = (cfg.get("sudo") or {}).get("method", "prompt")
if method == "none":
raise ToolError(
f"sudo is disabled for alias '{alias}' — re-add it with sudo='prompt' to enable it"
)
pw = _cached_sudo_password(alias) if method != "nopasswd" else None
if pw is None:
out, err, code = _run_with_stdin(
client, _sudo_wrap("-n", sudo_user, command), timeout)
if not _sudo_says(err, code, _SUDO_NEEDS_PW):
return out, err, code
if method == "nopasswd":
raise ToolError(
f"sudo on '{alias}' requires a password, but the alias is configured with "
f"sudo='nopasswd' (which only ever runs 'sudo -n' and never prompts). "
f"Re-add the alias with sudo='prompt', or grant this user a NOPASSWD rule "
f"in the remote /etc/sudoers."
)
pw = _sudo_password(alias)
if pw is None:
raise ToolError(
f"sudo password required for alias '{alias}' but none was provided "
f"({_no_secret_reason()}). Either answer the sudo prompt in the Agent Inbox, or — for "
f"unattended runs — set SSH_MCP_SUDO_PASSWORD in the connector settings, or "
f"grant this user NOPASSWD sudo on the host."
)
out, err, code = _run_with_stdin(
client, _sudo_wrap("-S -p ''", sudo_user, command), timeout, pw + "\n")
if _sudo_says(err, code, _SUDO_BAD_PW) or _sudo_says(err, code, _SUDO_NEEDS_PW):
_sudo_pw_cache.pop(alias, None) # drop it so the next call re-prompts
raise ToolError(
f"sudo password rejected on '{alias}'"
+ (" (from SSH_MCP_SUDO_PASSWORD)" if SUDO_PASSWORD_ENV else
" — the cached password was discarded, retry to be asked again")
)
return out, err, code
# ── 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, "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, "context_lines", 0), 10)
maxr = _int(args, "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, "timeout_sec", DEFAULT_CMD_TIMEOUT)
cfg = _find_alias(alias)
if not cfg:
return f"Error: unknown alias '{alias}'"
# A `sudo` the agent typed into `command` is the same intent as sudo=true —
# honour it here rather than nesting a second, tty-less sudo remotely.
command, inline_sudo, inline_user = _split_leading_sudo(command)
if inline_sudo:
sudo = True
sudo_user = sudo_user or inline_user
if sudo_user:
sudo = True # `sudo -u X` is meaningless without sudo itself
if not command.strip():
return "Error: 'command' is empty"
client = _get_client(alias)
if sudo:
out, err, code = _run_sudo(alias, cfg, client, command, sudo_user, timeout)
else:
out, err, code = _run_with_stdin(client, command, timeout)
result = {"stdout": out, "stderr": err, "exit_code": code}
if _sudo_says(err, code, _SUDO_NEEDS_PW):
# A sudo buried mid-command (e.g. `cd /x && sudo …`) that we could not
# peel off. Don't silently re-run the whole line as root — tell the LLM.
result["hint"] = (
"this command used sudo internally and sudo asked for a password on a "
"tty it does not have. Re-run it with sudo=true and no 'sudo' inside "
"`command` (with sudo=true the whole command line runs as root)."
)
return json.dumps(result)
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":
out, err, code = _run_sudo(
alias, cfg, client, f"systemctl {action} {qsvc}", None, DEFAULT_CMD_TIMEOUT)
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",
"title": "List Aliases",
"description": "List configured SSH host aliases (never reveals keys or sudo passwords).",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "add_alias",
"title": "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. Keep the default 'prompt' unless you KNOW otherwise: it tries 'sudo -n' first (so a host with a NOPASSWD rule never prompts) and only asks the user for the sudo password via elicitation if the host actually demands one. 'nopasswd' runs 'sudo -n' only and NEVER prompts — pick it just to forbid prompting, since on a host without a NOPASSWD rule every sudo call then fails. 'none' disables sudo entirely. 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",
"title": "Remove Alias",
"description": "Remove a host alias and close its pooled connection.",
"inputSchema": {"type": "object", "properties": {"alias": _ALIAS}, "required": ["alias"]},
},
{
"name": "read_file",
"title": "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",
"title": "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",
"title": "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",
"title": "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",
"title": "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",
"title": "Execute Command",
"description": (
"Run a command on the remote host. For anything needing root, set sudo=true and "
"write the command WITHOUT a 'sudo' prefix — with sudo=true the entire command "
"line (pipes and redirections included) runs as root. A leading 'sudo' left in "
"`command` is stripped and treated as sudo=true anyway, but a 'sudo' in the middle "
"of a command (e.g. 'cd /x && sudo …') has no tty and will fail."
),
"inputSchema": {
"type": "object",
"properties": {
"alias": _ALIAS,
"command": {"type": "string", "description": "Shell command. Do NOT prefix it with 'sudo' — use the sudo argument."},
"sudo": {"type": "boolean", "description": "Run the whole command as root, via sudo (default false)."},
"sudo_user": {"type": "string", "description": "Target user for sudo -u (optional; implies sudo)."},
"timeout_sec": {"type": "integer", "description": "Kill after N seconds (default 120)."},
},
"required": ["alias", "command"],
},
},
{
"name": "upload",
"title": "Upload File",
"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",
"title": "Download File",
"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",
"title": "System Info",
"description": "Report OS, kernel, CPU count, RAM and root-disk usage, and uptime.",
"inputSchema": {"type": "object", "properties": {"alias": _ALIAS}, "required": ["alias"]},
},
{
"name": "systemd",
"title": "Manage Systemd Service",
"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":
# Remember whether the client can collect input mid-call: without the
# elicitation capability we must never block on a password prompt.
global _client_can_elicit
caps = (msg.get("params") or {}).get("capabilities") or {}
_client_can_elicit = "elicitation" in caps
log(f"client elicitation capability: {'yes' if _client_can_elicit else 'no'}")
return _ok(req_id, {
"protocolVersion": "2025-06-18",
"capabilities": {"tools": {}},
"serverInfo": {"name": "ssh", "version": "1.1.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 = next_message()
if msg is None:
break
resp = handle_message(msg)
if resp is not None:
send(resp)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()