ssh-hosts-mcp
# ssh-hosts-mcp
[English](README.md) | [Русский](README.ru.md) | [Deutsch](README.de.md)
Unified SSH MCP server with a host registry — one `host_id` per call instead of IPs, users and key paths.
## Overview
ssh-hosts-mcp is a [Model Context Protocol](https://modelcontextprotocol.io/) server that gives LLM clients access to SSH hosts through a named registry. The agent calls `ssh_exec` with `host_id` — addresses, users, ports and private keys stay in a local config file and never enter the model context.
Three transports are supported:
| Transport | Implementation | Tools |
|-----------|----------------|-------|
| `direct` | `ssh2` (Node.js) | exec, read, SFTP write (base64-exec fallback) |
| `ssh3` | spawns the `ssh3` Go client (QUIC over UDP) | exec, read, base64-exec write (≤48 KB) |
| `wsl` | WSL `bash -lc 'ssh …'` (proxied setups) | exec only |
## Features
- **Host registry** — `host_id` abstraction; the agent never guesses IP/user/key
- **Three transports** — direct `ssh2`, `ssh3` (QUIC over UDP), WSL-proxied SSH
- **Host-key pinning** — `trustedHostKey` (sha256) verified on every direct connection; unpinned hosts are flagged in `ssh_list_hosts`; ssh3 pins via TOFU `known_hosts`
- **Command policy** — built-in never-allowed denylist (`rm -rf /`, `mkfs`, `dd of=/dev/*`, `curl|sh`, …) plus per-host/`defaults` `policy.deny`/`policy.allow` regex lists; deny wins, whitelist mode is fail-closed (RouterOS pipes exempt)
- **Path confinement** — optional `allowedPaths` (read/write prefixes), absolute POSIX paths only, `..` traversal rejected
- **Output & time limits** — per-host `maxOutputBytes` truncation with an explicit marker; per-host `timeoutMs` (per-call overrides)
- **Paged file reads** — `ssh_read_file` returns a byte range (default 64 KB) with `nextOffset` for continuation
- **Health snapshots** — `ssh_server_status`: CPU/mem/disk/uptime in one call, busybox/OpenWrt and RouterOS variants
- **WSL idle wake** — `"wake": "wsl"` ssh3 hosts auto-revive a shut-down WSL distro before calls (idle shutdown otherwise kills inbound QUIC)
- **File transfer** — SFTP with automatic base64-exec fallback for hosts without sftp-server (OpenWrt/dropbear), written bytes verified
- **RouterOS-aware** — MikroTik hosts flagged with `"shell": "routeros"`
- **Server instructions** — `docs/INSTRUCTIONS.md` is served to the MCP client automatically
- **Agent Skills** — a ready [SKILL.md](skills/ssh-hosts/SKILL.md) for skill-aware clients (copy to the client's `skills/` directory)
- **Safety rails** — rejects literal `undefined`/`null` file paths, propagates remote exit codes (incl. win32 unsigned 32-bit fixup)
- **Local-first config** — real addresses live in a gitignored `hosts.local.json`; the repository ships an anonymized template
## Tools
| Tool | Purpose |
|------|---------|
| `ssh_list_hosts` | List configured hosts and metadata (incl. `hostKeyPinned`) |
| `ssh_exec` | Run a remote command (policy-checked, output capped) |
| `ssh_read_file` | Read a byte-range of a remote file (default 64 KB, continue via `offset`) |
| `ssh_write_file` | Write a remote file (SFTP on direct hosts, base64-exec on ssh3; ≤48 KB) |
| `ssh_server_status` | CPU/mem/disk/uptime snapshot |
## Installation
```bash
git clone https://github.com/nagual2/ssh-hosts-mcp.git
cd ssh-hosts-mcp
npm install
npm test
```
## MCP configuration
Generic `mcpServers` entry (Claude Desktop, Cursor, ZCode, …):
```json
{
"mcpServers": {
"ssh": {
"command": "node",
"args": ["/path/to/ssh-hosts-mcp/index.mjs"],
"env": {
"SSH_HOSTS_CONFIG": "/path/to/hosts.local.json"
}
}
}
}
```
Config precedence: `SSH_HOSTS_CONFIG` env override → `hosts.local.json` (real addresses, gitignored) → `hosts.json` (anonymized template, safe to publish).
## Host configuration
```json
{
"version": 1,
"defaults": {
"maxOutputBytes": 262144,
"policy": { "deny": ["^reboot"] }
},
"hosts": {
"my-host": {
"label": "Linux box",
"transport": "direct",
"host": "192.0.2.10",
"user": "root",
"port": 22,
"privateKeyPath": "~/.ssh/id_ed25519",
"trustedHostKey": "SHA256:…"
},
"my-ssh3-host": {
"label": "Same box over ssh3",
"transport": "ssh3",
"host": "server.example.lan",
"port": 443,
"urlPath": "/ssh3-term",
"user": "user",
"privateKeyPath": "~/.ssh/id_ed25519",
"clientPath": "ssh3-client"
},
"my-proxy-host": {
"label": "Reachable only through a proxy",
"transport": "wsl",
"wslHost": "alias-from-ssh-config",
"user": "coder"
}
}
}
```
### Transports in detail
- **`direct`** — Node `ssh2` from the MCP host machine; `~` in `privateKeyPath` is expanded to the home directory.
- **`ssh3`** — spawns the ssh3 client (`clientPath`, default `ssh3-client` on PATH); all flags go before the positional `user@host:port/urlPath` target. The server certificate must be pinned in `~/.ssh3/known_hosts` (TOFU) or `"insecure": true` set (dev only). Requires ssh3 server ≥ 0.1.8 — use the [`nagual2/ssh3`](https://github.com/nagual2/ssh3) fork, which adds the `-privkey` flag.
- **`wsl`** — runs `wsl bash -lc 'ssh -o BatchMode=yes <wslHost> <command>'`; only `ssh_exec` is supported (use `cat` / `tar|ssh` for files).
Full field reference: [`hosts.json`](hosts.json) and [`docs/INSTRUCTIONS.md`](docs/INSTRUCTIONS.md).
## Design notes
- **48 KB base64-exec limit** — remote writes are embedded as `echo <base64> | base64 -d > file`; an ARG_MAX-safe ceiling, with the written size verified via `wc -c`.
- **Exit codes** — Go clients surface unsigned 32-bit codes on win32; `normalizeExitCode` maps them back to signed values.
- **`filePath`, not `path`** — the write schema uses `filePath`; a literal `"undefined"`/`"null"` path is rejected before touching the remote host.
## Testing
```bash
npm test # node --test test/lib.test.mjs — pure helpers, no network
```
## WSL access comparison (ssh3-MCP vs wsl.exe)
[`test/e2e-wsl-compare.mjs`](test/e2e-wsl-compare.mjs) measures both access paths against a live WSL ssh3 host: channel A spawns the real MCP stdio server, channel B calls `wsl.exe` directly. Results (2026-10-17, LAN, median of 5):
| Check | ssh3 (MCP) | wsl.exe |
|-------|------------|---------|
| `echo` round-trip | **75 ms** (63–93) | 205 ms |
| exit-code propagation (`exit 42`) | 42 ✓, 63 ms | 42 ✓, 215 ms |
| 4 KB write + read-back | byte-exact | — |
| 1 MB output volume | 1000000 ✓, 120 ms | 1000000 ✓, 243 ms |
| after 35 s idle | **alive** with `"wake": "wsl"` (wake ≈ 6.7 s) | wakes the distro in 0.3–5 s |
| `ssh_server_status` | ✓ | — |
Takeaway: ssh3 is ~3× faster on the hot path and byte-accurate; an idle WSL distro shuts down (~30 s) and kills inbound QUIC — hosts with `"wake": "wsl"` self-heal.
## License
[MIT](LICENSE) © 2026 nagual2
TDQS
Scored across 4 tools
Each tool has a clear, unique purpose: listing hosts, executing commands, reading files, and writing files. There is no overlap or ambiguity between the tools, and the descriptions clarify any caveats like 'direct hosts only'.
All tools follow a consistent 'ssh_' prefix followed by a verb_noun pattern: list_hosts, exec, read_file, write_file. The naming is predictable and uniform, making it easy for an agent to infer functionality.
Four tools is a well-scoped number for an SSH host management server. It covers the essential operations without unnecessary bloat, and each tool clearly earns its place.
The tool set covers the core lifecycle of SSH host interaction: listing, executing, reading, and writing. While operations like deleting files or managing host configurations are missing, the common workflows are fully supported, leaving only minor gaps.