ssh-mcp

A policy-gated SSH execution MCP server for Claude Code and Codex, written in
Rust.
It presents a curated host inventory to the model and runs remote commands and
file transfers through a per-host policy gate: hosts you are free to use run
without a prompt, hosts that need care run with confirmation or restrictions —
each one declared once, in one file.
The design rationale lives in DESIGN.md; this file covers what
you need to get it running.
Why
Claude Code and Codex can both run external tools through MCP servers and
host-side policy hooks. Raw SSH does not fit that model well: every SSH target
needs its own trust decision, even lab machines you are happy to let the agent
use freely, and the purpose of each host has to be re-explained every session.
ssh-mcp moves SSH execution off the Bash tool onto structured MCP tools.
The model reads the inventory itself via list_hosts, picks a host, and calls
a tool. A per-host policy decides — without a prompt for free hosts, with one
for gated hosts. The same gate covers command execution and file transfer.
Tools
The MCP server exposes nine tools. All of them except list_hosts,
list_agent_keys, trace, and propose_host take a host argument
that must be an alias from list_hosts.
| Tool | What it does |
|---|
list_hosts | Returns each host's alias, purpose, tags, and policy kinds — never an address, user, or credential. Read-only, ungated. |
exec | Runs a shell command on a host and returns the exit code, line counts, and (optionally) the scoped output. The op parameter is an ordered pipeline of steps; omit it or pass [] to get metadata only (the body stays in the per-session trace buffer for inspection via trace). To get the body inline, pass at least one step: [{full: true}] for everything, [{tail: 50}] for the last 50, or chain like [{head: 100}, {tail: 50}, {grep: "err"}]. Piping the command through tail / head / grep yourself defeats the trace path; the daemon spots that and returns an advisory note on the result. |
get | Downloads a file or directory. If the local destination is an existing directory the entry lands inside it under its remote base name (the cp rule); otherwise it replaces the destination. Returns wire bytes (tar framing + metadata, not the sum of file content sizes). |
put | Symmetric: uploads a local file or directory. If the remote destination is an existing directory the entry lands inside; otherwise it replaces. Returns wire bytes (same meaning as get). |
sync_get / sync_put | Mirror a directory in either direction. Both paths are treated as roots: files in the destination that are absent from the source are deleted; files matching by sha-256 are skipped. Returns per-op counts and the wire bytes for the files that actually moved. |
trace | Re-inspects the full detail of a recent tool call from a per-session ring buffer (depth 5, 10 MiB per entry). op is the same pipeline shape as exec, but required (at least one step — pass [{full: true}] for the whole body). Accepts a stream selector (stdout / stderr / both, default both) for exec entries. grep matches the bare line text, so a pattern that worked on the original exec result keeps working. Transfer entries come back as <verb> <path> lines. |
propose_host | Appends a pending host entry to the daemon-owned ephemeral inventory next to ssh-mcp.toml (for the default config, ~/.ssh/ssh-mcp.ephem.toml) for a freshly spun-up cloud VM or similar. The entry is written with disabled = true plus an expires_at (required, RFC 3339, at most 30 days out) and a pinned host_key (required, OpenSSH single-line public key); the user has to open the TOML and remove the disabled line for the host to become usable — that hand edit is the trust gate. The server picks the alias (tmp- + 6 random hex chars) and hard-codes policy = ["claude"]; the input supplies hostname, user, purpose, expires_at, host_key, plus the optional port, tags, proxy_jump. The pinned host_key lets the entry skip ~/.ssh/known_hosts entirely — verification is byte-match against the pin. Returns the alias, the absolute ephemeral config path, the appended TOML snippet, and a short activation hint to echo to the user. Example: {"hostname": "13.78.10.5", "user": "azureuser", "purpose": "azure scratch box", "expires_at": "2026-05-27T19:30:00+09:00", "host_key": "ssh-ed25519 AAAAC3Nz... host@vm"}. |