SSH MCP Server v2
SSH MCP Server is a security-first Model Context Protocol server that gives LLM agents controlled SSH access to remote hosts — with command classification, policy-based authorization, human-in-the-loop approval, and full audit logging.
The risk this server exists to manage. Giving an LLM shell access on a remote host puts private data, untrusted input and network egress in one place — Simon Willison's "lethal trifecta". Prompt injection has no general fix, so ssh-mcp assumes any command may be attacker-influenced: it classifies before executing, authorizes against a role × host-group matrix, gates destructive work behind approval, and records the decision either way. That narrows the blast radius; it does not remove the risk. Two things stay yours: never point it at a root account, and never set
autoapproval on a production profile. SECURITY.md has the full threat model.
Quick Start
1. Install
npm install -g ssh-mcp2. Configure
Create the config file at the path for your platform:
| Platform | Path |
|---|---|
| Linux | ~/.config/ssh-mcp/config.toml (or $XDG_CONFIG_HOME/ssh-mcp/config.toml) |
| macOS | ~/Library/Application Support/ssh-mcp/config.toml |
| Windows | %APPDATA%\ssh-mcp\config.toml |
[defaults]
defaultProfile = "dev"
approvalMode = "ask-destructive"
[[profiles]]
name = "dev"
host = "192.168.1.100"
port = 22
user = "deploy" # NOT root!
auth = "key"
keyRef = "~/.ssh/id_ed25519"
role = "admin"
approvalPolicy = "auto" # dev is permissivechmod 700 ~/.config/ssh-mcp && chmod 600 ~/.config/ssh-mcp/config.tomlThe config decides which hosts, roles and policy rules this server honours, so it checks that nobody but you can read it — and treats the two platforms differently, because the question has a much clearer answer on one of them.
Linux and macOS: enforced. The mode check above, on the file and the directory —
which is why chmod 700 is in that command, since mkdir -p under the default umask
leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is
unambiguous here and chmod is a one-line fix.
Windows: split by what the ACL actually allows. There are no mode bits, so the ACL is read instead — and read exposure and write exposure are not treated alike, because Windows is much clearer about one of them than the other.
| The ACL lets another account… | Default |
|---|---|
| only read the config | reported, and the server starts |
| change the config | refused |
| nothing (no ACL at all) | refused — that is full control for everyone |
| …and if the ACL could not be read | refused, except when icacls is absent or the check timed out |
A config under %APPDATA% inherits access for you, SYSTEM and Administrators and needs
nothing done to it. One created elsewhere does not: a file under C:\ inherits read for
every local account and modify for every authenticated one. The message names the two
icacls commands that fix it either way.
Read exposure is reported rather than refused because that is where Windows is genuinely muddier than POSIX, and refusing over it blocked a config at the documented location (#138). Write exposure is refused because it is not muddy at all: another account being able to rewrite the file that decides which hosts, roles and approval policy this server honours is an authorization bypass, not a disclosure.
Two flags move the whole thing: --strictConfigAcl refuses everything the check objects
to, read-only grants included; --allowUncheckedConfigAcl reports everything and refuses
nothing. Neither combination leaves you without an exit, which is the lesson of #138.
Exit statuses
| Status | Meaning |
|---|---|
0 | Clean shutdown |
1 | A defect in the server — printed with a stack trace; please report it |
2 | How it was invoked or configured — printed as a message, no stack |
A supervisor that treats any non-zero status as a failure needs no change. One
that matched on 1 to detect a startup problem should match on 2 as well.
3. Set credentials via environment variables
export SSH_MCP_PASSWORD="your-password" # if using auth=password
# OR use SSH agent (recommended):
export SSH_AUTH_SOCK="$SSH_AUTH_SOCK" # already set if agent running4. Connect from your MCP client
Claude Code:
claude mcp add --transport stdio ssh-mcp -- ssh-mcpClaude Desktop / Cursor / Windsurf:
{
"mcpServers": {
"ssh-mcp": {
"command": "ssh-mcp",
"env": {
"SSH_MCP_PASSWORD": "your-password"
}
}
}
}Never pass passwords as CLI arguments — they're visible via ps aux. Use env vars, config files, SSH agent, or OS keychain.
Tools (11)
| Tool | Purpose | readOnly | destructive |
|---|---|---|---|
list-connections | Discover available hosts and connection status | ✅ | — |
list-sessions | List active sessions per host | ✅ | — |
open-session | Create a named interactive (stateful) or background session | — | — |
close-session | Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped | — | ✅ |
read-session-output | Read output from background sessions (e.g., tail -f) | ✅ | — |
read-command | Execute allowlisted read-only commands (ls, cat, grep, ...) | ✅ | — |
run-command | Execute arbitrary commands (destructive ones need approval) | — | — |
privileged-command | Execute with sudo (always requires approval) | — | ✅ |
sftp-upload | Upload a file via SFTP | — | ✅ |
sftp-download | Download a file via SFTP | ✅ | — |
signal-process | Send INT/TERM/KILL to a remote PID | — | ✅ |
Interactive Sessions
Sessions maintain state (CWD, environment variables) between commands:
Agent: open-session(name="deploy", type="interactive")
Agent: run-command(session="deploy", command="cd /opt/myapp")
Agent: run-command(session="deploy", command="git pull") # runs in /opt/myapp
Agent: run-command(session="deploy", command="npm ci") # CWD persists
Agent: close-session(name="deploy")Background Sessions
Long-running processes (logs, builds):
Agent: open-session(name="logs", type="background", command="tail -f /var/log/syslog")
Agent: read-session-output(name="logs", lines=20) # poll
Agent: close-session(name="logs")Remote host support
Tested against Linux (Debian/bash, Alpine/busybox ash), Dropbear, and Windows OpenSSH on Windows 11.
| Linux / BSD / macOS | Windows OpenSSH | |
|---|---|---|
read-command, run-command, privileged-command, signal-process | ✅ | ✅ |
sftp-upload, sftp-download | ✅ | ✅ |
| Background sessions | ✅ | ✅ |
| Interactive sessions | ✅ | ❌ |
Interactive sessions require a POSIX shell (sh, bash, ash, zsh). They work by
bracketing each command with printf markers and reading $? and $PWD from a
trailer — none of which exist in cmd.exe, the default shell for Windows
OpenSSH. Opening one against such a host fails immediately with an explicit
error rather than timing out; everything else works normally.
Setting PowerShell as the OpenSSH DefaultShell does not help: the protocol is
POSIX-specific, not merely non-cmd.
Configuration
Profile options
[defaults]
defaultProfile = "dev"
sessionMaxPerConnection = 5
sessionIdleTimeoutMs = 600000 # 10min
sessionBackgroundMaxMs = 3600000 # 1hr
commandTimeoutMs = 60000
commandMaxChars = 5000 # 0 = unlimited, the config spelling of --maxChars=none
commandMaxOutputBytes = 1048576 # 1MB
connectionIdleReapMs = 900000 # 15min
commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
approvalGrantTtlMs = 0 # 0 = always prompt; see "Approval Grants"
approvalMode = "ask-destructive" # auto | ask-destructive | ask-all | deny
[[profiles]]
name = "prod-web-1"
host = "10.0.1.50"
port = 22
user = "deploy"
auth = "agent" # agent | key | password | keychain
keyRef = "~/.ssh/id_ed25519" # for auth=key
keychainEntry = "ssh-mcp/prod" # for auth=keychain (requires @napi-rs/keyring)
via = "bastion" # ProxyJump — route through bastion profile
group = "prod" # Policy tier: prod | staging | dev, or your own (see [policy])
workdir = "/var/www"
trustedHostKey = "SHA256:..." # Pin host key (optional)
tty = false
role = "operator" # viewer | operator | admin
readOnly = false
approvalPolicy = "ask-all"
cert = false # SSH CA cert auth — auto-detects keyRef-cert.pub
sessionMaxPerConnection = 3 # per-profile override
sessionIdleTimeoutMs = 300000 # stricter for prod
commandQuotaPerDay = 200 # per-profile override
maxChars = 2000 # per-profile override; stricter for prod
# Optional. Merged over the built-in role matrix; see "Policy Engine" below.
# roleBindings is keyed by role and then by tier, so the block below changes
# operator on prod and leaves operator's other tiers, and viewer and admin,
# on their defaults.
[policy]
denylist = ["^terraform\\s+destroy"]
[policy.roleBindings.operator]
prod = ["read-only", "safe", "destructive"]Unknown sections and keys are a startup error, not a warning, so a typo cannot
leave you running defaults you thought you had overridden. That extends to role
and tier names: every one you write under [policy.roleBindings] has to be
reachable by some profile, and every profile's role and tier has to resolve to
real bindings. Both directions are checked at startup.
ProxyJump (Bastion)
Reach internal hosts behind a bastion/jump server. The via field specifies a profile name to tunnel through:
[[profiles]]
name = "bastion"
host = "bastion.example.com"
user = "deploy"
auth = "agent"
[[profiles]]
name = "internal-db"
host = "10.0.1.50" # private IP — not directly reachable
user = "dbadmin"
auth = "key"
keyRef = "~/.ssh/db_key"
via = "bastion" # tunnel through bastionNo agent forwarding — only a TCP tunnel via forwardOut. The bastion stays connected and reusable for multiple internal hosts.
SSH CA Certificates
For enterprise setups with a central SSH Certificate Authority:
[[profiles]]
name = "prod-db"
host = "db.internal"
user = "admin"
auth = "key"
keyRef = "~/.ssh/id_ed25519"
cert = true # enable CA cert authThe certificate file is auto-detected using OpenSSH convention (keyRef + -cert.pub, e.g. ~/.ssh/id_ed25519-cert.pub). You can override the path with SSH_MCP_<NAME>_CERT env var. The cert is concatenated with the private key per ssh2 convention.
Credential Resolution Order
- SSH agent (
SSH_AUTH_SOCK) — no key material in process memory - OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret Service) — requires
auth = "keychain"and@napi-rs/keyring - Environment variables —
SSH_MCP_PASSWORD
…