使用说明
ssh-mcp
An MCP server that allows AI Agents to SSH with guardrails
Overview
ssh-mcp is a FastMCP-based MCP server that lets AI agents securely SSH into remote Linux systems. It exposes eight MCP tools:
| Tool | Description |
|---|---|
execute_ssh_command |
Run a shell command on a remote host and receive stdout, stderr, and exit code as separate fields |
read_command_output |
Read output that was too large to return inline — head, tail, slice, or grep it |
upload_file |
Push a text file to a remote host over SFTP |
download_file |
Pull a text file from a remote host over SFTP |
find |
Search for files and directories, returning structured JSON |
systemd_service |
Start, stop, restart, reload, enable, disable, or query a systemd unit |
reboot |
Reboot a host (requires explicit confirmation) and wait for it to return |
ping |
Check SSH reachability and report latency and uptime |
Key features
- Per-call connection targeting — specify host, username, and credentials on every call; no single pre-configured target required
- Connection pooling — SSH handshakes are reused across calls to the same
(host, port, username)triple - Graceful timeout — commands are bounded by a per-call
timeout(server-clamped); on expiry the remote process group receivesSIGTERM, thenSIGKILLafter a grace period, and partial output is still returned - CIDR allowlist — all targets are validated against a list of allowed CIDR ranges after DNS resolution; deny-by-default when no CIDRs are configured
- Automatic output spooling — responses above
SSH_MCP_INLINE_OUTPUT_LIMITare written to a TTL-swept disk spool and returned as a handle with a head/tail preview, so a 40 000-lineapt-getlog costs ~10 KB of context instead of megabytes; the agent pulls what it needs withread_command_output - Optional
working_directory— stateless execution with an optional per-call working directory - Sudo support — pass
sudo: trueon any tool; the remote user must have NOPASSWD sudo configured - Structured command logging — timestamp, host, command, exit code, and duration are logged on every execution
- Dual transport — run as a STDIO server (default) or as a streaming-HTTP server
Installation
pip install ssh-mcp
Or from source:
git clone https://github.com/dylanturn/ssh-mcp
cd ssh-mcp
pip install -e .
Configuration
All configuration is through environment variables:
| Variable | Default | Description |
|---|---|---|
SSH_MCP_ALLOWED_CIDRS |
(empty) | Required. Comma-separated CIDR ranges the agent may SSH into, e.g. 10.0.0.0/8,192.168.0.0/16. If empty, all connections are denied. |
SSH_MCP_HOST_KEY_POLICY |
reject |
reject (default, uses system known_hosts — most secure) or auto_add (accept any key, suitable for dynamic VMs on trusted networks). |
SSH_MCP_KNOWN_HOSTS_PATH |
(none) | Path to an additional known_hosts file (used when SSH_MCP_HOST_KEY_POLICY=reject). |
SSH_MCP_DEFAULT_USERNAME |
root |
SSH username used for every connection. |
SSH_MCP_DEFAULT_KEY_PATH |
(none) | Path on the server to the default SSH private key file. |
SSH_MCP_DEFAULT_TIMEOUT |
30 |
Command timeout used when a call does not pass its own timeout. |
SSH_MCP_MAX_TIMEOUT |
3600 |
Ceiling on the per-call timeout; longer requests are clamped, not rejected. |
SSH_MCP_MAX_OUTPUT_BYTES |
10485760 |
Hard cap on captured output (10 MiB). Data beyond it is discarded and truncated: true is set. |
SSH_MCP_INLINE_OUTPUT_LIMIT |
10240 |
Payloads above this are spooled to disk and returned as an output_id handle plus a head/tail preview. Nothing is lost. |
SSH_MCP_SPOOL_DIR |
(temp dir) | Where spooled output is written. Defaults to a per-process temporary directory removed at shutdown. If set explicitly, the directory is kept and only spool files are removed. |
SSH_MCP_SPOOL_TTL_SECONDS |
3600 |
Age at which spooled output is swept. 0 disables expiry. |
SSH_MCP_SPOOL_MAX_BYTES |
268435456 |
Total spool disk budget (256 MiB); oldest entries evicted first. |
SSH_MCP_READ_OUTPUT_MAX_BYTES |
65536 |
Per-call ceiling on a read_command_output response (64 KiB). |
SSH_MCP_POOL_MAX_IDLE |
5 |
Maximum idle connections per (host, port, username) triple. |
SSH_MCP_LOG_LEVEL |
INFO |
Python logging level (DEBUG, INFO, WARNING, ERROR). |
SSH_MCP_TRANSPORT |
stdio |
stdio or streamable-http. |
SSH_MCP_HOST |
0.0.0.0 |
Bind host for HTTP transport. |
SSH_MCP_PORT |
8000 |
Bind port for HTTP transport. |
Security: CIDR allowlist
SSH_MCP_ALLOWED_CIDRS is the primary security control. The server resolves hostnames to IP addresses before checking them against the allowlist, preventing DNS-based bypasses.
export SSH_MCP_ALLOWED_CIDRS="10.10.0.0/16,192.168.100.0/24"
If the variable is unset or empty, every SSH attempt returns an error immediately.
Usage
STDIO transport (default — for use with MCP clients like Claude Desktop)
SSH_MCP_ALLOWED_CIDRS="10.0.0.0/8" ssh-mcp
# or
SSH_MCP_ALLOWED_CIDRS="10.0.0.0/8" python -m ssh_mcp
HTTP transport
SSH_MCP_ALLOWED_CIDRS="10.0.0.0/8" \
SSH_MCP_TRANSPORT=streamable-http \
SSH_MCP_PORT=8080 \
ssh-mcp
Or via CLI flags:
SSH_MCP_ALLOWED_CIDRS="10.0.0.0/8" ssh-mcp --transport streamable-http --port 8080
Docker
A Dockerfile and docker-compose.yml are included. The container defaults to the streamable-http transport on port 8000.
Quick start with docker compose:
SSH_MCP_ALLOWED_CIDRS="10.0.0.0/8,192.168.0.0/16" docker compose up
The server is then reachable at http://localhost:8000/mcp.
Optional — use your host SSH keys inside the container:
The compose file bind-mounts ~/.ssh (read-only) into /root/.ssh so existing keys and known_hosts entries are available without any extra configuration. Set SSH_DIR to use a different directory:
SSH_DIR=/path/to/keys SSH_MCP_ALLOWED_CIDRS="10.0.0.0/8" docker compose up
Remove or comment out the volumes block in docker-compose.yml if you prefer to supply credentials per-call only.
Spooled output volume:
The compose file mounts a named volume ssh-mcp-spool at /var/spool/ssh-mcp (SSH_MCP_SPOOL_DIR) for oversized command output. This is bulk scratch data — up to SSH_MCP_SPOOL_MAX_BYTES (256 MiB by default) — so it belongs on a volume rather than in the container's writable layer. Size the volume for that budget, or lower the budget.
The spool holds captured remote output, which can include the contents of privileged files. A named volume outlives docker compose down; use docker compose down -v to remove it. If spooled output should never touch disk, swap the volume for the tmpfs block commented in docker-compose.yml:
- type: tmpfs
target: /var/spool/ssh-mcp
tmpfs:
size: 268435456
Running with docker run instead of compose? Pass -v ssh-mcp-spool:/var/spool/ssh-mcp. Without any mount the server still works — it falls back to a per-process temp directory removed at shutdown.
If you see Permission denied: /var/spool/ssh-mcp/out_….txt: the spool directory is not writable by the user the server runs as. The image creates it 1777 (sticky, like /tmp) so any uid can use it, but Docker only applies image permissions to an empty volume — a volume created by an earlier build keeps whatever it had. Recreate it:
docker compose down -v && docker compose up --build
To check what the container actually sees:
docker compose run --rm --entrypoint sh ssh-mcp -c 'id; stat -c "%a %U:%G" /var/spool/ssh-mcp'
The server no longer fails on this: the command still returns its output as an excerpt, with a spool_error explaining the cause. But there is deliberately no fallback to a temp directory — writing captured remote output somewhere you did not configure would defeat the point of choosing the location, so large output simply is not spooled until the directory is fixed.
Build and run manually with docker:
docker build -t ssh-mcp .
docker run --rm \
-e SSH_MCP_ALLOWED_CIDRS="10.0.0.0/8" \
-v ssh-mcp-spool:/var/spool/ssh-mcp \
-p 8000:8000 \
ssh-mcp
MCP Tool Reference
execute_ssh_command
Execute a shell command on a remote Linux host.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
host |
string | ✓ | Hostname or IP of the remote system |
command |
string | ✓ | Shell command to run |
port |
integer | — | SSH port (default 22) |
working_directory |
string | — | Working directory for this command (stateless) |
sudo |
boolean | — | Run via sudo; requires NOPASSWD sudo on the remote host |
timeout |
integer | — | Wall-clock seconds (default SSH_MCP_DEFAULT_TIMEOUT, clamped to SSH_MCP_MAX_TIMEOUT) |
The command is executed by a full shell, so builtins (cd, export, source), pipes, redirections, &&/; lists, and heredocs all behave normally.
The SSH username and key are configured server-side via SSH_MCP_DEFAULT_USERNAME and SSH_MCP_DEFAULT_KEY_PATH — they are not per-call parameters.
Response fields
| Field | Type | Description |
|---|---|---|
stdout |
string | Standard output, or a head/tail excerpt when spooled |
stderr |
string | Standard error, or a head/tail excerpt when spooled |
exit_code |
integer | Process exit code, or -1 on timeout |
timed_out |
boolean | true if the command exceeded timeout |
timeout_seconds |
integer | The timeout actually applied, after clamping |
truncated |
boolean | true if output was cut at SSH_MCP_MAX_OUTPUT_BYTES — data was lost, not spooled |
discarded_bytes |
integer | How much output was discarded when truncated |
duration_seconds |
float | Wall-clock execution time |
stdout_output_id |
string | Spooled only. Handle for read_command_output |
stdout_total_lines / stdout_total_bytes |
integer | Spooled only. Size of the full payload |
hint |
string | Spooled only. How to read the rest |
stderr_* equivalents appear when stderr is the stream that spooled. truncated and spooling are independent: spooling loses nothing, truncated does.
read_command_output
Read output that execute_ssh_command, download_file, or find spooled to disk.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
output_id |
string | ✓ | Handle from a previous response |
mode |
string | — | head (default), tail, slice, grep, or full |
lines |
integer | — | Line count for head/tail/slice (default 100) |
pattern |
string | — | Regular expression; required for mode="grep" |
ignore_case |
boolean | — | Case-insensitive grep |
context |
integer | — | Lines of context around each match, like grep -C |
max_matches |
integer | — | Stop after this many matches (default 100) |
start_line |
integer | — | 1-indexed line to start from, for mode="slice" and mode="grep" |
Response: content, start_line, end_line, lines_returned, total_lines, total_bytes, response_capped, next_start_line, source, plus matches / matches_returned in grep mode, and warning when the payload was truncated.
Every response is capped at SSH_MCP_READ_OUTPUT_MAX_BYTES; when it is, response_capped is set and next_start_line says where to resume — in grep mode too, where a resumed search continues after the last match returned.
response_capped (this response hit its ceiling — benign) is deliberately not named truncated (output was permanently lost — serious). source.truncated carries the latter through to every read.
upload_file
Upload a text file to a remote host via SFTP.
Parameters: host, remote_path, content, port, sudo
Response: { "success": true, "remote_path": "...", "bytes_written": N }
download_file
Download a text file from a remote host via SFTP.
Parameters: host, remote_path, port, sudo
Response: { "content": "...", "remote_path": "...", "truncated": false }, plus output_id, total_lines, total_bytes, and hint when the file is spooled.
Development
pip install -e ".[dev]"
pytest
Project layout
src/ssh_mcp/
├── __init__.py
├── __main__.py # python -m ssh_mcp entry point
├── config.py # pydantic-settings configuration
├── connection.py # SSH connection pool + command execution
├── output_store.py # on-disk spool for oversized output
├── security.py # CIDR allowlist validation
└── server.py # FastMCP tool definitions + CLI entry point
tests/
├── test_security.py
├── test_connection.py
├── test_output_store.py
└── test_server.py
