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.
This commit is contained in:
Daniele
2026-09-03 23:48:00 +01:00
parent 78c78d6039
commit d84ce13dab
10 changed files with 875 additions and 708 deletions
+297 -75
View File
@@ -24,12 +24,22 @@ Elicited login secrets are kept only in this process's RAM with a short TTL
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.
* ``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
@@ -66,6 +76,10 @@ LOGIN_PW_TTL = int(os.environ.get("SSH_MCP_LOGIN_PW_TTL", "300")) # in-RAM lo
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"}
@@ -107,13 +121,32 @@ def readline() -> dict | None:
_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": {...}}). While
waiting, any other inbound message is ignored (v1: serial processing).
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",
@@ -124,10 +157,11 @@ def elicit(message: str, requested_schema: dict) -> dict:
while True:
msg = readline()
if msg is None:
return {"action": "cancel"}
return {"action": "cancel", "_reason": "disconnected"}
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')}")
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:
@@ -141,6 +175,17 @@ def _text_result(req_id: Any, text: str, is_error: bool = False) -> dict:
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:
@@ -216,6 +261,13 @@ def _login_password(alias: str, kind: str = "login") -> str | None:
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}:")]:
@@ -259,7 +311,8 @@ def _connect(cfg: dict, paramiko):
password = _login_password(alias, "login")
if password is None:
raise ToolError(
f"login password required for alias '{alias}' (user declined or timed out)"
f"login password required for alias '{alias}' but none was provided "
f"({_no_secret_reason()})"
)
def attempt(passphrase):
@@ -301,7 +354,8 @@ def _connect(cfg: dict, paramiko):
passphrase = _login_password(alias, "passphrase")
if passphrase is None:
raise ToolError(
f"key passphrase required for alias '{alias}' (user declined or timed out)"
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:
@@ -380,34 +434,89 @@ def _get_sftp(alias: str):
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."""
"""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_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
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 _sudo_password(alias: str) -> str | None:
"""Return the sudo password for ``alias`` from RAM cache, or elicit it.
def _cached_sudo_password(alias: str) -> str | None:
"""Live RAM-cache entry for ``alias``, if any.
Never persisted. Returns None if the user declines/cancels/times out.
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 = _sudo_pw_cache.get(alias)
if cached and (now - cached[1] <= SUDO_PW_TTL):
return cached[0]
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}'.",
@@ -430,21 +539,119 @@ def _sudo_password(alias: str) -> str | None:
return None
def _sudo_prefix(alias: str, cfg: dict, sudo_user: str | None):
"""Build the sudo prefix for ``cfg``. Returns (prefix, stdin_password).
# 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")
Raises ToolError when sudo is disabled or the password is unavailable.
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")
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)
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:
raise ToolError("sudo password required (user declined or timed out)")
return f"sudo -S -p '' {u}", pw
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 ───────────────────────────────────────────────────────────────
@@ -628,7 +835,7 @@ 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))
max_depth = _int(args, "depth", 3)
dirs_only = bool(args.get("dirs_only", False))
sftp = _get_sftp(alias)
@@ -675,8 +882,8 @@ def _tool_grep_files(args: dict) -> str:
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))
ctx = min(_int(args, "context_lines", 0), 10)
maxr = _int(args, "max_results", 100)
client = _get_client(alias)
flags = _grep_flags(args)
@@ -840,32 +1047,38 @@ def _tool_exec(args: dict) -> str:
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))
timeout = _int(args, "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
# 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)
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})
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:
@@ -884,11 +1097,8 @@ def _tool_systemd(args: dict) -> str:
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,
)
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())
@@ -1064,7 +1274,7 @@ TOOLS = [
"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."},
"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"],
@@ -1161,14 +1371,20 @@ TOOLS = [
{
"name": "exec",
"title": "Execute Command",
"description": "Run a command on the remote host. Set sudo=true to run via sudo (method per alias).",
"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."},
"sudo": {"type": "boolean", "description": "Run via sudo (default false)."},
"sudo_user": {"type": "string", "description": "Target user for sudo -u (optional)."},
"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"],
@@ -1248,10 +1464,16 @@ def handle_message(msg: dict) -> dict | None:
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.0.0"},
"serverInfo": {"name": "ssh", "version": "1.1.0"},
})
if method == "notifications/initialized":
return None
@@ -1283,7 +1505,7 @@ def main() -> None:
log("starting SSH MCP server")
try:
while True:
msg = readline()
msg = next_message()
if msg is None:
break
resp = handle_message(msg)