Python SSH MCP
About
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.
Details
- Author
- Nightreaver
- Downloads
- 292
- Categories
- Productivity, Infrastructure, Other, Developer Tools
Jump to
- SSH-based remote server tools
- Permission layer for controlled access
- Skills toolset
- Docker management toolset
- Systemctl service management toolset
- Runbooks automation support
Setting up with Highlight
This MCP is not yet compatible with Highlight’s one-click setup. However, you can still use it with Highlight by following these steps:
- Download and install Highlight from highlightai.com/download
- Navigate to the plugins tab and select "Add Custom Plugin"
-
Configure the plugin with the settings below
Plugin Name
Python SSH MCPCommand (node, npx, python, etc.)Please refer to the README for specific instructions on how to obtain API keys or other required environment variables.
- Enable "Start Automatically" if you want the plugin to start when Highlight launches
From the repository
—
Claude Desktop / Cursor
Paste into your MCP client config file to install this server.
{
"mcpServers": {
"python ssh mcp": {
"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"
}
}
}
}
}
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"
}
}
}
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.
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 singlessh_exectool, 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-intests/e2e/suite that drives every tool against the operator's realhosts.toml. Strictknown_hostsby 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 intools/list(not genericobject); the few that legitimately produce merged or bimodal payloads stay asdict[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_hashvia PowerShell-EncodedCommand(seeADR-0023); Docker CLI swappable for Podman viaSSH_DOCKER_CMD/ per-hostdocker_cmd.
- Quick Start
- Features
- Installation
- Client Setup
- Walkthrough — your first host in 5 minutes
- Troubleshooting
- Disclaimer
- Support
- Installpython-ssh-mcp
- Write your firsthosts.tomlentry (Walkthrough §3)
- Set upyour MCP Client (Claude Desktop, Claude Code, Cursor, ...)
- Ask the LLM to runssh_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 — seeCONFIGURATION.md → Access tiers
CONFIGURATION.md— configuring more hosts, access tiers, allowlist/blocklist, tool groups, Docker/Podman, per-host SSH identity,known_hostsmanagement, sudo.ADVANCED.md— runbooks (FastMCP Skills), hooks, observability, testing, key rotation, architecture, contributing.
- MCP-compliant serverexposing 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 groupsorthogonal 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: seeTOOLS.mdfor 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 catchescat/tee/sudo cat/... and reroutes to the right native tool
- Sudo tier —ssh_sudo_execplusfive 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)
Python SSH MCP is a standard PEP 621 Python package (hatchling build backend). Use whichever installer you prefer —uvis 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 stdio
- .[tasks]— adds theRedis 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 viafastmcp.json.
Every major MCP client accepts a JSON snippet that tells it how to spawn the server. The shape is standardized around anmcpServersobject — 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, seeTroubleshooting— 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-mcpbuilds and runs the server in an ephemeraluv-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 withfastmcpon PATH.
Flip theALLOW_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.
- 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.
claude mcp add --transport stdio ssh-mcp -- uvx --from git+https://github.com/Nightreaver/python-ssh-mcp ssh-mcp
For scope control:--scope user(global),--scope project(commits.mcp.jsonto the current repo),--scope local(default, this project only). Env vars via repeated--env KEY=VALUEflags, or by editing~/.claude/mcp.jsonafter the fact.
- Global:~/.cursor/mcp.json
- Per-workspace:<workspace>/.cursor/mcp.json
SamemcpServersshape as the base snippet. Cursor picks up config changes on the next chat session — no full restart needed.
VS Code (GitHub Copilot Chat / Agent Mode)
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"
{ "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 isservers(notmcpServers) 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. SamemcpServersshape 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). SamemcpServersshape 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 thetransportblock.
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:
Auto-detectsfastmcp.json, launches the web UI with a live MCP Inspector attached. Hot-reload on source changes.
Walkthrough — your first host in 5 minutes
The fastest path:agent auth + one host + read-only tier. Five steps:
- Linux/macOS:ssh-add ~/.ssh/id_ed25519
- Windows:startPageantand load your.ppk(or runssh-agent+ssh-add)
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 moreSHA256:...lines. Copy the one you intend to use — you'll reference it below.
2. Pin the target's host key (verify BEFORE you trust)
Strictknown_hostsverification is on by default (no auto-accept).Pinning is a three-step flow — never appendssh-keyscanoutput directly intoknown_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 thatSHA256:...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 ifknown_hostsis 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"]
Seehosts.toml.examplefor 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) seeCONFIGURATION.md → Configuring more hosts.
If you already maintain a populated~/.ssh/config(host aliases,ProxyJump,IdentityFile,Ciphers/MACsoverrides for legacy gear), pointSSH_CONFIG_FILEat it and skip restating those fields inhosts.toml:
Precedence:hosts.tomlalways wins.~/.ssh/configonly fills in fields you didn't set per-host — the OpenSSH config can't broaden whatpath_allowlist,command_allowlist, or the host blocklist permit. Startup logsssh_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=
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, ...}.
The agent resolution order is: explicitidentity_agentinhosts.toml→SSH_AUTH_SOCKenv 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()))"
identity 'SHA256:...' not found in agent
The fingerprint inhosts.tomldoesn'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 intoidentity_fingerprint.
Either the host key changed (rotation or MITM) orknown_hostsis missing the entry. Donotbypass this from the LLM, and donot>>a scan directly intoknown_hostswithout verification. Use the three-step flow fromWalkthrough §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.hostkey
The host isn't inhosts.tomland isn't inSSH_HOSTS_ALLOWLIST. Resolution tries the input first as ahosts.<alias>key, then againsthosts..hostname, then against the env allowlist. Add ahosts.tomlentry or add the literal hostname toSSH_HOSTS_ALLOWLIST.
Deny wins — checkSSH_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 inpath_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.
- Doeshosts.<name>.path_allowlistcover the target?
command_allowlist is empty but ALLOW_DANGEROUS_TOOLS=true
The loader warns if you enable exec without scoping what commands are allowed. Either setSSH_COMMAND_ALLOWLISTor an emptycommand_allowlistper 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 thessh_mcp.auditPython logger — including read-tier tools (since v1.4.0, all tiers are audited). There isno 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.
Wiressh_mcp.auditto 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 withjq:
# 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:
# 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
Sign in to leave a review
Use Google, GitHub, or an email account so ratings stay tied to real people.
No reviews posted yet.




