SSH代理服务器

一个基于FastMCP的MCP服务器,允许AI代理通过SSH安全地连接到远程Linux系统,并提供多种工具以实现远程命令执行、文件传输等功能。

  • by dylanturn
  • Python
  • 本地部署
  • 1 星标
  • 更新于
在 GitHub 查看 dylanturn/ssh-mcp

使用说明

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 receives SIGTERM, then SIGKILL after 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_LIMIT are written to a TTL-swept disk spool and returned as a handle with a head/tail preview, so a 40 000-line apt-get log costs ~10 KB of context instead of megabytes; the agent pulls what it needs with read_command_output
  • Optional working_directory — stateless execution with an optional per-call working directory
  • Sudo support — pass sudo: true on 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