There are more than two dozen SSH MCP servers on GitHub. I checked before I wrote one. Then I wrote one anyway, which is, I am aware, exactly what each of those two dozen authors also did. So this is the part where I explain why mine is not just the twenty-sixth copy of the same idea.
What’s already out there
The existing servers sort into two rough camps.
The first is the just-works tier. You install it, point it at a host, and the model can run anything. They are easy to spot, because the README is about thirty lines long and the word security does not appear in it. One of the Go implementations sets its host-key callback to ssh.InsecureIgnoreHostKey() and hardcodes it. No verification, no flag to turn it on, no known_hosts. And most of this camp has no concept of a command being dangerous. reload and show version take the exact same code path.
The second camp is more careful, and credit where it is due. One popular TypeScript server supports a configurable allowlist and denylist of commands. There is a genuinely rigorous one built on FastMCP, the same framework I used, with a four-tier access model, strict host-key checking on by default, a hashed audit log, and close to a thousand tests. If what you want is an agent that manages Linux servers, go use that one. It is better at that job than mine is.
So why another. Because every server in both camps is built for a Unix host. And I do not spend my day on Unix hosts. I spend it on switches, routers, and firewalls, and that turns out to matter more than you might initially expect.
A generic SSH tool meets a switch
Point a generic SSH-exec tool at an Aruba CX switch, or at the ArubaOS-Switch (ProCurve), and watch what happens.
It hangs on the pager. A switch hands you show running-config one screen at a time and waits for a keypress that the tool is never going to send. It does not know about enable. It does not know about config mode. One specific wrinkle: ProCurve and the ArubaOS Mobility Controllers present an interactive login banner that scrapli’s prompt detection cannot get past. scrapli stalls inside open(), never gets far enough to run a command, and no post-open hook can recover. I ended up writing a separate raw asyncssh PTY path for that whole class of gear: open a PTY, dismiss the banner, drain it, disable paging, and detect end-of-output by quiet time instead of prompt pattern. The tools call it through the same interface as scrapli and do not need to know the difference. And on the occasion you do get a full running-config back, it is a couple thousand lines with the RADIUS keys and SNMP communities sitting in plain sight, on their way into the model’s context window.
None of that is a knock on the other servers. They were not built for this. That is the entire point.
Read-only by default, and not as a toggle
A typo on a laptop is an inconvenience. A typo on a core router is a campus-wide outage and a long afternoon. I once worked with a programmer who was poking around on a core router and using the abbreviated sh for show commands. They were in interface configuration mode, typed sh, and hit Enter. There is no show command in interface configuration mode. There is, however, a shutdown command. Guess what the programmer did.
So the server is read-only by default, and the word default is doing real work in that sentence.
Write mode is an environment variable, read once when the server starts. When it is off, the configuration tool (ssh_send_config) is not disabled. It is never registered. The model sees only the read tools: ssh_check_reachable to confirm a host answers, and ssh_run_command / ssh_run_commands to ask it questions. It cannot call a tool it cannot see, and it cannot be argued into a mode that does not exist in the running process. Turning on write access is a human editing a config file on purpose, not something that happens in the middle of a conversation.
Security as the default, not a flag
Secure is an easy word to put in a README. Here is what it actually means in this one.
The read tools run every command through a denylist before anything connects. That covers the obvious entries, reload and erase and write, and also debug, because a debug all on a busy router is its own kind of outage. It blocks outbound commands too, ssh and telnet and curl, so the agent cannot quietly use a switch as a jump box to somewhere it has no business being. A security review pass caught one bypass: $(reload) and backticks slipping a destructive verb through shell command substitution. That is closed too.
Credentials flow in by reference, not by value. The agent picks a credential profile by name; the password or private key (with passphrase if needed) lives in the server process and is never visible to the model, never in a tool-call log, never in a prompt-injection target. Outbound, every byte of device output runs through credential redaction before it leaves the process. I did not want to trust that on faith, so I pointed the server at a switch in my homelab and pulled the real running-config. 2,461 lines. 23 of them carried a secret: local account passwords, RADIUS and TACACS keys, the SNMP community, BGP neighbor passwords. All 23 came back masked. Nothing leaked.
Host keys are verified trust-on-first-use. The key gets pinned on the first connection, and a changed key after that fails the connection instead of shrugging and continuing. It is the same behavior OpenSSH calls accept-new, and it is a long way better than the verification-disabled default I found nearly everywhere else.
An optional host allowlist (globs and CIDR blocks) confines which devices the server may even reach. An output cap (1 MB by default) keeps a runaway show tech-support from drowning the model’s context window. And if you run the server over HTTP instead of stdio, it refuses to start without an auth token. An unauthenticated HTTP endpoint that runs commands on network equipment is not a feature. It is an incident waiting for a date.
Letting scrapli do the vendor quirks
I did not hand-roll paging, prompt detection, and enable handling for every vendor. That road ends in madness and a very long bug list. scrapli already does it, per platform, and does it well. The server maps a platform name to the right driver, hands genuinely old gear the legacy cipher list it needs to negotiate at all, and otherwise stays out of the way. Aruba CX, Cisco across four flavors, Arista, Junos, VyOS, Palo Alto PAN-OS, Huawei VRP, FortiOS, and a generic driver for the actual Unix hosts on the rare occasion you need one. ProCurve and the ArubaOS Mobility Controllers route through the raw PTY path from earlier. The list of supported platforms is exposed as an MCP resource (ssh://platforms) so callers can discover it instead of hardcoding it.

When the session drops
SSH sessions drop. A flapping link, a control-plane CPU spike, a TCP RST out of nowhere. The honest question is what the operator and the agent see when it happens mid-stream.
On a read batch, ssh_run_commands returns the results it gathered before the drop, with the failed command marked. You see exactly which show made it back and which did not, rather than losing the whole batch to a bare error. On a write, ssh_send_config returns a partial result on the generic shell path that tells you how many of N commands were sent before the session dropped. A half-applied config is a dangerous state, and the worst version of that state is the one where you do not know which half. The server makes sure you know.
The audit log
Live visibility is the first half of operator trust. A record of what happened after the fact is the second.
An SSH server that runs commands on network gear should leave a durable trail of what it did. Now it does.
Audit logging is FastMCP middleware. It wraps every tool call in one place, which means it catches the cases that matter most: a denylist-rejected command, an SSH timeout, a credential mismatch. Not only the calls that succeeded. Each call writes one JSON line: timestamp, tool, host, platform, credential profile, the commands sent, the outcome. Commands get run through the same redaction pass as device output, because a write-mode config line can carry a secret. Device output itself is not logged. The point is a trail of intent and result, not a transcript.
A sink failure can never break a tool call. If the log file goes away or the disk fills, the tool still runs and the failure goes to the server log instead of the device session. The audit log exists to record what the server did. It does not get to stop the server from doing it.
It is opt-in. SSH_MCP_AUDIT_LOG takes a file path (JSONL, appended) or the literal stderr for piping into whatever you already collect logs with. Unset, off. The ssh://platforms resource reports whether it is enabled, so an agent can tell at a glance.
What it is, and what it is not
This is a tool for letting an agent read a network during triage. It plugs into the network-diagnostic workflows I already use, so when a ticket comes in the agent can look at the switch instead of guessing at it. It is not a server-management tool and it is not trying to become one. If you want to manage Linux boxes, the rigorous FastMCP server I mentioned earlier will treat you better.
The code is at https://github.com/CyberneticCodeComposer/ssh-mcp. It ships as a regular MCP stdio server, an HTTP server with the token guard above, and a Claude Desktop extension (.dxt) that prompts for credentials at install time. Pick the one that matches how you already work. Depending on what platforms you are connecting to, you will want to load up the SKILLS files so the agent will know what commands to run to get the correct information.
The thing I keep coming back to: most of this gear has an API. Aruba CX has REST. Cisco has RESTCONF and NETCONF and gNMI. Fortinet has its own. Juniper has Junos PyEZ. They are all different, and in a multi-vendor environment SSH is the lowest common denominator. Not the best interface to any one device, but the one interface that works across all of them. That is the gap worth building for.













































