feat(tooling): register the remote-ssh MCP server and split the SSH guidance

elway is a convergent deploy runner and it is good at that: 64 playbooks, sudo
handled once, upload, and three tiers of idempotency. It is the wrong shape for
"what is running over there", which is why this session answered every
reconnaissance question with a raw `ssh host '...'` despite CLAUDE.md saying to
use elway — a playbook for a read-only `docker ps` is ceremony, and each ad-hoc
command is a fresh handshake and a fresh permission decision.

The MCP server fills that half. It holds a persistent shell, so cwd and exported
variables survive between calls and a command costs ~6 ms rather than a full
connect. Measured against nh3-extdev and irv-ml1: state persisted across calls
on both, including the host reached over WireGuard.

CLAUDE.md now routes by intent — changing versus looking — rather than naming
one tool, because the previous single-tool instruction was being ignored in
practice for exactly the cases it did not fit.

Registered project-scoped rather than user-scoped: this workspace is where SSH
infra work happens, and a server that can open shells on the fleet should be
opted into per project rather than inherited everywhere.

SSH_MCP_STRICT_ALLOWLIST=1 is set in .mcp.json deliberately and documented as
load-bearing. Without it the allowlist is every Host in ~/.ssh/config — 18
aliases including both backup servers, database VMs and a tenant-managed host.
Verified: corviduo-dev, present in ssh_config but not in our allowlist, is
refused with host_not_allowed.
This commit is contained in:
2026-09-05 07:45:21 -07:00
parent 169fa357ff
commit 1f25cdcd19
2 changed files with 37 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"remote-ssh": {
"command": "node",
"args": ["/home/lkraven/development/remote-ssh-mcp/dist/index.js"],
"env": {
"SSH_MCP_STRICT_ALLOWLIST": "1"
}
}
}
}
+26 -2
View File
@@ -21,14 +21,38 @@ lags the code defeats its own purpose.
**New session starting here?** Read [`docs/orientation.md`](docs/orientation.md) first — fleet topology, backup architecture, governing principles, and all the NFS/DSM/naming gotchas that have cost past sessions time.
**For SSH-driven work: use `scripts/elway`.** Write a playbook under
**SSH work splits two ways. Pick by whether you are CHANGING something or
LOOKING at something — neither tool replaces the other.**
**Changing things → `scripts/elway`.** Write a playbook under
`playbooks/<name>.yaml` and run
`scripts/elway <host> --playbook ...` instead of chaining
`ssh -t host 'sudo …'` commands — handles sudo once lazily,
structured pass/change/fail reporting, idempotency via
`creates:` / `when:` / `changed_when:`. Template:
`creates:` / `when:` / `changed_when:`, and file upload. Template:
[`playbooks/elway-smoke.yaml`](playbooks/elway-smoke.yaml).
**Looking at things → the `remote-ssh` MCP server.** Registered project-scoped in
[`.mcp.json`](.mcp.json); tools `ssh_hosts` / `ssh_open` / `ssh_run` / `ssh_peek` /
`ssh_interrupt` / `ssh_list` / `ssh_close`. It holds a **persistent shell**, so
`cd` and exported variables survive between calls and each command costs ~6 ms
instead of a fresh handshake. Reach for it for reconnaissance — the
`docker ps` / `grep` / `curl` questions where authoring a playbook is ceremony.
It deliberately has **no file transfer and no idempotency**; that is elway's half.
- Source: `~/development/remote-ssh-mcp` — our fork of
[the-nine-nation/remote-ssh-mcp](https://gitea.phasefinal.com/vh/remote-ssh-mcp)
(MIT). `.mcp.json` points at the **built** `dist/`, so after editing the fork
run `npm run build` or the server keeps serving the old code.
-**The host allowlist is the only real containment boundary.** The denylist
is three regexes and a `bash -c` walks past it. We run with
`SSH_MCP_STRICT_ALLOWLIST=1`, which makes `~/.config/remote-ssh-mcp/config.json`'s
`allowedHosts` **authoritative** rather than additive — without it, every one of
the 18 `Host` entries in `~/.ssh/config` is reachable. Widening that list is a
deliberate act; do it in the config file, not by relying on discovery.
- Hosts not in the allowlist return `host_not_allowed`. Hosts reachable only by
raw IP (no `Host` alias) are not reachable at all — add an alias first.
**Task visibility via task-board.** If the Claude Code session has
the `task-board` plugin enabled (installed from
`git@gitea.phasefinal.com:vh/task-board.git`), a card at