Labsco
Nightreaver logo

Python SSH MCP

โ˜… 5

from Nightreaver

A SSH MCP Server written in python. Which builds upon a sophisticated tools and permission layer. Including Skills, Docker and Systemctl toolset and some runbooks.

๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅโœ“ VerifiedFreeNeeds API keys

Python SSH MCP

An SSH MCP server built in Python on top of FastMCP. The goal: give an LLM real SSH access to many hosts while keeping fine-grained control over what it can and can't do. The configuration surface is deliberately broad โ€” probably overkill if you just want a single ssh_exec tool, but it pays off once you start connecting more than one host or locking the agent down to specific paths, commands, and visibility tiers. If a tool you need isn't here, open an issue.

Currently implemented: 100 tools across 10 groups, 1649 passing unit tests + 6 dockerized-sshd integration tests + an opt-in tests/e2e/ suite that drives every tool against the operator's real hosts.toml. Strict known_hosts by default, path-allowlist confinement on every path-bearing tool, SHA-256-hashed audit log, operator-pluggable hooks. Secret-redaction policy that lets the LLM read config files without seeing the secrets (HMAC-SHA256 hash markers; redact_paths_globs with block/warn/audit_only bypass modes). Five sudo-tier path-bearing tools that respect path-policy under sudo (read/read_redacted/write/edit/sftp_list). Local-disk streaming mode on upload/deploy/download/sudo_write so large files bypass the LLM's base64 channel. Most tools return typed Pydantic results so MCP clients see real schemas in tools/list (not generic object); the few that legitimately produce merged or bimodal payloads stay as dict[str, Any] with the rationale documented at the function. POSIX SSH targets supported end-to-end; Windows SSH targets supported for SFTP + file-ops + ssh_file_hash via PowerShell -EncodedCommand (see ADR-0023); Docker CLI swappable for Podman via SSH_DOCKER_CMD / per-host docker_cmd.

Contents

In this file:

Features

  • MCP-compliant server exposing SSH over stdio (or HTTP if you prefer); transport speaks MCP directly, no shim.
  • Four-tier access model โ€” read / low-access / dangerous / sudo. Each tier is toggled with its own env flag and enforced via FastMCP Visibility transforms. Default: read-only.
  • Ten tool groups orthogonal to tiers (host, session, sftp-read, file-ops, exec, sudo, shell, docker, systemctl, pkg). SSH_ENABLED_GROUPS trims the catalog to what a given assistant actually needs.
  • 100 tools: see TOOLS.md for the complete per-tool reference. Highlights:
    • Read-only probes (ping, host info, disk usage, processes, alerts, known-hosts verify, user info, host notes, server-info)
    • mcp://ssh-mcp/server-info resource + ssh_server_info fallback tool -- server identity + capability surface so the LLM (or operator) can self-introspect "what version is this server / which tiers are unlocked / how many tools are visible" without grepping the catalog (v1.5.0)
    • SFTP reads (list, stat, download, find, file_hash) with remote-realpath confinement
    • ssh_read_redacted โ€” read configs (.env, .yml, ...) with secrets replaced inline by HMAC-SHA256 hash markers so the LLM gets structural info but never the plaintext (v1.4.0)
    • Low-access file ops (cp, mv, mkdir, delete, delete_folder, edit, patch, upload, deploy, link, transfer) โ€” SFTP-first, atomic writes. upload/deploy/sftp_download support local_path= to stream big files without base64 round-trips (v1.3.0)
    • Exec tier with per-call timeout, streaming variant, broadcast across hosts, and a default-on cheatsheet that catches cat/tee/sudo cat/... and reroutes to the right native tool
    • Sudo tier โ€” ssh_sudo_exec plus five sudo-tier path-bearing tools (ssh_sudo_read, _read_redacted, _write, _edit, _sftp_list) so root-owned files stay inside path-policy instead of bypassing via raw sudo cat (v1.4.0). Password piped via stdin, never argv; env passwords hard-rejected at startup
    • 27 Docker tools (ps, logs, inspect, stats, events, system_df, images, volumes, compose up/down/logs/..., container lifecycle, exec, run, prune)
    • 17 systemctl tools (read + lifecycle mutations) and 8 journalctl/list helpers
    • 9 APT/package tools โ€” read (apt_list, apt_search, apt_show, apt_show_holds) + mutations (apt_install, apt_upgrade, apt_remove, apt_autoremove, apt_mark). Non-Debian hosts get a clean PlatformNotSupported
    • Persistent shell sessions with cwd tracking (no remote PTY, sentinel-based state)
  • Strict known_hosts โ€” no auto-accept; unknown or mismatched keys fail closed.
  • Path confinement on everything โ€” each path-bearing tool canonicalizes via remote realpath (or SFTP realpath on Windows) and checks the allowlist, with restricted_paths carve-outs for sensitive zones.
  • Per-host policy in hosts.toml โ€” users, keys, allowlists, sudo mode, platform, proxy chains, alert thresholds, persistent-session opt-out.
  • Windows SSH target support for SFTP + file-ops (see ADR-0023); POSIX-only tools refuse Windows targets with a clean PlatformNotSupported that names the missing capability.
  • Audit log โ€” one JSON line per tool call (all tiers), paths/commands SHA-256-hashed, error field is exception class only (full text stays at DEBUG locally).
  • Operator hooks: import any module via SSH_HOOKS_MODULE for STARTUP / SHUTDOWN / PRE_TOOL_CALL / POST_TOOL_CALL events. Bounded per-hook timeout, exception isolation, backlog warning when pending tasks pile up.
  • Runbooks via FastMCP Skills โ€” per-tool SKILL.md files give the LLM scoped how-to docs on demand.
  • BM25 tool search (optional) โ€” replaces tools/list with search_tools + call_tool once 50+ schemas start eating context.
  • Tool catalog overview logged at startup (per-tier and per-group counts) so operators can see exactly what the LLM will be offered.

Walkthrough โ€” your first host in 5 minutes

The fastest path: agent auth + one host + read-only tier. Five steps:

1. Load your key into an agent

  • Linux/macOS: ssh-add ~/.ssh/id_ed25519
  • Windows: start Pageant and load your .ppk (or run ssh-agent + ssh-add)

Verify the agent is reachable:

uv run python -c "import asyncio; from ssh_mcp.ssh.agent import list_agent_fingerprints; print(asyncio.run(list_agent_fingerprints()))"

You should see one or more SHA256:... lines. Copy the one you intend to use โ€” you'll reference it below.

2. Pin the target's host key (verify BEFORE you trust)

Strict known_hosts verification is on by default (no auto-accept). Pinning is a three-step flow โ€” never append ssh-keyscan output directly into known_hosts.

# 2a. Scan to a scratch file. This does NOT trust anything yet.
ssh-keyscan -t ed25519,ecdsa,rsa web01.example.com > /tmp/web01.hostkey

# 2b. Print the fingerprint and compare OUT-OF-BAND.
ssh-keygen -lf /tmp/web01.hostkey
# โ†’ 256 SHA256:abc123... web01.example.com (ED25519)

Compare that SHA256:... against a source you trust that is not the network you just scanned over: the host's provisioner output, a console session, a terraform output, the sysadmin's Signal message. If they don't match, stop. A typo or MITM would pin a hostile key as trusted.

# 2c. Only after the out-of-band fingerprint matches, append and clean up.
cat /tmp/web01.hostkey >> ~/.ssh/known_hosts
rm /tmp/web01.hostkey

The MCP server refuses to connect if known_hosts is missing, empty, or doesn't match.

3. Write hosts.toml

Copy the annotated starter template and edit:

cp hosts.toml.example hosts.toml
# Replace the SHA256:REPLACE-WITH-... fingerprints with yours from step 1.

Or write from scratch โ€” the minimum viable block:

[defaults]
user = "deploy"

[defaults.auth]
method = "agent"
identity_fingerprint = "SHA256:<paste-your-fingerprint-here>"
identities_only = true

[hosts.web01]
hostname = "web01.example.com"
path_allowlist = ["/opt/app", "/var/log"]

See hosts.toml.example for bastion / proxy-jump, per-host key overrides, and the on-disk-key + keychain passphrase pattern. For multi-host recipes (bastions, per-role keys, legacy hosts) see CONFIGURATION.md โ†’ Configuring more hosts.

Inheriting from ~/.ssh/config

If you already maintain a populated ~/.ssh/config (host aliases, ProxyJump, IdentityFile, Ciphers/MACs overrides for legacy gear), point SSH_CONFIG_FILE at it and skip restating those fields in hosts.toml:

SSH_CONFIG_FILE=~/.ssh/config

Precedence: hosts.toml always wins. ~/.ssh/config only fills in fields you didn't set per-host โ€” the OpenSSH config can't broaden what path_allowlist, command_allowlist, or the host blocklist permit. Startup logs ssh_config: honoring <abs-path> (or a WARNING if the file is missing) so misconfiguration surfaces immediately.

4. Write .env

# Start locked down โ€” only read-only tools are active.
ALLOW_LOW_ACCESS_TOOLS=false
ALLOW_DANGEROUS_TOOLS=false
ALLOW_SUDO=false

# Optional safety rail
SSH_HOSTS_BLOCKLIST=

5. Verify end-to-end

uv run ssh-mcp

From an MCP client (or from a quick Python shell), call:

from ssh_mcp.server import mcp_server
# tools: ssh_host_ping, ssh_host_info, ssh_sftp_list, ssh_find, ...

ssh_host_ping(host="web01") should return {reachable: true, auth_ok: true, latency_ms: N, ...}.

If it fails, see Troubleshooting.


Querying audit logs

Every tool call writes one JSON line to the ssh_mcp.audit Python logger โ€” including read-tier tools (since v1.4.0, all tiers are audited). There is no in-process tool to query the audit log โ€” that is intentional (INC-052): if the LLM could read its own audit trail, a compromised or jailbroken agent could self-monitor what it has been caught doing and tune around it. Audit flows one-way to operators.

Wire ssh_mcp.audit to the sink of your choice (file, Loki, Splunk, Datadog, journald, ...) in your own logging config. For local debugging or quick incident triage, write the lines to a file and query with jq:

# In your own bootstrap (or a custom run_server wrapper)
import logging
h = logging.FileHandler("/var/log/ssh-mcp/audit.jsonl")
h.setFormatter(logging.Formatter("%(message)s"))
logging.getLogger("ssh_mcp.audit").addHandler(h)

Each line is a single compact JSON object. Schema:

fieldtypenotes
tsfloatUnix epoch seconds
correlation_idstr16 hex chars; pairs with the DEBUG-level full-error line on ssh_mcp.audit
toolstrThe MCP tool name (e.g. ssh_exec_run, ssh_broadcast)
tierstrread / low-access / dangerous / sudo
hoststrResolved hostname (or ? for fan-out tools like ssh_broadcast)
resultstrok / error
duration_msintWall-clock duration of the tool call
path_hashstrsha256:<16hex> of the canonical path (when the tool touched one)
command_hashstrsha256:<16hex> of the redacted command (when the tool ran one)
exit_codeintWhen applicable
errorstrException class name only โ€” full text stays at DEBUG level (INC-008)

Useful jq recipes:

# All errors in the last hour, sorted by tool
jq -r 'select(.result == "error") | "\(.ts) \(.tool) \(.host) \(.error)"' \
  /var/log/ssh-mcp/audit.jsonl | sort -k2

# Slowest dangerous-tier calls (top 20 by duration_ms)
jq 'select(.tier == "dangerous")' /var/log/ssh-mcp/audit.jsonl \
  | jq -s 'sort_by(-.duration_ms) | .[:20] | .[] | {tool, host, duration_ms}'

# Count by tool to see what the LLM is actually using
jq -r '.tool' /var/log/ssh-mcp/audit.jsonl | sort | uniq -c | sort -rn

# Trace one specific call end-to-end via correlation_id
jq 'select(.correlation_id == "a1b2c3d4e5f6abcd")' /var/log/ssh-mcp/audit.jsonl

The command_hash and path_hash fields are deduplication aids, not privacy controls โ€” short SHA-256 prefixes are trivially rainbow-tableable for common commands and canonical paths. If audit confidentiality matters, enforce it via transport encryption (TLS to your log backend) and access control on the sink itself.

Disclaimer

Python SSH MCP is local infrastructure that grants an LLM (or any MCP client) the ability to execute commands on remote systems over SSH. Use at your own risk. Default-deny tier flags and strict known_hosts enforcement protect against the obvious footguns, but no software can protect against an operator who flips every flag to true without understanding the blast radius.

Read DECISIONS.md before enabling the dangerous or sudo tier in production. Audit the ssh_mcp.audit JSON lines on a regular basis. When in doubt, leave a tier off.

This project is not affiliated with or endorsed by any SSH, FastMCP, or MCP provider.


Support

Building and maintaining this MCP server takes real time and effort, even with AI assistance. If this SSH MCP has made your workflow and life easier, please consider supporting me:

Issues, questions, and feedback: open a GitHub issue. If you find Python SSH MCP useful, consider starring the repo โ€” it genuinely helps.