trackmcp
Back to directory

MCP server exposing SSH control for Linux servers via Model Context Protocol. TypeScript-based implementation.

119 stars TypeScriptAI & Machine Learning Updated Oct 12, 2025

Documentation

SSH MCP Server v2

NPM Version
Downloads
CI
OpenSSF Scorecard
codecov
License
GitHub issues

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 `auto` approval on a production profile. SECURITY.md has the full threat model.


Quick Start

1. Install

bash
npm install -g ssh-mcp

2. Configure

Without a config the server still starts, so a client or directory can complete the MCP

handshake and read `tools/list` — but every tool call is refused until you configure it,

with a message naming the path below. Nothing runs on a host until this step is done.

Create the config file at the path for your platform:

PlatformPath
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`
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 permissive
bash
chmod 700 ~/.config/ssh-mcp && chmod 600 ~/.config/ssh-mcp/config.toml

The 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 configreported, and the server starts
change the configrefused
nothing (no ACL at all)refused — that is full control for everyone
…and if the ACL could not be readrefused, 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

StatusMeaning
`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.

Starting with nothing configured is not an exit-2 condition, as of the release that

added introspection without a config: the server starts so it can be described, and

refuses each tool call instead. A supervisor that used a non-zero exit to catch an

unconfigured deployment should watch for `starting unconfigured` on stderr, or read

`configured` from `GET /health` when running the HTTP transport.

3. Set credentials via environment variables

bash
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 running

4. Connect from your MCP client

Claude Code:

bash
claude mcp add --transport stdio ssh-mcp -- ssh-mcp

Claude Desktop / Cursor / Windsurf:

json
{
  "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)

ToolPurposereadOnlydestructive
`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/privileged need approval, unless `approvalPolicy = "auto"`)
`privileged-command`Execute with sudo (needs approval, unless `approvalPolicy = "auto"`)
`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:

code
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):

code
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 / macOSWindows 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

toml
[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:

toml
[[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 bastion

No 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:

toml
[[profiles]]
name = "prod-db"
host = "db.internal"
user = "admin"
auth = "key"
keyRef = "~/.ssh/id_ed25519"
cert = true                         # enable CA cert auth

The 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__CERT` env var. The cert is concatenated with the private key per ssh2 convention.

Credential Resolution Order

1. SSH agent (`SSH_AUTH_SOCK`) — no key material in process memory

2. OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret Service) — requires `auth = "keychain"` and `@napi-rs/keyring`

3. Environment variables — `SSH_MCP_PASSWORD`, `SSH_MCP_KEY`, `SSH_MCP_SUDO_PASSWORD`, or profile-specific `SSH_MCP__PASSWORD`

4. Key file — `keyRef` path or `SSH_MCP_KEY` env var

Never CLI arguments. v2 removes `--password`, `--sudoPassword`, `--suPassword` entirely.


Policy Engine

Roles

RoleDevStagingProd
viewerread-onlyread-onlyread-only
operatorread-only, safe, destructiveread-only, safe, destructiveread-only, safe
adminallallread-only, safe, destructive

Which column applies comes from the profile's `group`. Set it explicitly —

without it the tier is guessed from the profile name (`prod`/`staging`/`dev`,

`local`, `test`, `sandbox`), and an unrecognised name resolves to `prod`,

the strictest tier. A production host named `web-01` is therefore treated as

production rather than silently getting dev permissions.

Note what this means for `sudo`: `admin` has no `privileged` on `prod`, so

`privileged-command` is refused there by design — including on a quick-start

profile, which has no name to infer from and therefore lands on `prod`. If the

host is not production, say so:

bash
npx ssh-mcp --host=10.0.0.5 --user=deploy --group=dev
toml
[[profiles]]
name = "build-box"
group = "dev"

Configuring the matrix

The table above is the default, not a limit. An optional `[policy]` section is

merged over it at startup, so granting sudo on a host you have honestly

labelled `prod` is a reviewable line in a config file rather than a relabelling:

toml
[policy.roleBindings.admin]
prod = ["read-only", "safe", "destructive", "privileged"]

The merge is at role *and* tier depth. That block changes `admin` on `prod` and

nothing else: `admin` on `staging` and `dev` keep their defaults, and `viewer`

and `operator` are untouched. Roles and tiers the defaults have never heard of

are added rather than rejected, which is what makes a custom `group` resolve to

real bindings instead of falling back to the strictest tier:

toml
[[profiles]]
name = "build-box"
role = "admin"
group = "tier-1"

[policy.roleBindings.admin]
"tier-1" = ["read-only", "safe", "destructive"]

Extra deny patterns live in the same section, and are applied on top of the

never-allowed list rather than replacing it:

toml
[policy]
denylist = ["^terraform\\s+destroy"]

Because role and tier names are free strings, nothing in the merge itself can

tell a new custom role from a misspelling of an existing one. A cross-check at

startup does, and these all fail there rather than at the point of use:

  • a command class outside `read-only | safe | destructive | privileged`, so a

`priviledged` typo cannot parse into a grant of nothing and then read as a

policy decision when a command is refused;

  • any unrecognised section or key anywhere in the config, so a block the parser

does not understand is an error rather than a clean startup with none of the

behaviour you configured;

  • a role or tier under `[policy.roleBindings]` that no profile uses, so

`[policy.roleBindings.operater]` cannot merge in as a fourth role while the

profiles you meant to restrict keep running on defaults;

  • a profile whose `role` has no bindings, or whose tier has none under that

role, so a host cannot end up on `read-only` for a reason nobody wrote down.

The last one covers the tier you did not set as well as the one you did. A

profile with no `group` still resolves to one by name, and that inferred tier

has to exist under the profile's role like any other.

A tier with no bindings for a role grants `read-only`, and never another tier's

classes. There is no fallback between tiers: while the matrix was compiled in,

falling back to `prod` meant falling back to a role's strictest cell, but a

`[policy]` block can write that cell now.

An OPA sidecar is not an alternative route to the same grant. OPA is consulted

only for commands the local policy already allows, so it can refuse more but

never widen. Widening happens here or not at all.

Command Classification

Every command is classified before execution:

  • read-only: Allowlisted commands (`ls`, `cat`, `grep`, `df`, `stat`, `systemctl status`, ...)
  • safe: Non-destructive mutations (`npm install`, `git pull`, ...)
  • destructive: mutations that need approval (`rm -rf /tmp/build`, ...)
  • privileged: `sudo`, `su`, `doas`, `pkexec`

A separate forbidden list is never allowed, whatever the role or approval

policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,

writes to `/etc/cron`, `/etc/systemd` or `authorized_keys`, `iptables -F`, and

recursive `chmod 777 /` / `chown /`. Add your own patterns via the policy

denylist; an invalid pattern fails at startup rather than degrading silently.

Approval Modes

  • `auto` — no prompts (dev only!)
  • `ask-destructive` — prompt for destructive/privileged (default). Narrower than it sounds:

outside the never-allowed list, `destructive` is one `rm -rf /path` pattern, `find` with a

write/exec flag, an unresolvable command word, a program handed to an interpreter this

server cannot read (`python3 -c`, `perl -e`, `node -e`, a program arriving on a pipe —

but not `awk`, whose program is not read), and `sftp-upload`/interactive `open-session` — elevation classifies `privileged`, which also

prompts. Ordinary writes, service control and signals do not. See

SECURITY.md before relying on this in production.

  • `ask-all` — prompt for every command
  • `deny` — reject destructive/privileged commands outright (no prompt)

Approval Grants (just-in-time)

`approvalGrantTtlMs` lets one explicit approval cover repeats of the **exact

same** command on the same profile for a bounded time (e.g. `300000` for five

minutes). It exists because approving `rm -rf /tmp/build` every few seconds

during an iterative task trains you to click through prompts — which is worse

for safety than a grant you chose deliberately.

A grant is bound to the exact command text, the profile and the command class:

approving `rm -rf /tmp/build` does not cover `rm -rf /tmp/build-prod`, the same

command on another host, or the same command escalated to `sudo`. Runs covered

by a grant appear in the audit log with `approver: "jit-grant"`, so they stay

distinguishable from a fresh human answer.

Off by default (`0` = always prompt). Auto-approval weakens the gate that

makes destructive commands safe, so turning it on should be a decision.

Answering the prompt

Approval goes through the MCP elicitation request, so what you see is your

client's dialog. Accepting it approves the command — there is no second field to

fill in.

You have 10 minutes to answer. Past that the request expires and the command

is refused rather than left pending, and the refusal says so; the prompt may

still be open in your client, in which case run the command again once you are

ready. If your client does not support elicitation at all, every destructive and

privileged command is refused with `APPROVAL_UNAVAILABLE` naming that cause —

approval fails closed by design.

Command Quota

`commandQuotaPerDay` bounds how many commands a profile may run in a rolling

24-hour window (0 = unlimited). The approval gate stops *destructive* commands

and the HTTP rate limiter caps request rate, but neither bounds total work — a

prompt-injected agent looping over allowed commands stays under both. The quota

is the circuit breaker for that case.

Counted after policy allows a command and before it runs, so a denied command

does not spend budget. The window slides rather than resetting at midnight,

which would let an agent spend a full quota just before the reset and another

immediately after.

External Policy Engine (OPA)

For organizations that standardize on Open Policy Agent / Rego:

bash
ssh-mcp --opaUrl=http://localhost:8181

When `--opaUrl` is set, commands the built-in engine allows are additionally

evaluated by OPA. OPA can only narrow. A command the built-in engine has

already denied returns that denial without OPA being consulted at all, so a

sidecar answering `allow` cannot grant a class the role bindings withhold. To

widen, edit `[policy]`.

An outage falls back to the local decision and logs one warning per minute. That is the

default because OPA is an *additional* deny layer and stopping all work would be the worse

failure — but an operator who deployed OPA *as* the authorization gate loses that gate

during the outage, and the only signal is a stderr line MCP clients usually discard.

`--opaFailClosed` makes the gate being down mean no; the refusal carries `ruleId:

"opa-unavailable"` so the audit record says the gate was down rather than implying a policy

refused the command.

The request shape follows the AuthZEN Access Evaluation contract:

json
{
  "input": {
    "subject": { "role": "operator", "profile": "prod-web-1" },
    "action": { "tool": "run-command", "commandClass": "destructive" },
    "resource": { "command": "rm -rf /tmp/cache", "binary": "rm", "host": "10.0.1.50" },
    "context": { "readOnly": false }
  }
}

OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands by default (fail-open, to avoid locking out access); `--opaFailClosed` refuses instead. A 200 that carries no boolean `result` counts as unreachable — that is what OPA answers for an undefined document, so a misnamed package or an unactivated bundle is an outage rather than consent.

Example Rego policy (`ssh-mcp.rego`):

rego
package ssh.mcp

default allow := false

# Admins pass the OPA gate on dev hosts. The built-in policy still applies on
# top: this widens nothing that the role bindings withhold.
allow if {
  input.subject.role == "admin"
  startswith(input.subject.profile, "dev")
}

# Deny all destructive commands on prod
deny if {
  input.action.commandClass == "destructive"
  startswith(input.subject.profile, "prod")
}

Security

Threat Model

See SECURITY.md for the full threat model, vulnerability reporting policy, and deployment checklist.

Supply chain

Releases carry signed attestations, published through Sigstore

and recorded in its public transparency log. They live in two different stores, which is

what decides how each is verified:

AttestationPredicateStored bySince
Build provenance — SLSA Build Level 2`slsa.dev/provenance/v1`npmevery release
SBOM — CycloneDX and SPDX`cyclonedx.org/bom`, `spdx.dev/Document`GitHubreleases after v2.4.0

Provenance comes from npm trusted publishing:

the release workflow authenticates with a short-lived OIDC token and no stored credential,

so there is no long-lived npm token to leak.

bash
npm audit signatures        # provenance, against an installed tree

npm pack ssh-mcp            # the SBOM attestation is bound to the tarball, so fetch it
gh attestation verify ssh-mcp-*.tgz --repo tufantunc/ssh-mcp --predicate-type https://cyclonedx.org/bom

Both flags on the last command are load-bearing. `gh attestation verify` defaults to the

SLSA predicate, so without `--predicate-type` it filters the SBOM out and reports nothing

found — and the provenance it would look for instead is in npm's store, not the GitHub

store `--repo` queries. Use `https://spdx.dev/Document` for the SPDX one.

Both SBOMs are also attached to each

GitHub release, for reading rather than

verifying.

Level 2, not 3. Provenance is signed by the generic GitHub-hosted runner —

`builder.id` is `https://github.com/actions/runner/github-hosted` — which the build itself

can influence; Build L3 requires an isolated builder it cannot. Reaching L3 is not

currently compatible with trusted publishing: npm turns on its own provenance whenever that

setting is left at its default, and then ignores any externally generated one. So L3 today

would mean returning to a long-lived npm token — trading the property described above for a

level number.

Safe Defaults

  • Non-root user in all examples
  • TOFU host key verification (accept on first connect, verify after — within one process; see SECURITY.md)
  • RFC 9142 algorithm allow-list (no SHA-1, no CBC, no ssh-rsa)
  • exec()-only (no persistent su shells — fixes PTY leak)
  • Sudo via stdin (not argv — fixes process list leak)
  • Sanitizer strips CR/LF/NUL from all metadata
  • 3-layer redaction (field → regex → entropy) on audit logs
  • No CLI-arg secrets (use env vars, keychain, or config)

Hardening Checklist

  • [ ] Create dedicated low-privilege service account on target hosts
  • [ ] Use command-specific `sudoers` instead of `NOPASSWD: ALL`
  • [ ] Enable `ask-all` approval for production profiles
  • [ ] Restrict network egress on target hosts
  • [ ] Use `readOnly = true` for monitoring profiles
  • [ ] Review audit logs regularly
  • [ ] Run `chmod 700 && chmod 600 config.toml` (Windows: the ACL under `%APPDATA%` is already restricted)

Transports

stdio (default)

For local MCP clients (Claude Code, Cursor, Windsurf). No network exposure.

bash
ssh-mcp                          # reads config from XDG path
ssh-mcp --config=/path/to.toml   # custom config path

HTTP (optional)

For remote/web clients behind a reverse proxy with TLS:

bash
ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret
ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
FlagDefaultDescription
`--bearerToken`requiredBearer token for authentication (all routes except `GET /health`)
`--httpPort`3000HTTP listen port
`--httpHost`127.0.0.1Bind address
`--rateLimit`0 (off)Max requests per minute (0 = unlimited)
`--authFailureLimit`10Failed bearer-auth attempts allowed per client per minute (0 = off)
`--trustProxy`falseRead the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer
`--trustedProxies`Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only

Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`

`GET /health` answers `{"healthy": true, "configured": }`. It stays `200` either way —

`healthy` is liveness — while `configured` is false when no profile is set, which is the

case of a config bind mount that silently did not attach: the server binds the port and

refuses every tool call. `GET /status` carries the profile list itself and stays behind the

bearer token.

When rate limit is exceeded, the server returns HTTP 429 with `Retry-After` header and a JSON-RPC error body so MCP clients can handle it gracefully.

Failed authentication is throttled separately, and on by default. `--rateLimit` never saw a

wrong bearer token, because the auth check answers before the limiter is reached — so

guessing ran at network speed. `--authFailureLimit` gives each client its own small budget,

spent only on a 401; a correct token never consumes from it, so a working client never

throttles itself. Once an address has spent its budget every request from it waits,

including one with the right token — that is deliberate, since answering the guess would

otherwise tell the caller which token was right. Clients are told apart by socket address. **Behind a

reverse proxy that means every client shares one budget**, so set `--trustProxy` when the

proxy is yours — the server prints a warning the first time it sees `X-Forwarded-For`

without it. `--trustProxy` takes the *rightmost* `X-Forwarded-For` entry, which is the hop

the proxy itself appended; everything to its left came from the client, so reading the

leftmost would let a client choose its own budget or spend a victim's. That only holds if a

proxy actually appended the entry, so the header is read **only when the peer is the

proxy** — bare `--trustProxy` means a loopback peer, which is the deployment above; name a

proxy elsewhere with `--trustedProxies`. When the header cannot be read as an address, or

the peer is not trusted, the server falls back to the socket address and says so once, so

a flag that is not taking effect is not silent. One trusted hop is assumed. A malformed `--authFailureLimit` is refused at startup rather than silently

disabling the check; only `0` turns it off.

Always terminate TLS at a reverse proxy (Caddy/nginx). The server listens on `127.0.0.1` only.


Docker

bash
# Build
docker build -t ssh-mcp .

# Run (config file + env vars for credentials)
docker run -i \
  -v ./config.toml:/home/appuser/.config/ssh-mcp/config.toml:ro \
  -e SSH_MCP_PASSWORD=secret \
  ssh-mcp

Or with docker-compose:

bash
docker-compose --profile app up

The Docker image runs as non-root UID 65532, with a minimal `node:22-slim` base.


CLI Flags (v2)

Secrets are never passed as CLI arguments.

FlagDefaultDescription
`--config`platform config dir (see Configure)Path to TOML config file
`--host`Quick start: SSH host (creates single-profile config)
`--user`Quick start: SSH username
`--port`22Quick start: SSH port
`--key`Quick start: Path to private key
`--workdir`Quick start: Working directory for commands and sessions
`--group`prodQuick start: Policy tier — `prod`, `staging` or `dev`
`--timeout`60000Command timeout in ms
`--maxChars`5000Max command length (`none` or `0` disables the limit; in a config file the same setting is `commandMaxChars = 0`)
`--sessionMax`5Max concurrent sessions per connection
`--sessionTtl`600000Session idle timeout in ms
`--transport`stdio`stdio` or `http`
`--httpPort`3000HTTP transport port
`--httpHost`127.0.0.1HTTP bind address
`--bearerToken`Bearer token for HTTP transport auth (required for `--transport=http`)
`--rateLimit`0HTTP requests per minute on the MCP route (0 = unlimited)
`--authFailureLimit`10Failed bearer-auth attempts allowed per client per minute (0 = off)
`--trustProxy`falseRead the client address from `X-Forwarded-For`, but only when the peer is the proxy — bare means a loopback peer
`--trustedProxies`Comma-separated peer addresses allowed to send `X-Forwarded-For`. Empty means loopback only
`--allowedHosts`bind address + localhostComma-separated Host headers accepted by the DNS-rebinding guard
`--hostKeyMode``tofu``tofu \strict \insecure`. See SECURITY.md — `strict` currently refuses every host
`--insecureHostKey`falseDisable host key verification (test only!)
`--allowUncheckedConfigAcl`falseWindows: report every ACL finding and refuse none
`--strictConfigAcl`falseWindows: refuse on every ACL finding, including a read-only over-grant
`--disableApproval`falseSkip the approval gate (quick start profile only)
`--opaUrl`OPA sidecar URL for external policy
`--opaFailClosed`falseRefuse every command while OPA is unreachable, instead of falling back to local policy
`--opaTimeoutMs`10000How long to wait for the OPA sidecar. Lower makes the fail-open cheaper to reach; higher makes an outage slower to notice
`--commandQuota`0 (off)Max commands per rolling 24h per profile
`--approvalGrantTtl`0 (off)Auto-approve an identical command for this many ms after approval
`--auditEntropyScan`falseEnable entropy-based secret scanning in audit
`--auditTamperEvident`falseEnable hash-chained tamper-evident audit log
`--otelEndpoint`OTLP/HTTP endpoint for OpenTelemetry traces
`--otelServiceName`ssh-mcpService name reported on trace spans
`--dumpToolHashes`Print SHA-256 hashes of the tool descriptions and exit

Migrating from v1

v2 is a breaking release. Passing a removed flag now fails at startup with the

replacement, rather than failing later as a confusing auth error.

Tools

v1v2Notes
`exec``read-command`Allowlisted read-only commands. Prefer this for reads.
`exec``run-command`Arbitrary commands. Destructive and privileged ones go through the approval gate, unless `approvalPolicy = "auto"`.
`sudo-exec``privileged-command`Requires approval unless `approvalPolicy = "auto"`. Password is piped via stdin.
`description` parameterRemoved. It was an injection vector (#44) and never reached the host.

Command results now carry status. In v1 a failed command rejected with

`Error (code N)`. In v2 a non-zero exit comes back as an error result including

the exit code and stderr — so an empty response no longer means "it worked".

Flags

v1 flagReplacement
`--password``SSH_MCP_PASSWORD` env var (or `SSH_MCP__PASSWORD`)
`--suPassword``SSH_MCP_SUDO_PASSWORD` env var
`--sudoPassword``SSH_MCP_SUDO_PASSWORD` env var
`--disableSudo`Use a role/policy that disallows the `privileged` class

Credentials moved off the command line because CLI arguments are world-readable

via `/proc//cmdline` on Linux (CWE-214). Credentials now resolve through an

SSH agent → OS keychain → env var → key file cascade.

Example

jsonc
// v1
{ "command": "npx", "args": ["ssh-mcp", "--host=1.2.3.4", "--user=root", "--password=hunter2"] }

// v2 — credentials via env
{
  "command": "npx",
  "args": ["ssh-mcp", "--host=1.2.3.4", "--user=root"],
  "env": { "SSH_MCP_PASSWORD": "hunter2" }
}

For more than one host, move to a TOML config file (see Configuration)

and pass `--config `; profiles carry per-host roles and approval policy.

Host key verification

v1 did not verify host keys. v2 defaults to trust-on-first-use and records the

key in memory, for the life of the process; a later mismatch in that same

process fails the connection. Nothing is written to disk and `~/.ssh/known_hosts`

is not consulted, so a restart accepts afresh — see

SECURITY.md. Pin

explicitly with `trustedHostKey` in a profile, which is the only control here that

survives a restart, or pass `--insecureHostKey` to opt out (test environments only).


Testing

bash
# Start test SSH server
docker-compose --profile test up -d

# Run all tests
npm test

# Run only unit tests
npm test -- test/unit/

# Run with coverage
npm run coverage

MCP Inspector

bash
npm run inspect

Contributing

See CONTRIBUTING.md. Please follow the security checklist in all PRs.

Support

If you find SSH MCP Server helpful, consider starring the repository or sponsoring!

Listed on

SSH MCP Server on Glama

Also on the official MCP registry as `io.github.tufantunc/ssh-mcp`.

Frequently asked questions

What is ssh-mcp?

ssh-mcp is MCP server exposing SSH control for Linux servers via Model Context Protocol. TypeScript-based implementation.

How do I install ssh-mcp?

Open the GitHub repository and follow its README. Most MCP servers are added to your client's MCP config, then called by your agent.

Is ssh-mcp open source?

Yes — it is hosted on GitHub at https://github.com/tufantunc/ssh-mcp and has 119 stars.

Related MCP tools

Run your own MCP server? See who uses it and what to fix.

Measure it with TrackMCP