- TypeScript 58%
- JavaScript 42%
| src | ||
| .gitignore | ||
| DESIGN-v2-oauth-gateway.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| servers.example.json | ||
| tsconfig.json | ||
kua-ssh
An SSH MCP that teaches the agent to use it well.
Most SSH MCPs ship a big drawer of narrow tools — ssh_tunnel_create, ssh_backup_schedule, ssh_session_send, ssh_db_query, forty of them. In practice an agent ignores almost all of it and runs everything through one generic exec, the hard way: hand-rolled nohup for long jobs, docker logs | tail for logs, docker exec ... psql for queries, heredocs for file writes.
We know because we measured it: across a full production migration, an agent used 1 of 37 tools. Every build, log tail, database query, and file copy went through raw exec.
kua-ssh is the reaction to that.
Two ideas
1. Fewer, broader tools. Seven, each one something you'll actually reach for:
| tool | what it does |
|---|---|
servers |
list configured hosts + their tier and access policy |
exec |
run a command (bash by default); fan out across hosts; detach:true for long jobs |
job |
poll / wait / stop a detached job — no sleep-loops |
logs |
container or file logs, paged and greppable |
transfer |
scp up/down without remembering flags |
health |
probe an endpoint or container, clean pass/fail |
db |
read-query or dump a registered database, credentials resolved for you |
2. A hint engine. This is the part worth stealing. Every exec runs its command through a set of rules. When the command looks like a job a dedicated tool does better, the result comes back with a nudge:
exit=0 812ms on sol-prod
stdout:
...
--- kua-ssh hints ---
This ran through `exec`, but a sharper tool fits:
- you ran `docker logs` -> next time use the **logs** tool. Hand-tailing logs is
fragile and easy to truncate; the logs tool reads container or file logs with
consistent paging.
e.g. logs({server, target:"muralla", lines:80})
The agent doesn't have to read a manual or remember the toolset. The toolset reminds it, at the exact moment it took the lazy path. Rules are pure data (src/hints.ts) — a regex, the better tool, and one sentence of why — so teaching a new better-path is a one-line PR.
Why it's better than what it replaces
- No timeout wall. Long builds and syncs run with
detach:trueand are tracked byjob. The old failure mode — a hard ~45s cutoff that killed the call while the remote process kept running — is gone. - bash, not dash. Commands run under
bash -lcby default, so${var:0:6},[[ ]], and process substitution just work. No heredoc gymnastics. - A real registry. Hosts are named, aliased, tiered (
prod/dev/bastion), and policed (full/restricted/readonly). Areadonlyhost refuses destructive commands; arestrictedhost runs only allow-listed ones. - Self-documenting. The hint engine turns 37-tools-you-ignore into 7-tools-you-use.
Quickstart
git clone <repo> kua-ssh && cd kua-ssh
npm install
cp servers.example.json servers.json # edit to taste
npm run typecheck # optional
npm start # speaks MCP over stdio
Register it with your MCP client (Claude Desktop / Cowork shown):
{
"mcpServers": {
"kua-ssh": {
"command": "node",
"args": ["--import", "tsx", "/abs/path/to/kua-ssh/src/index.ts"],
"env": { "KUA_SSH_SERVERS": "/abs/path/to/kua-ssh/servers.json" }
}
}
}
It uses your existing system ssh/scp and keys — nothing new to provision.
servers.json
{
"requireTailnet": true,
"servers": [
{ "name": "sol-prod", "aliases": ["solprod","prod"], "host": "100.64.x.x",
"user": "root", "tier": "prod", "policy": "full" },
{ "name": "sol-dev", "aliases": ["soldev","dev"], "host": "100.71.x.x",
"user": "kavi", "tier": "dev", "policy": "full" }
],
"databases": [
{ "name": "app-db", "server": "sol-prod", "container": "app-db",
"engine": "postgres" }
]
}
Tailnet-only
kua-ssh refuses to connect to anything that isn't a Tailscale address — an
IPv4 in 100.64.0.0/10, an IPv6 in fd7a:115c:a1e0::/48, or a *.ts.net
MagicDNS name. A public IP in servers.json is rejected before a socket is
opened. Set "requireTailnet": false at the top of the registry to disable, or
"allowNonTailnet": true on a single host for break-glass.
This only governs what kua-ssh will dial. For it to actually reach those addresses, the process must run on a tailnet node:
- Run it on a machine that's already on the tailnet (your workstation, a bastion) — nothing else to do.
- Run it in a container/sandbox that isn't on the tailnet — attach that
runtime to Tailscale and point kua-ssh at it: a
tailscaledsidecar, or userspace Tailscale with a SOCKS5 proxy (tailscale up --socks5-server=localhost:1055), then setKUA_SSH_PROXY_COMMAND="nc -X 5 -x 127.0.0.1:1055 %h %p"so every ssh/scp is dialed through the tailnet.
A note on where this should run
kua-ssh is the tool an agent uses to reach your servers — so two deployment rules matter:
- Don't run it on a host it isn't allowed to manage. If your design says the dev box can't reach prod, then don't run the prod-managing instance on the dev box. Run it from a control point allowed to reach every tier (your workstation, or a dedicated bastion). Develop anywhere; run it where the trust boundary says it belongs.
- Don't edit the instance you depend on. Ship changes as a second connector, prove them, then retire the old one. Breaking the SSH tool from inside the SSH tool is a bad afternoon.
v2: kua-control
A remote, OAuth-gated sibling for "manage the fleet from any browser" — with
short sessions, continuous/step-up authentication, per-action authorization,
ephemeral SSH certificates, and approvals for prod. It wraps this v1 tool core
rather than replacing it. See DESIGN-v2-oauth-gateway.md.
Status
Early. The exec / job / logs / transfer / health / db core is here and typechecks against @modelcontextprotocol/sdk ^1.26. The hint engine is intentionally the most finished part. PRs that add hint rules are the most welcome kind.
MIT.