S

SSH — policy-gated remote access Community

MCP server · by tufantunc

Developer Tools npm v2.22.0

Policy-gated, audited SSH for Linux and Windows hosts: roles, approvals, and an audit log.

View source ↗ Website ↗ Generate config for multiple servers More servers

Install

registry version 2.22.0

npm
npx -y ssh-mcp

Setup by client

Pick your client — the config below is generated for this server (local stdio). Copy respects the “Pin version” switch above.

claude_desktop_config.json

{
  "mcpServers": {
    "ssh": {
      "command": "npx",
      "args": [
        "-y",
        "ssh-mcp"
      ],
      "env": {
        "SSH_MCP_PASSWORD": "<YOUR_SSH_MCP_PASSWORD>",
        "SSH_MCP_PASSPHRASE": "<YOUR_SSH_MCP_PASSPHRASE>",
        "SSH_MCP_SUDO_PASSWORD": "<YOUR_SSH_MCP_SUDO_PASSWORD>"
      }
    }
  }
}

Format verified against the official Claude Desktop docs ↗.

Environment variables

Defined in the server's official registry manifest. Required secrets are shown as <YOUR_…> placeholders in the configs above.

  • SSH_MCP_PASSWORD secret — Password for the configured profile; a per-profile SSH_MCP_<PROFILE>_PASSWORD takes precedence. Credentials are never accepted as CLI arguments.
  • SSH_MCP_KEY — Path to the private key file to authenticate with — the path, not the key material. A per-profile SSH_MCP_<PROFILE>_KEY takes precedence.
  • SSH_MCP_PASSPHRASE secret — Passphrase for an encrypted private key.
  • SSH_MCP_SUDO_PASSWORD secret — Password the sudo tool escalates with, when the policy in force allows the privileged class.

Implementation status

Community implementation

"ssh" is a general capability, not a single product — no official MCP server exists for it. This page features the most popular community implementation as of 2026-10-15.

Evidence ↗

Other community implementations

Independent, non-official implementations of the SSH — policy-gated remote access MCP server maintained by the community.

Last verified: 2026-10-15 | Sources: Verification basis ↗

GitHub

⭐ 808 stars · 最近更新于 15 天前 · MIT license

About this server

SSH MCP Server v2 SSH MCP Server is a security-first Model Context Protocol server that gives LLM agents controlled SSH access to remote hosts — with command classification, policy-based authorization, human-in-the-loop approval, and full audit logging. > The risk this server exists to manage. Giving an LLM shell access on a remote host puts private data, untrusted input and network egress in one place — Simon Willison's "lethal trifecta". Prompt injection has no general fix, so ssh-mcp assumes any command may be attacker-influenced: it classifies before executing, authorizes against a role × host-group matrix, gates destructive work behind approval, and records the decision either way. That narrows the blast radius; it does not remove the risk. Two things stay yours: never point it at a root account , and never set auto approval on a production profile . SECURITY.md has the full threat model. --- Quick Start Install Configure Without a config the server still starts, so a client or directory can complete the MCP handshake and read tools/list — but every tool call is refused until you configure it, with a message naming the path below. Nothing runs on a host until this step is done. Create the config file at the path for your platform: Platform Path --- --- Linux /.config/ssh-mcp/config.toml (or $XDG CONFIG HOME/ssh-mcp/config.toml ) macOS /Library/Application Support/ssh-mcp/config.toml Windows %APPDATA%\ssh-mcp\config.toml The config decides which hosts, roles and policy rules this server honours, so it checks that nobody but you can read it — and treats the two platforms differently, because the question has a much clearer answer on one of them. Linux and macOS: enforced. The mode check above, on the file and the directory — which is why chmod 700 is in that command, since mkdir -p under the default umask leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is unambiguous here and chmod is a one-line fix. Windows: split by what the ACL actually allows. There are no mode bits, so the ACL is read instead — and read exposure and write exposure are not treated alike, because Windows is much clearer about one of them than the other. The ACL lets another account… Default --- --- only read the config reported, and the server starts change the config refused nothing (no ACL at all) refused — that is full control for everyone …and if the ACL could not be read refused, except when icacls is absent or the check timed out A config under %APPDATA% inherits access for you, SYSTEM and Administrators and needs nothing done to it. One created elsewhere does not: a file under C:\ inherits read for every local account and modify for every authenticated one. The message names the two icacls commands that fix it either way. Read exposure is reported rather than refused because that is where Windows is genuinely muddier than POSIX, and refusing over it blocked a config at the documented location ( 138). Write exposure is refused because it is not muddy at all: another account being able to rewrite the file that decides which hosts, roles and approval policy this server honours is an authorization bypass, not a disclosure. Two flags move the whole thing: --strictConfigAcl refuses everything the check objects to, read-only grants included; --allowUncheckedConfigAcl reports everything and refuses nothing. Neither combination leaves you without an exit, which is the lesson of 138. Exit statuses Status Meaning --- --- 0 Clean shutdown 1 A defect in the server — printed with a stack trace; please report it 2 How it was invoked or configured — printed as a message, no stack A supervisor that treats any non-zero status as a failure needs no change. One that matched on 1 to detect a startup problem should match on 2 as well. Starting with nothing configured is not an exit-2 condition, as of the release that added introspection without a config: the server starts so it can be described, and refuses each tool call instead. A supervisor that used a non-zero exit to catch an unconfigured deployment should watch for starting unconfigured on stderr, or read configured from GET /health when running the HTTP transport. Set credentials via environment variables Connect from your MCP client Claude Code: Claude Desktop / Cursor / Windsurf: Never pass passwords as CLI arguments — they're visible via ps aux . Use env vars, config files, SSH agent, or OS keychain. --- Tools (14) Tool Purpose readOnly destructive ------ --------- :--------: :----------: list-connections Discover available hosts and connection status ✅ — list-sessions List active sessions per host ✅ — open-session Create a named interactive (stateful) or background session — — close-session Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped — ✅ read-session-output Read output from background sessions (e.g., tail -f ) ✅ — read-command Execute allowlisted read-only commands ( ls , cat , grep , ...) ✅ — run-command Execute arbitrary commands (destructive/privileged need approval, unless approvalPolicy = "auto" ) — — privileged-command Execute with sudo (needs approval, unless approvalPolicy = "auto" ) — ✅ sftp-upload Upload a file via SFTP (replaces the destination unconditionally) — ✅ sftp-download Download a file via SFTP ✅ — sftp-list List a remote directory, bounded in entries and bytes ✅ — sftp-upload-file Stream a local file to the remote host, never through model context — ✅ sftp-download-file Stream a remote file to local disk, never through model context — ✅ signal-process Send INT/TERM/KILL to a remote PID — ✅ Streaming file transfer sftp-upload / sftp-download move file contents through the model's context: the text is an argument on the way out and a response on the way back. That is what you want for a config snippet and exactly what you do not want for a 200 MB tarball or anything binary. The two also differ on what they do to an existing destination, and the approved string now says which is which. sftp-upload replaces unconditionally and spells --overwrite every time, because that is what it always does; sftp-upload-file refuses unless you pass overwrite: true , and only then carries the flag. Since sftp-upload takes its content as an argument rather than naming a local file, its string also carries --bytes= > --sha256= — without that, two uploads to the same path are the same string, which means one approval covers both and an auditor cannot tell which set of bytes landed. The digest is 128 bits rather than a short prefix because that claim has to hold against a caller who picks both payloads, not only against an accidental repeat. [policy].denylist patterns are tested against that whole string and, for every SFTP tool, against the remote path on its own — as given, lexically normalized, and read the way Windows reads it (see "Policy Engine"). A rule written for the path, such as authorized keys$ , therefore does not depend on where the string puts it or on how the file is spelled. A rule anchored on the whole string ( ^sftp:upload /root/. $ ) still does: that layout is ours to change, so anchor on the path instead. sftp-upload-file and sftp-download-file stream between the remote host and local disk instead. Neither the bytes nor a base64 encoding of them ever reaches the model — the response is a byte count and two paths. The two transfer tools are off until you configure defaults.transferRoot , and refuse with an explanation until then. ( sftp-list has no local side and needs none of this.) That directory is the whole of their local reach: Checked on every call, and refused rather than degraded if any of it fails: the directory must be 0700 and owned by the account running this server, with no group- or world-writable parent, and must not overlap the ssh-mcp installation, the config directory, the audit log directory, or /.ssh . Caller paths are confined to it, symlinks are refused rather than followed, and a download is staged as a sibling .part file and published atomically, so a failed transfer never leaves a half-written file at the destination. transferTimeoutMs is an idle budget, not a total one: it bounds one metadata round-trip, or one stretch of the copy with no bytes moving, and is re-armed on progress. A slow but live transfer of a large file survives it; a stalled channel still fails within one window. That is why the byte cap above does not have to be divided by it — a total budget would have made a 256MB cap mean "only if the link sustains 900 KB/s". Not available on Windows, where the transfer root cannot yet be verified private; the two transfer tools refuse there rather than writing into a directory other accounts may be able to read. sftp-upload-file takes an optional mode (1–511; setuid, setgid and the sticky bit are refused). Omitted, a new remote file is published 0600 ; an overwritten one inherits the replaced file's permission bits — its permission bits only, not its setuid or setgid. What a transfer is authorized for includes these arguments: the string the policy engine classifies, the approval prompt a human reads, and the audit record all name --overwrite and --mode when they are given. So approving one upload to a path does not approve a different one to the same path, and an approval grant ( approvalGrantTtlMs ) cannot be replayed with a different mode. Interactive Sessions Sessions maintain state (CWD, environment variables) between commands: Background Sessions Long-running processes (logs, builds): Remote host support Tested against Linux (Debian/bash, Alpine/busybox ash), Dropbear, and Windows OpenSSH on Windows 11. Linux / BSD / macOS Windows OpenSSH --- :---: :---: read-command , run-command , privileged-command , signal-process ✅ ✅ sftp-upload , sftp-download ✅ ✅ sftp-list ✅ ✅ sftp-upload-file , sftp-download-file ✅ ❌ (local side unverifiable) Background sessions ✅ ✅ Interactive sessions ✅ ❌ Interactive sessions require a POSIX shell (sh, bash, ash, zsh). They work by bracketing each command with printf markers and reading $? and $PWD from a trailer — none of which exist in cmd.exe , the default shell for Windows OpenSSH. Opening one against such a host fails immediately with an explicit error rather than timing out; everything else works normally. Setting PowerShell as the OpenSSH DefaultShell does not help: the protocol is POSIX-specific, not merely non- cmd . --- Configuration Profile options Unknown sections and keys are a startup error, not a warning, so a typo cannot leave you running defaults you thought you had overridden. That extends to role and tier names: every one you write under [policy.roleBindings] has to be reachable by some profile, and every profile's role and tier has to resolve to real bindings. Both directions are checked at startup. Host-side identification Every command this server runs announces itself to the host as the environment variable AI AGENT=ssh-mcp , so an operator can tell an agent's session from a person's from anything on the host that reads the session environment — a /etc/profile snippet or a ForceCommand wrapper, for instance. It covers all three channels a command can run on: one-shot commands, background sessions and interactive sessions. SFTP transfers do not carry it, because the SSH library this server uses offers no way to send an environment request on a subsystem channel. The variable only lands in the session if the host opts in with AcceptEnv AI AGENT in sshd config . Without that line sshd ignores it and the session environment is unchanged — measured against Dropbear, which has no AcceptEnv mechanism at all, and against Windows OpenSSH on a default configuration: the command runs, the exit code is unchanged, the variable is simply absent. The request is nevertheless sent to every host, opted in or not. AcceptEnv is the server's policy about what it stores; it is not a gate on what this client transmits. A host that has not opted in — including one that is hostile — can still see on the wire that an agent rather than a person is driving the session. That is why announceAgent = false exists on the profile: set it for any host you do not control and nothing is sent to it. It is on by default, because the operators who benefit are the ones running the hosts you do control. No version is sent, deliberately. A version would narrow a hostile host to one specific build; the tool's name alone does not. (The SSH transport already discloses the library version in its identification string, which is one library's version across every program that uses it.) Treat the variable as a courtesy label, not an attestation. Any SSH client can set it and any agent can omit it, so it is useful for attributing sessions you already trust and must not be used as an authorization or intrusion-detection input. Authoritative attribution belongs to the key or principal that authenticated. ProxyJump (Bastion) Reach internal hosts behind a bastion/jump server. The via field specifies a profile name to tunnel through: No agent forwarding — only a TCP tunnel via forwardOut . The bastion stays connected and reusable for multiple internal hosts. SSH CA Certificates For enterprise setups with a central SSH Certificate Authority: The certificate file is auto-detected using OpenSSH convention ( keyRef + -cert.pub , e.g. /.ssh/id ed25519-cert.pub ). You can override the path with SSH MCP > CERT env var. The cert is concatenated with the private key per ssh2 convention. Credential Resolution Order SSH agent ( SSH AUTH SOCK ) — no key material in process memory OS keychain (macOS Keychain / Windows Credential…

Summary from the project README — github.com/tufantunc/ssh-mcp

Distributed as npm, listed under Developer Tools. Review our MCP security guide before granting access to real data.

Frequently asked questions

What does the SSH — policy-gated remote access MCP server do?

SSH MCP Server v2 SSH MCP Server is a security-first Model Context Protocol server that gives LLM agents controlled SSH access to remote hosts — with command classification, policy-based authorization, human-in-the-loop approval, and full audit logging. > The risk this server exists to manage. Giving an LLM shell access on a remote host puts private data, untrusted input and network egress in one place — Simon Willison's "lethal trifecta". Prompt injection has no general fix, so ssh-mcp assumes any command may be attacker-influenced: it classifies before executing, authorizes against a role × host-group matrix, gates destructive work behind approval, and records the decision either way. That narrows the blast radius; it does not remove the risk. Two things stay yours: never point it at a root account , and never set auto approval on a production profile . SECURITY.md has the full threat model. --- Quick Start Install Configure Without a config the server still starts, so a client or directory can complete the MCP handshake and read tools/list — but every tool call is refused until you configure it, with a message naming the path below. Nothing runs on a host until this step is done. Create the config file at the path for your platform: Platform Path --- --- Linux /.config/ssh-mcp/config.toml (or $XDG CONFIG HOME/ssh-mcp/config.toml ) macOS /Library/Application Support/ssh-mcp/config.toml Windows %APPDATA%\ssh-mcp\config.toml The config decides which hosts, roles and policy rules this server honours, so it checks that nobody but you can read it — and treats the two platforms differently, because the question has a much clearer answer on one of them. Linux and macOS: enforced. The mode check above, on the file and the directory — which is why chmod 700 is in that command, since mkdir -p under the default umask leaves the directory 0755. The server refuses to start otherwise. "Only the owner" is unambiguous here and chmod is a one-line fix. Windows: split by what the ACL actually allows. There are no mode bits, so the ACL is read instead — and read exposure and write exposure are not treated alike, because Windows is much clearer about one of them than the other. The ACL lets another account… Default --- --- only read the config reported, and the server starts change the config refused nothing (no ACL at all) refused — that is full control for everyone …and if the ACL could not be read refused, except when icacls is absent or the check timed out A config under %APPDATA% inherits access for you, SYSTEM and Administrators and needs nothing done to it. One created elsewhere does not: a file under C:\ inherits read for every local account and modify for every authenticated one. The message names the two icacls commands that fix it either way. Read exposure is reported rather than refused because that is where Windows is genuinely muddier than POSIX, and refusing over it blocked a config at the documented location ( 138). Write exposure is refused because it is not muddy at all: another account being able to rewrite the file that decides which hosts, roles and approval policy this server honours is an authorization bypass, not a disclosure. Two flags move the whole thing: --strictConfigAcl refuses everything the check objects to, read-only grants included; --allowUncheckedConfigAcl reports everything and refuses nothing. Neither combination leaves you without an exit, which is the lesson of 138. Exit statuses Status Meaning --- --- 0 Clean shutdown 1 A defect in the server — printed with a stack trace; please report it 2 How it was invoked or configured — printed as a message, no stack A supervisor that treats any non-zero status as a failure needs no change. One that matched on 1 to detect a startup problem should match on 2 as well. Starting with nothing configured is not an exit-2 condition, as of the release that added introspection without a config: the server starts so it can be described, and refuses each tool call instead. A supervisor that used a non-zero exit to catch an unconfigured deployment should watch for starting unconfigured on stderr, or read configured from GET /health when running the HTTP transport. Set credentials via environment variables Connect from your MCP client Claude Code: Claude Desktop / Cursor / Windsurf: Never pass passwords as CLI arguments — they're visible via ps aux . Use env vars, config files, SSH agent, or OS keychain. --- Tools (14) Tool Purpose readOnly destructive ------ --------- :--------: :----------: list-connections Discover available hosts and connection status ✅ — list-sessions List active sessions per host ✅ — open-session Create a named interactive (stateful) or background session — — close-session Close a session. A background session's command is signalled (INT/TERM/KILL) before its channel is dropped — ✅ read-session-output Read output from background sessions (e.g., tail -f ) ✅ — read-command Execute allowlisted read-only commands ( ls , cat , grep , ...) ✅ — run-command Execute arbitrary commands (destructive/privileged need approval, unless approvalPolicy = "auto" ) — — privileged-command Execute with sudo (needs approval, unless approvalPolicy = "auto" ) — ✅ sftp-upload Upload a file via SFTP (replaces the destination unconditionally) — ✅ sftp-download Download a file via SFTP ✅ — sftp-list List a remote directory, bounded in entries and bytes ✅ — sftp-upload-file Stream a local file to the remote host, never through model context — ✅ sftp-download-file Stream a remote file to local disk, never through model context — ✅ signal-process Send INT/TERM/KILL to a remote PID — ✅ Streaming file transfer sftp-upload / sftp-download move file contents through the model's context: the text is an argument on the way out and a response on the way back. That is what you want for a config snippet and exactly what you do not want for a 200 MB tarball or anything binary. The two also differ on what they do to an existing destination, and the approved string now says which is which. sftp-upload replaces unconditionally and spells --overwrite every time, because that is what it always does; sftp-upload-file refuses unless you pass overwrite: true , and only then carries the flag. Since sftp-upload takes its content as an argument rather than naming a local file, its string also carries --bytes= > --sha256= — without that, two uploads to the same path are the same string, which means one approval covers both and an auditor cannot tell which set of bytes landed. The digest is 128 bits rather than a short prefix because that claim has to hold against a caller who picks both payloads, not only against an accidental repeat. [policy].denylist patterns are tested against that whole string and, for every SFTP tool, against the remote path on its own — as given, lexically normalized, and read the way Windows reads it (see "Policy Engine"). A rule written for the path, such as authorized keys$ , therefore does not depend on where the string puts it or on how the file is spelled. A rule anchored on the whole string ( ^sftp:upload /root/. $ ) still does: that layout is ours to change, so anchor on the path instead. sftp-upload-file and sftp-download-file stream between the remote host and local disk instead. Neither the bytes nor a base64 encoding of them ever reaches the model — the response is a byte count and two paths. The two transfer tools are off until you configure defaults.transferRoot , and refuse with an explanation until then. ( sftp-list has no local side and needs none of this.) That directory is the whole of their local reach: Checked on every call, and refused rather than degraded if any of it fails: the directory must be 0700 and owned by the account running this server, with no group- or world-writable parent, and must not overlap the ssh-mcp installation, the config directory, the audit log directory, or /.ssh . Caller paths are confined to it, symlinks are refused rather than followed, and a download is staged as a sibling .part file and published atomically, so a failed transfer never leaves a half-written file at the destination. transferTimeoutMs is an idle budget, not a total one: it bounds one metadata round-trip, or one stretch of the copy with no bytes moving, and is re-armed on progress. A slow but live transfer of a large file survives it; a stalled channel still fails within one window. That is why the byte cap above does not have to be divided by it — a total budget would have made a 256MB cap mean "only if the link sustains 900 KB/s". Not available on Windows, where the transfer root cannot yet be verified private; the two transfer tools refuse there rather than writing into a directory other accounts may be able to read. sftp-upload-file takes an optional mode (1–511; setuid, setgid and the sticky bit are refused). Omitted, a new remote file is published 0600 ; an overwritten one inherits the replaced file's permission bits — its permission bits only, not its setuid or setgid. What a transfer is authorized for includes these arguments: the string the policy engine classifies, the approval prompt a human reads, and the audit record all name --overwrite and --mode when they are given. So approving one upload to a path does not approve a different one to the same path, and an approval grant ( approvalGrantTtlMs ) cannot be replayed with a different mode. Interactive Sessions Sessions maintain state (CWD, environment variables) between commands: Background Sessions Long-running processes (logs, builds): Remote host support Tested against Linux (Debian/bash, Alpine/busybox ash), Dropbear, and Windows OpenSSH on Windows 11. Linux / BSD / macOS Windows OpenSSH --- :---: :---: read-command , run-command , privileged-command , signal-process ✅ ✅ sftp-upload , sftp-download ✅ ✅ sftp-list ✅ ✅ sftp-upload-file , sftp-download-file ✅ ❌ (local side unverifiable) Background sessions ✅ ✅ Interactive sessions ✅ ❌ Interactive sessions require a POSIX shell (sh, bash, ash, zsh). They work by bracketing each command with printf markers and reading $? and $PWD from a trailer — none of which exist in cmd.exe , the default shell for Windows OpenSSH. Opening one against such a host fails immediately with an explicit error rather than timing out; everything else works normally. Setting PowerShell as the OpenSSH DefaultShell does not help: the protocol is POSIX-specific, not merely non- cmd . --- Configuration Profile options Unknown sections and keys are a startup error, not a warning, so a typo cannot leave you running defaults you thought you had overridden. That extends to role and tier names: every one you write under [policy.roleBindings] has to be reachable by some profile, and every profile's role and tier has to resolve to real bindings. Both directions are checked at startup. Host-side identification Every command this server runs announces itself to the host as the environment variable AI AGENT=ssh-mcp , so an operator can tell an agent's session from a person's from anything on the host that reads the session environment — a /etc/profile snippet or a ForceCommand wrapper, for instance. It covers all three channels a command can run on: one-shot commands, background sessions and interactive sessions. SFTP transfers do not carry it, because the SSH library this server uses offers no way to send an environment request on a subsystem channel. The variable only lands in the session if the host opts in with AcceptEnv AI AGENT in sshd config . Without that line sshd ignores it and the session environment is unchanged — measured against Dropbear, which has no AcceptEnv mechanism at all, and against Windows OpenSSH on a default configuration: the command runs, the exit code is unchanged, the variable is simply absent. The request is nevertheless sent to every host, opted in or not. AcceptEnv is the server's policy about what it stores; it is not a gate on what this client transmits. A host that has not opted in — including one that is hostile — can still see on the wire that an agent rather than a person is driving the session. That is why announceAgent = false exists on the profile: set it for any host you do not control and nothing is sent to it. It is on by default, because the operators who benefit are the ones running the hosts you do control. No version is sent, deliberately. A version would narrow a hostile host to one specific build; the tool's name alone does not. (The SSH transport already discloses the library version in its identification string, which is one library's version across every program that uses it.) Treat the variable as a courtesy label, not an attestation. Any SSH client can set it and any agent can omit it, so it is useful for attributing sessions you already trust and must not be used as an authorization or intrusion-detection input. Authoritative attribution belongs to the key or principal that authenticated. ProxyJump (Bastion) Reach internal hosts behind a bastion/jump server. The via field specifies a profile name to tunnel through: No agent forwarding — only a TCP tunnel via forwardOut . The bastion stays connected and reusable for multiple internal hosts. SSH CA Certificates For enterprise setups with a central SSH Certificate Authority: The certificate file is auto-detected using OpenSSH convention ( keyRef + -cert.pub , e.g. /.ssh/id ed25519-cert.pub ). You can override the path with SSH MCP > CERT env var. The cert is concatenated with the private key per ssh2 convention. Credential Resolution Order SSH agent ( SSH AUTH SOCK ) — no key material in process memory OS keychain (macOS Keychain / Windows Credential

How do I install SSH — policy-gated remote access?

Run the install command shown above, then paste the config for your client (Claude Desktop, Claude Code, Cursor, VS Code or Windsurf) into its MCP settings and restart the client.

Is SSH — policy-gated remote access free to use?

The MCP server itself is free to install. The source is public on https://github.com/tufantunc/ssh-mcp. Any third-party service it calls may require its own API key and account.

Which clients support SSH — policy-gated remote access?

Any MCP-compatible client can use it, including Claude Desktop, Claude Code, Cursor, VS Code, Windsurf and custom agents.

Add this badge

Publish this server? Link back with a badge on your README or site — copy either snippet.

Listed on MCPNav

Markdown

[![Listed on MCPNav](https://mcpnav.net/badge/ssh.svg)](https://mcpnav.net/server/ssh/)

HTML

<a href="https://mcpnav.net/server/ssh/"><img src="https://mcpnav.net/badge/ssh.svg" alt="Listed on MCPNav" height="20"></a>