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.
In this file:
- Quick Start
- Features
- Installation
- Client Setup
- Walkthrough — your first host in 5 minutes
- Troubleshooting
- Disclaimer
- Support
- Install python-ssh-mcp
- Write your first
hosts.tomlentry (Walkthrough §3) - Set up your MCP Client (Claude Desktop, Claude Code, Cursor, ...)
- Ask the LLM to run
ssh_host_pingagainst your target — verifies agent +known_hosts+ pool end-to-end - Flip the tier flags you need (
ALLOW_LOW_ACCESS_TOOLS,ALLOW_DANGEROUS_TOOLS,ALLOW_SUDO) and the LLM unlocks file ops, exec, sudo — see CONFIGURATION.md → Access tiers
CONFIGURATION.md — configuring more hosts, access tiers, allowlist/blocklist, tool groups, Docker/Podman, per-host SSH identity, known_hosts management, sudo.
ADVANCED.md — runbooks (FastMCP Skills), hooks, observability, testing, key rotation, architecture, contributing.
- 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 FastMCPVisibilitytransforms. Default: read-only. - Ten tool groups orthogonal to tiers (
host,session,sftp-read,file-ops,exec,sudo,shell,docker,systemctl,pkg).SSH_ENABLED_GROUPStrims 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-inforesource +ssh_server_infofallback 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_downloadsupportlocal_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_execplus 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 rawsudo 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 cleanPlatformNotSupported - 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, withrestricted_pathscarve-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
PlatformNotSupportedthat names the missing capability. - Audit log — one JSON line per tool call (all tiers), paths/commands SHA-256-hashed,
errorfield is exception class only (full text stays at DEBUG locally). - Operator hooks: import any module via
SSH_HOOKS_MODULEforSTARTUP/SHUTDOWN/PRE_TOOL_CALL/POST_TOOL_CALLevents. Bounded per-hook timeout, exception isolation, backlog warning when pending tasks pile up. - Runbooks via FastMCP Skills — per-tool
SKILL.mdfiles give the LLM scoped how-to docs on demand. - BM25 tool search (optional) — replaces
tools/listwithsearch_tools+call_toolonce 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.
Python SSH MCP is a standard PEP 621 Python package (hatchling build backend). Use whichever installer you prefer — uv is the recommended path:
uv sync # create .venv + install runtime deps + dev extras
uv run ssh-mcp # start the server on stdio
# Or without syncing a venv first — build + run in an ephemeral environment:
uvx --from . ssh-mcp
# Plain pip also works (PEP 517):
pip install -e ".[dev]"
ssh-mcp
# FastMCP shortcuts (once the package is installed):
fastmcp dev inspector # dev UI: MCP Inspector + hot reload; auto-finds fastmcp.json
fastmcp run # run the server; auto-finds fastmcp.json
fastmcp run -t http -p 8000 # HTTP transport instead of stdioOptional dependency groups:
.[tasks]— adds the Redis client (redis>=5.0.0) for a production task backend. The FastMCP tasks runtime itself (docket, in-memory by default) is a hard dep viafastmcp[tasks]and ships regardless — install this extra only when pointingFASTMCP_DOCKET_URLat a real Redis (task-loss on restart matters in prod withALLOW_DANGEROUS_TOOLS=true; see_warn_task_backend)..[telemetry]— OpenTelemetry distro + OTLP exporter..[dev]— pytest, ruff, mypy.
MCP clients (Claude Desktop, Claude Code, Cursor) discover the server via fastmcp.json.
Every major MCP client accepts a JSON snippet that tells it how to spawn the
server. The shape is standardized around an mcpServers object — only the
file path differs per client. Pick your client, paste the snippet into the
right file, restart the client so the subprocess is respawned.
Heads-up:
fastmcp.json'sdeployment.envblock (if you keep one in the project) overrides the client env unconditionally — keep tier flags out of that block and let them come from the client config or your.env. If the client appears to hold a stale subprocess after code changes, see Troubleshooting — most clients spawn and own the server subprocess, so a terminal-side restart is not enough.
The base snippet (used by Claude Desktop, Cursor, Windsurf, Kilocode — most clients speak this dialect):
{
"mcpServers": {
"ssh-mcp": {
"command": "uvx",
"args": ["--from", "git+https://github.com/Nightreaver/python-ssh-mcp", "ssh-mcp"],
"env": {
"LOG_LEVEL": "INFO",
"ALLOW_LOW_ACCESS_TOOLS": "false",
"ALLOW_DANGEROUS_TOOLS": "false",
"ALLOW_SUDO": "false"
}
}
}
}uvx --from <path> ssh-mcp builds and runs the server in an ephemeral
uv-managed environment; no persistent venv needed on the client side.
Alternative: replace with "command": "fastmcp", "args": ["run", "<path-to-clone>/fastmcp.json"] if you already have a venv
with fastmcp on PATH.
Flip the ALLOW_* flags as you grant capability to the assistant. Keep
read-only as the default and open the exec/sudo tiers only where you need
them.
Config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Paste the base snippet. Fully quit Claude Desktop (tray icon → Quit) and relaunch — reload does NOT re-spawn MCP subprocesses.
CLI command, no JSON editing needed:
claude mcp add --transport stdio ssh-mcp -- uvx --from git+https://github.com/Nightreaver/python-ssh-mcp ssh-mcpFor scope control: --scope user (global), --scope project (commits
.mcp.json to the current repo), --scope local (default, this project
only). Env vars via repeated --env KEY=VALUE flags, or by editing
~/.claude/mcp.json after the fact.
Config file:
- Global:
~/.cursor/mcp.json - Per-workspace:
<workspace>/.cursor/mcp.json
Same mcpServers shape as the base snippet. Cursor picks up config
changes on the next chat session — no full restart needed.
VS Code's MCP support (1.102+) uses a slightly different key. Config file:
- Per-workspace:
<workspace>/.vscode/mcp.json - Global user settings:
settings.json→"mcp.servers"
Workspace .vscode/mcp.json:
{
"servers": {
"ssh-mcp": {
"type": "stdio",
"command": "uvx",
"args": ["--from", "git+https://github.com/Nightreaver/python-ssh-mcp", "ssh-mcp"],
"env": {
"LOG_LEVEL": "INFO",
"ALLOW_LOW_ACCESS_TOOLS": "false",
"ALLOW_DANGEROUS_TOOLS": "false",
"ALLOW_SUDO": "false"
}
}
}
}Note the top-level key is servers (not mcpServers) and each entry
carries a "type": "stdio" discriminator. Reload the VS Code window
(Developer: Reload Window) to respawn.
Config file: ~/.codeium/windsurf/mcp_config.json. Same mcpServers
shape as the base snippet. Restart Windsurf after editing.
Config file: ~/.kilocode/mcp.json (or the equivalent in your Kilocode
install — the extension docs list the exact path). Same mcpServers
shape as the base snippet. Reload the VS Code window after editing.
Config file: ~/.continue/config.json. Continue uses a nested key:
{
"experimental": {
"modelContextProtocolServers": [
{
"transport": {
"type": "stdio",
"command": "uvx",
"args": ["--from", "git+https://github.com/Nightreaver/python-ssh-mcp", "ssh-mcp"]
}
}
]
}
}Note: list-of-objects, not a named map. Env vars go inside the
transport block.
Config via the Command Palette: assistant: configure context servers,
or edit ~/.config/zed/settings.json:
{
"context_servers": {
"ssh-mcp": {
"source": "custom",
"command": {
"path": "uvx",
"args": ["--from", "git+https://github.com/Nightreaver/python-ssh-mcp", "ssh-mcp"],
"env": {}
}
}
}
}For debugging the server before wiring it into a real client:
fastmcp dev inspectorAuto-detects fastmcp.json, launches the web UI with a live MCP
Inspector attached. Hot-reload on source changes.
The fastest path: agent auth + one host + read-only tier. Five steps:
- Linux/macOS:
ssh-add ~/.ssh/id_ed25519 - Windows: start Pageant and load your
.ppk(or runssh-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.
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.hostkeyThe MCP server refuses to connect if known_hosts is missing, empty, or doesn't match.
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.
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/configPrecedence: 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.
# 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=uv run ssh-mcpFrom 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.
The agent resolution order is: explicit identity_agent in hosts.toml → SSH_AUTH_SOCK env var → Windows auto-detect (Pageant / OpenSSH pipe). Check:
# Unix / macOS
echo $SSH_AUTH_SOCK
ssh-add -l
# Windows (PowerShell)
Get-Process Pageant -ErrorAction SilentlyContinue
uv run python -c "import asyncio; from ssh_mcp.ssh.agent import list_agent_fingerprints; print(asyncio.run(list_agent_fingerprints()))"The fingerprint in hosts.toml doesn't match any key the agent exposes. List what's actually loaded:
uv run python -c "import asyncio; from ssh_mcp.ssh.agent import list_agent_fingerprints; [print(fp) for fp in asyncio.run(list_agent_fingerprints())]"Copy one of the reported fingerprints into identity_fingerprint.
Either the host key changed (rotation or MITM) or known_hosts is missing the entry. Do not bypass this from the LLM, and do not >> a scan directly into known_hosts without verification. Use the three-step flow from Walkthrough §2:
ssh-keyscan -t ed25519,ecdsa <host> > /tmp/h.hostkey
ssh-keygen -lf /tmp/h.hostkey # compare fingerprint out-of-band
cat /tmp/h.hostkey >> ~/.ssh/known_hosts && rm /tmp/h.hostkeyThe host isn't in hosts.toml and isn't in SSH_HOSTS_ALLOWLIST. Resolution tries the input first as a hosts.<alias> key, then against hosts.*.hostname, then against the env allowlist. Add a hosts.toml entry or add the literal hostname to SSH_HOSTS_ALLOWLIST.
Deny wins — check SSH_HOSTS_BLOCKLIST. This is intentional; remove the entry if the block was a mistake, but first confirm with the operator who added it.
The resolved (canonicalized) path is outside every root in path_allowlist. Check:
- Is the path correct? Low-access tools resolve symlinks before checking, so a symlink pointing out of
/opt/appwill be rejected even if the link itself lives there. - Does
hosts.<name>.path_allowlistcover the target?
The loader warns if you enable exec without scoping what commands are allowed. Either set SSH_COMMAND_ALLOWLIST or an empty command_allowlist per host (explicit = no restriction).
- Check the tier:
ALLOW_LOW_ACCESS_TOOLS/ALLOW_DANGEROUS_TOOLS/ALLOW_SUDOare default-deny. - Check the group:
SSH_ENABLED_GROUPS(empty = all; explicit = filter). - Restart the MCP client — tool lists are cached per MCP server version.
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:
| field | type | notes |
|---|---|---|
ts |
float | Unix epoch seconds |
correlation_id |
str | 16 hex chars; pairs with the DEBUG-level full-error line on ssh_mcp.audit |
tool |
str | The MCP tool name (e.g. ssh_exec_run, ssh_broadcast) |
tier |
str | read / low-access / dangerous / sudo |
host |
str | Resolved hostname (or ? for fan-out tools like ssh_broadcast) |
result |
str | ok / error |
duration_ms |
int | Wall-clock duration of the tool call |
path_hash |
str | sha256:<16hex> of the canonical path (when the tool touched one) |
command_hash |
str | sha256:<16hex> of the redacted command (when the tool ran one) |
exit_code |
int | When applicable |
error |
str | Exception 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.jsonlThe 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.
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.
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.