The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Ssh MCP listing page.
A centralized MCP gateway that gives AI agents controlled access to SSH infrastructure over Streamable HTTP.
ssh-mcp runs as a single HTTP service. Multiple AI clients — agents, CI pipelines, dashboards — connect to one gateway. SSH credentials stay on the gateway. Authorization policies, audit logging, and rate limiting are applied centrally before any SSH command executes.
Each agent runs its own process. SSH credentials live on every machine. No centralized control.
A single deployment serves all clients. Credentials, policies, and logs live in one place.
Different agents need different permissions. ssh-mcp enforces this at the gateway:
A minimal config demonstrating this setup:
Most MCP SSH servers run as local stdio processes — one per client, with no shared state, no centralized authorization, and no audit trail. When multiple AI agents, CI pipelines, or dashboards need SSH access, each one independently manages its own SSH keys and runs its own MCP process. This creates:
ssh-mcp solves this by deploying a single MCP server as an HTTP gateway. All clients connect to it; it connects to your SSH targets. Authorization, authentication, rate limiting, connection pooling, and audit logging happen in one place.
Run a team of AI agents with different access levels. The deployment agent can systemctl restart nginx on web servers; the monitoring agent can journalctl everywhere; the database agent can only run psql on the DB server. Each agent authenticates with its own API key; each key has its own permission set.
Point your CI pipeline at ssh-mcp instead of managing SSH keys on every runner. A single API key per pipeline, network-based rules for your CI subnet, and command allowlists ensure your deployment scripts run exactly what they should — nothing more.
Use ssh_download_file to pull logs, config files, or database dumps from remote servers without leaving your MCP client. The 8-layer path validation and sandbox root settings ensure file transfers stay within safe boundaries.
Build an MCP-powered dashboard that queries uptime, free, df, and ps across your fleet. The connection pool reuses SSH sessions, the circuit breaker isolates failing targets, and Prometheus metrics at /metrics feed your existing monitoring stack.
Every command is logged with structured JSONL: who ran what, on which server, from which IP, whether it was allowed, and how long it took. The matched_via field traces exactly which authorization layer made the decision. Config changes are logged separately with before/after state.
ssh-mcp applies defense-in-depth at every layer. The full security model is documented in docs/SECURITY.md.
Security boundary: ssh-mcp adds an authorization, authentication, and auditing layer in front of SSH. It does not replace the permissions of the underlying SSH accounts. If a command is allowed, the SSH user executes it with whatever privileges that account has. The gateway itself should be protected with TLS and network access controls. Logs may contain command output and should be treated accordingly.
Commands are evaluated through an ordered, layered chain. If any layer denies, the request stops there:
| Layer | What it checks |
|---|---|
| 1. Target validation | Is the server name known? |
2. block_patterns | Does the command match a blocked regex? |
| 3. Dangerous patterns | Does it contain $(), backticks, or newlines? |
| 4. Redirection guard | Do shell redirects target /dev/, /proc/, /sys/? |
| 5. Segmentation | After stripping redirects and splitting on &&, ` |
6. default rules | All-client allow/deny rules |
7. api_keys rules | Per-key allow/deny rules |
8. networks rules | Per-CIDR allow/deny rules |
| 9. Deny | Implicit fallback |
API keys are sent via X-API-Key or Authorization: Bearer headers. Keys are hashed with PBKDF2-HMAC-SHA256 (100,000 iterations, random 16-byte salt) and verified with constant-time comparison. Raw keys are never stored.
Commands, target names, and log strings are sanitized before processing: null bytes stripped, control characters removed, NFKC-normalized, and run through ReDoS protection for block_patterns.
SFTP transfers go through 8-layer path validation including null-byte checks, control-character stripping, dot-segment normalization, symlink resolution, and sandbox-root enforcement.
Sliding-window rate limiter per client IP (60 requests / 60 seconds, /health exempt). Violations return HTTP 429 with Retry-After.
Rate limiting is configurable under settings.rate_limit:
Note: the rate limiter is built once at container startup from the initial config and is not rebuilt on config hot-reload. To disable rate limiting you must set
settings.rate_limit.enabledtofalsein the config present at boot (e.g.config/ssh-mcp-config.jsonin the mounted volume). This is useful for high-volume clients or test suites that issue many requests from a single IP.
Open config/ssh-mcp-config.json and add one target:
Any MCP client supporting Streamable HTTP can connect. Point it at http://localhost:9080/mcp with an API key header. See MCP Client Configuration for details.
Any MCP client supporting Streamable HTTP transport can connect. The configuration format varies by client — use the URL and headers below.
| Setting | Value |
|---|---|
| Transport | Streamable HTTP |
| URL | https://ssh-mcp.example.com/mcp |
| Authentication | X-API-Key header or Authorization: Bearer |
Send tool calls as JSON-RPC tools/call requests to /mcp:
All tool calls are JSON-RPC tools/call requests to /mcp. All tools return a string (JSON or plain text).
| Tool | Parameters | Description |
|---|---|---|
ssh_list_servers | (none) | List configured SSH targets (host, port, username — no secrets) |
ssh_list_allowed_commands | server_name (str) | List commands the current client may run on a target (union of default + api_key + network rules) |
ssh_execute_command | server_name (str), command (str), timeout (int, default 30), sudo (bool, default false) | Execute a command over SSH; returns stdout (stderr appended as [STDERR], exit code as [EXIT: n]) |
ssh_download_file | server_name (str), remote_path (str) | Download a file via SFTP; authorization equivalent to cat <path> |
ssh_upload_file | server_name (str), remote_path (str), content (str), permissions (str, default "0644") | Upload a file via SFTP; authorization equivalent to tee <path> |
ssh_check_connection | server_name (str), timeout (int, default 10) | Check SSH connectivity by running the target's checkcommand; returns success flag, output, and exit code |
All MCP tools follow the ssh_<verb>_<noun> naming pattern:
| Tool | Pattern |
|---|---|
ssh_list_servers | ssh_ + list + servers |
ssh_list_allowed_commands | ssh_ + list + allowed_commands |
ssh_execute_command | ssh_ + execute + command |
ssh_check_connection | ssh_ + check + connection |
ssh_download_file | ssh_ + download + file |
ssh_upload_file | ssh_ + upload + file |
The ssh_ prefix is redundant across all tools but is intentionally preserved for MCP API contract stability — renaming tools would break existing MCP client integrations that reference tool names by string. New tools added in the future must follow this same convention.
Note on sudo: There is no
sudo_passwordparameter. If sudo requires a password, it comes from the target'spasswordfield in the config. Thesudoflag wraps withsudo -S -p ''(password from config) orsudo -n(passwordless).
On failure a tool returns:
Common error_type values: AuthorizationError, PathValidationError, FileTransferError, SSHAuthenticationError, SSHTimeoutError, MCPSSHError. The retryable flag is true for SSHTimeoutError. Rate-limit violations return HTTP 429 instead.
The server reads <config_dir>/ssh-mcp-config.json. Set config_dir via --config CLI flag or MCP_SSH_CONFIG_PATH environment variable (default: /config). If the file doesn't exist, the server writes a bundled default-config.json.
The config is validated against config.schema.json (JSON Schema Draft 2020-12) at load time. Unknown keys cause a hard error.
ssh_targetsAn object keyed by server identifier. Each target requires host, port, username, and at least one of private_key or password.
| Field | Required | Default | Description |
|---|---|---|---|
host | Yes | — | Hostname or IP address |
port | No | 22 | SSH port |
username | Yes | — | SSH username |
private_key | * | — | Path to SSH private key file on the server filesystem |
password | * | — | SSH password (can also be set via secrets.json or env vars) |
checkcommand | No | "echo ping" | Command executed by ssh_check_connection to verify connectivity |
* At least one of private_key or password is required.
private_keyis a path on the server's filesystem (in Docker, mounted into the container), not an inline key.
block_patternsA list of regex patterns. Any command matching a pattern is denied regardless of other allow-list layers. Patterns are screened for catastrophic-backtracking constructs at load time (ReDoS protection) and compiled with timeout guards at runtime.
allowed_commandsThree sub-objects control which commands each client may run:
default — rules for all clients (unless a more specific layer decides first)api_keys — per-key rules, matched by key_hashnetworks — per-CIDR rules, matched by client source IPEach rule has a targets list (server ids or "*" for all) and a commands list (base command names or "*" for any command).
settings| Setting | Default | Description |
|---|---|---|
max_output_length | 50000 | Max bytes of command output returned to client (int or size string) |
command_timeout_max | 120 | Hard cap on command timeout (seconds) |
retry_max_attempts | 3 | Retry attempts for transient SSH failures |
retry_backoff_base_seconds | 1.0 | Base exponential backoff (seconds) |
circuit_breaker_failure_threshold | 5 | Failures before the circuit opens per target |
circuit_breaker_timeout_seconds | 60.0 | Recovery timeout for an open circuit (seconds) |
log_level | "INFO" | Log level: DEBUG, INFO, WARNING, ERROR |
max_log_output | 4096 | Max chars of output stored in log entries |
compress_rotated | true | Gzip rotated log files |
pool_max_connections_per_target | 5 | Max pooled SSH connections per target |
pool_idle_timeout_seconds | 300.0 | Idle connection timeout (seconds) |
pool_cleanup_interval_seconds | 60.0 | Pool cleanup interval (seconds) |
max_concurrent_ssh_connections | 20 | Global cap across all targets; excess returns HTTP 503 |
watcher_debounce_seconds | 2.0 | Min gap between config reloads; 0 disables |
trusted_proxies | [] | Trusted reverse-proxy IPs (IPv4/IPv6) |
settings.sftp)| Setting | Default | Description |
|---|---|---|
sftp.sandbox_root | "/" | Root directory for SFTP path validation |
sftp.max_path_length | 4096 | Maximum allowed SFTP path length (bytes); 0 disables |
SSH target passwords and API-key hashes can be separated from the main config into <config_dir>/secrets.json or MCP_SSH_SECRET_* environment variables. Precedence:
| Secret source | Effect |
|---|---|
secrets.json | Per-target password and per-key key_hash overrides (matched by name) |
MCP_SSH_SECRET_PASSWORD_<TARGET_ID> | Override ssh_targets[<TARGET_ID>].password |
MCP_SSH_SECRET_API_KEY_<KEY_NAME> | Override key_hash for api_keys entry <KEY_NAME> |
<TARGET_ID> and <KEY_NAME> are upper-cased with - → _. API-key values must be hash strings, not raw keys.
| Environment variable | CLI flag | Default | Legacy fallback |
|---|---|---|---|
MCP_SSH_CONFIG_PATH | --config | /config | CONFIG_DIR |
MCP_SSH_SSH_KEY | --ssh-key | ssh_key | SSH_KEY_PATH |
MCP_SSH_LOG_DIR | --log-dir | /logs | LOG_DIR |
MAX_OUTPUT_LENGTH | --max-output | 50000 | — |
CONFIG_API_ENABLED | — | false | — |
CONFIG_API_TOKEN | — | (required when API enabled) | — |
| — | --fix-permissions | False | — |
| — | --print-default-config | — | — |
CLI flags take precedence over environment variables. Any settings key can be overridden at runtime with MCP_SSH_SETTING_<KEY> (upper-cased, - → _).
The server polls the config file for changes (15 s interval, 2 s debounce). When a change is detected, it reloads, validates, and atomically swaps in the new configuration. Config-change callbacks (authorization rules rebuild, connection pool refresh) run after the swap succeeds. Watchdog-based file monitoring is used when available.
GET /health returns {"status": "ok"} plus connection pool stats. The container's HEALTHCHECK uses this endpoint.
GET /metrics exposes metrics on a dedicated registry, all prefixed mcpssh_:
| Metric | Type | Labels |
|---|---|---|
mcpssh_requests_total | Counter | tool, status (success/error/denied) |
mcpssh_ssh_connections_total | Counter | target |
mcpssh_ssh_connection_duration_seconds | Histogram | target |
mcpssh_auth_denials_total | Counter | reason |
mcpssh_command_duration_seconds | Histogram | target |
mcpssh_pool_active_connections | Gauge | target |
mcpssh_pool_idle_connections | Gauge | target |
mcpssh_pool_created_total | Counter | target |
The mcp-ssh server supports pluggable log targets configured via settings.logging.log_targets in the config file. Each target is an independent driver that receives all log entries.
By default, log entries are written to stdout in human-readable text format. This is suitable for Docker environments where container logs are captured by the runtime.
| Target | Config value | Format | Description |
|---|---|---|---|
| Stdout | "stdout" | Text | Writes to stdout. Default target. |
| JSON File | "jsonfile" | JSONL | Writes one JSON object per line to a file. |
| Text File | "file" | Text | Writes human-readable text to a file. |
settings.log_level to control the default level.MCP_SSH_LOG_LEVEL to override the config-file default (e.g., MCP_SSH_LOG_LEVEL=DEBUG).log_level that overrides the default.If settings.logging is absent, the server falls back to a single JSONL file target in the log directory (/logs by default). This maintains backward compatibility with existing configurations.
Stdout and text-file targets use the format:
JSON-file targets write one JSON object per line:
File-based targets rotate when they exceed max_file_size_mb (default: 10 MiB), keeping backup_count backups (default: 5). Rotated files are gzip-compressed when compress_rotated is true.
| Event | Meaning |
|---|---|
config.load | Initial config loaded at startup |
config.reload | Config re-read from disk (with success, changed_keys, targets_added, targets_removed) |
config.migrated | Schema migration applied (from_version, to_version) |
config.default_created | Bundled default config copied |
config.fallback | Fell back to in-memory defaults |
config.callback_error | Config-change callback raised exception |
The unified container includes an optional Configuration API and Web Dashboard — a full management plane for your SSH policy, targets, command rules, and backups. No config-file editing required. This feature is disabled by default.
/api/docs and /api/redoc.Set these environment variables in your compose.yaml or .env file:
| Variable | Default | Description |
|---|---|---|
CONFIG_API_ENABLED | false | Set to true to enable the Configuration API |
CONFIG_API_TOKEN | (required when enabled) | Bearer token for authenticating API requests |
CONFIG_API_SESSION_COOKIE_SECURE | true | Set to false to disable the Secure flag on session cookies (for HTTP-only local development) |
All endpoints are mounted at /api on the same Starlette ASGI application as the MCP server.
| Method | Path | Description |
|---|---|---|
GET | /api/health | Health check for the config API (no auth required) |
POST | /api/hash-key | Hash a plaintext API key into a PBKDF2-HMAC-SHA256 string |
GET | /api/config/schema | Return the config JSON Schema (no auth required) |
POST | /api/config/validate | Validate a config dict without writing it to disk |
| Method | Path | Description |
|---|---|---|
GET | /api/config | Get the full configuration (redacts secrets) |
PUT | /api/config | Replace the full configuration |
GET | /api/config/{section} | Get a single config section (settings, ssh_targets, allowed_commands, block_patterns) |
PUT | /api/config/{section} | Replace a single config section |
| Method | Path | Description |
|---|---|---|
GET | /api/config/ssh_targets/{name} | Get a specific SSH target (secrets stripped) |
PUT | /api/config/ssh_targets/{name} | Create or replace an SSH target |
DELETE | /api/config/ssh_targets/{name} | Delete an SSH target |
POST | /api/config/ssh_targets/{name}/check | Test SSH connectivity via the target's checkcommand |
| Method | Path | Description |
|---|---|---|
GET | /api/config/allowed_commands | List allowed command rules (via GET /api/config/{section}) |
PUT | /api/config/allowed_commands | Replace allowed command rules (via PUT /api/config/{section}) |
| Method | Path | Description |
|---|---|---|
GET | /api/config/block_patterns | List block patterns (via GET /api/config/{section}) |
PUT | /api/config/block_patterns | Replace all block patterns |
POST | /api/config/block_patterns | Append a block pattern |
PUT | /api/config/block_patterns/{index} | Replace a single block pattern by index |
DELETE | /api/config/block_patterns/{index} | Remove a single block pattern by index |
| Method | Path | Description |
|---|---|---|
GET | /api/backups | List config backups (newest first) |
POST | /api/backups/{name}/restore | Restore configuration from a backup |
DELETE | /api/backups/{name} | Delete a backup file |
All API requests (except /api/health and /api/config/schema) require a Bearer token in the Authorization header:
When enabled, a responsive single-page application is available at http://localhost:9080/ui/ — a full management UI built with Tailwind CSS. No page reloads, toast notifications for every operation, and modal dialogs for editing.
| Page | Capabilities |
|---|---|
| SSH Targets | View, add, edit, delete targets; inline connectivity testing via checkcommand; table view with host/port/username |
| Block Patterns | Add, edit (by index), delete individual patterns; view the full pattern list |
| Command Rules | Edit default, API-key, and network rules; full rules editor with target and command lists |
| Settings | Edit all server settings: SFTP sandbox, rate limiting, logging, connection pooling, circuit breaker, and more |
| Backups | List, restore, and delete configuration backups; timestamp and size for each backup |
Additional features:
sessionStorage)Interactive API documentation is auto-generated by FastAPI:
http://localhost:9080/api/docshttp://localhost:9080/api/redocThe compose.yaml defines a single mcp-ssh service that hosts both the MCP server and, optionally, the Configuration API & Web Dashboard. The config API is enabled via the CONFIG_API_ENABLED environment variable (default: false).
mcp-ssh — MCP SSH Gateway + Config API| Host path | Container path | Mode |
|---|---|---|
./config | /config | rw |
./logs | /logs | rw |
./ssh_key | /app/ssh_key | ro |
./ssh_key.pub | /app/ssh_key.pub | ro |
Exposed on host port 9080 (maps to container port 8080). The runtime image is python:3.13-alpine with a hash-pinned digest. A non-root mcpssh user runs the process. A CycloneDX SBOM is generated at build time in the sbom stage.
Enable the config API by setting CONFIG_API_ENABLED=true in your .env file or environment:
When enabled, the config API is mounted at /api on the same HTTP server as the MCP gateway. It provides:
http://localhost:9080/api/... — full CRUD for SSH targets, block patterns, command rules, backups, and settingshttp://localhost:9080/ui/ — a single-page application for visual policy management (SSH targets, block patterns, command rules, settings, backups)http://localhost:9080/api/docs (Swagger UI) and http://localhost:9080/api/redoc (ReDoc)| Command | Description |
|---|---|
make build | Build the Docker image (ghcr.io/gelse/ssh-mcp:latest) |
make up | docker compose up -d |
make down | docker compose down |
make test | Run unit tests |
make config-test | Run config-api unit tests |
make integrationtest | Build test image, run integration tests |
make clean-test | Remove test artifacts and containers |
The Docker image is automatically built and published to GitHub Container Registry:
| Threat | Mitigation |
|---|---|
Command injection via chaining (cmd1 && cmd2) | Command segmentation — each segment runs the full authorization chain |
Shell redirection to sensitive paths (> /etc/passwd) | Redirection-target guard denies redirects into /dev/, /proc/, /sys/ |
| Path traversal in SFTP | 8-layer path validation: null-byte check, control-char strip, dot-segment normalization, symlink resolution, sandbox-root enforcement |
ReDoS via block_patterns | Static screening at load time + runtime timeout guards |
| API key brute force | PBKDF2-HMAC-SHA256 with constant-time verify; rate limiting per IP |
| Log injection | Newline sanitization on all user-controlled fields before logging |
| Secrets in config | secrets.json separation, MCP_SSH_SECRET_* env vars, 0600 file permissions |
server.py — FastMCP app factory + CLI entry pointlib/ — 30 single-responsibility modules (auth, config, SSH client, file transfer, logging, etc.)config-api/ — Configuration API + Web Dashboard (FastAPI, mounted at /api when CONFIG_API_ENABLED=true)tests/ — 36 unit-test files + integration tests with real Docker containersPython 3.13, FastMCP 3.4.x, paramiko 5.0, Starlette 1.4, FastAPI 0.115+, Pydantic 2.10+, httpx 0.28+, uvicorn 0.34+
The worked example in AGENTS.md walks through adding a new @mcp.tool() handler end-to-end: constants, types, re-exports, handler, tests, commit.
The project has no ruff, mypy, pyright, or flake8 configuration. Formatting follows .editorconfig defaults (4 spaces for Python, 88-char lines).
MIT License — see LICENSE for details.
/api/auth/session returns 401Symptom: Login with the API key succeeds, but after redirect the dashboard immediately
shows the login screen and the browser reports GET /api/auth/session 401 (Unauthorized).
Cause: The config-api session cookie is created with the Secure flag on by default.
When you access the dashboard over plain http:// (no TLS), modern browsers refuse
to store or send a Secure cookie over non-HTTPS connections. The cookie is never
persisted, so the next request carries no session and /api/auth/session returns 401.
If you access the dashboard over http://, make sure the Secure flag is disabled by
setting the environment variable in your .env file and restarting the container:
Because compose.yaml defaults the value to true
(${CONFIG_API_SESSION_COOKIE_SECURE:-true}), the variable must be present in .env —
adding it only to your shell does not apply. A container restart is required for the
change to take effect.
Note: Disabling
Secureweakens cookie security and should be limited to local / HTTP-only development. When the dashboard is served behind TLS (HTTPS), keepCONFIG_API_SESSION_COOKIE_SECURE=true(the default).