ssh-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ssh-mcprun 'df -h' on my production server"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
ssh-mcp
An MCP server that lets an LLM run shell commands over SSH, authenticated with each user's own personal SSH key rather than a single shared service account. Built for multi-user chat platforms (e.g. LibreChat) where the server is shared but the SSH identity per request should not be.
One tool: ssh_exec(host, port, username, command). No host allowlist, no
command whitelist -- see "Security model" below for why, and what that
means for anyone deploying this.
Why this exists
A few existing open-source SSH MCP servers were checked before writing
this one (vignitin/multi-ssh-mcp, giuliolibrando/ssh-mcp-server,
tufantunc/ssh-mcp). None of them support per-request credentials: every
one bakes a single host/user/credential into environment variables or a
config file at startup, which only works for a single-user deployment or
a shared service account. None of that fits a setup where many different
people, each with their own SSH key, share one running MCP server.
So this is a small, purpose-built server against
asyncssh rather than a wrapper
around an existing tool -- there was nothing suitable to wrap.
Related MCP server: terminal-mcp-server
How it works
MCP client --(streamable-http, /mcp, per-request headers)--> ssh-mcp
|
| asyncssh,
| one connection
| per tool call
v
arbitrary target hostCredentials travel as per-request HTTP headers, not server configuration:
x-ssh-private-key-- the private key to authenticate with, base64-encoded (a raw multi-line PEM block can't survive as an HTTP header value)x-ssh-key-passphrase-- optional, if that key is passphrase-protected
Both are read fresh on every tool call, decoded, handed straight to
asyncssh, and then discarded -- nothing is written to disk, and nothing
is cached across requests. It's the calling client's job to attach the
right headers for the right user; see "Using this with LibreChat" below
for one way to do that.
Host keys use genuine trust-on-first-use (TOFU), not "accept anything,
always": the first connection to a given host:port pins its key
fingerprint to a JSON file on disk (hostkeys.py); every later connection
must match that pin exactly or gets refused with host_key_mismatch. This
can't stop a machine-in-the-middle attack on the very first contact with a
host, but it turns an unannounced key change afterwards -- rotation or a
real attack -- into a loud, explicit failure instead of a silent hole.
No API key or bearer-token gate on the MCP connection itself. That's a deliberate simplicity choice for a specific deployment shape: a server reachable only from a trusted internal network, where the client attaches per-user SSH credentials itself (see below) and network placement is the actual access boundary. If you're exposing this somewhere less trusted, put a gate in front of it -- this project doesn't include one.
Security model
ssh_exec does not filter which hosts, commands, or users are allowed.
Whatever host/port/username/command a caller passes gets attempted, full
stop. That's a deliberate trade-off, not an oversight: filtering by host
or command from inside the MCP server would be security theater, since
any caller with a valid key can just SSH there directly outside this tool
too. The two things that actually stand between a request and a real
shell are:
Whoever can reach this server and set the credential headers at all -- entirely outside this code's control. If you're deploying this behind a multi-tenant client, restricting which of your users can even see/use this tool is that client's job (see "Using this with LibreChat" for one concrete way to do it).
The real Unix permissions attached to whichever key gets used.
ssh_execruns with exactly the authority that key's target account has -- nothing more, nothing less.
If neither of those is actually enforced for a given deployment, this tool is exactly as dangerous as handing every caller a bare terminal on every host their key can reach. That's the intended model -- SSH's own authorization, not a reimplementation of it -- so make sure it's a model you actually want before deploying this.
Missing host/username: asked via MCP elicitation, not guessed by the model
host and username are deliberately not in the tool's required
schema fields (command stays required -- deciding what to run is the
model's job, not a human's). Making host/username required would make a
spec-compliant model refuse to even call the tool without them and
improvise a plain-text follow-up question itself instead -- which is
exactly the UX this avoids. When either is missing, elicit_missing_ssh_args()
in ssh_mcp/app.py asks the human directly, in one combined form,
via MCP elicitation
(elicitation/create, form mode) -- not something the model has to phrase
itself, and not separate round trips per field. port rides along in
that same form, pre-filled with its usual default (22) via the schema's
default, editable but not itself a reason to interrupt when it's the
only thing unset.
This degrades safely on a client that doesn't support elicitation.
elicit_missing_ssh_args() checks the client's declared capability
(session.check_client_capability(...)) before ever sending a request,
and catches any failure from the call itself; either way it falls back to
plain missing_host/missing_username errors the model can still relay
as text questions, rather than the tool call erroring out or hanging.
Elicitation support varies by client -- at the time of writing, several
popular MCP clients (including LibreChat) don't implement it yet, so this
mostly acts as forward-compatible groundwork today. It costs nothing when
unsupported and activates automatically on any client that adds real
elicitation support later, with no changes needed here.
Verified with a real ClientSession via mcp.shared.memory's in-memory
transport: with and without an elicitation_callback registered, the
accept/decline/cancel branches, and a full combined form (host + username
missing, port's default overridden) -- confirmed all three values actually
reach the SSH call exactly as elicited, not just asserted from reading the
spec.
Using this with LibreChat
LibreChat can attach per-user values to MCP request headers via
customUserVars
-- each user enters their own key once in Settings, and LibreChat injects
it into the configured header on every request for that user. librechat.yaml:
mcpServers:
ssh:
type: streamable-http
url: http://ssh-mcp:8080/mcp
serverInstructions: true
headers:
X-SSH-Private-Key: '{{SSH_PRIVATE_KEY}}'
X-SSH-Key-Passphrase: '{{SSH_KEY_PASSPHRASE}}'
customUserVars:
SSH_PRIVATE_KEY:
title: "SSH Private Key (Base64)"
description: "Your personal SSH private key, base64-encoded: `base64 -w0 ~/.ssh/id_ed25519`"
SSH_KEY_PASSPHRASE:
title: "SSH Key Passphrase (optional)"
description: "Only fill in if your private key is passphrase-protected"Both customUserVars entries need both title and description --
a title-only entry fails LibreChat's config validation at startup with a
ZodError that, confusingly, gets reported against unrelated-looking
fields (LibreChat validates the whole mcpServers block as one union of
transport types, so one missing field surfaces as several apparently
unrelated errors at once). librechat.yaml is only read at container
startup -- restart LibreChat after editing it.
Restricting which users can see this server
Nothing in this project restricts who can use it -- any user who can set
the SSH_PRIVATE_KEY header can call ssh_exec. If you need to limit
that to a subset of your users, that has to happen in LibreChat (or
whatever client you're using), not here. As of LibreChat 0.8.5+, its
admin panel has a config-override system (Configuration Management) that
can scope an additional mcpServers entry to a specific role or group --
a user outside that group has no ssh entry in their resolved config at
all, not just a hidden one. Two things worth checking against your own
LibreChat version before relying on this, rather than assuming:
It's documented as "in preview," not GA, as of this writing.
There's a known history of group-scoped overrides silently not applying while role-scoped ones did (danny-avila/LibreChat#13172). Confirm the fix is in your running version by testing directly -- put a user in/out of the group and check whether the server actually (dis)appears for them.
Run
docker build -t ssh-mcp .
docker run --rm -p 8080:8080 -v ssh-mcp-hostkeys:/data ssh-mcpThe /data volume is what makes TOFU host-key pins survive a container
recreate -- without it, every redeploy forgets every previously-seen host
key and re-pins on next contact (not a security hole, just a temporary
loss of the "detect a later change" property until each host has been
re-contacted once).
Example docker-compose.yml service, building from a local clone:
services:
ssh-mcp:
build: .
container_name: ssh-mcp
volumes:
- ssh-mcp-hostkeys:/data
restart: always
volumes:
ssh-mcp-hostkeys:Verify
curl -s http://127.0.0.1:8080/readyz # "ok" once the session manager is upVerified end-to-end manually (not just unit-tested): built the image, ran
it, connected a real MCP client over streamable-http with the credential
headers, tools/list showed ssh_exec, tools/call against a live
throwaway asyncssh-based SSH server executed a real command over a real
SSH handshake and returned its actual stdout. Also exercised directly
(bypassing the HTTP layer) against that same throwaway server: TOFU pin on
first contact, acceptance on a matching second contact, hard rejection on
a changed/mismatched host key, a garbage private key rejected as
invalid_key, an unauthorized key rejected as connection_failed, and a
non-zero remote exit code passed through as ok: true with that exit
code (not treated as a tool failure).
Test
pip install -e '.[dev]'
pytestUnit tests (credential-header parsing, TOFU pin/accept/reject logic, tool
schema, elicit_missing_ssh_args's capability-check/field-selection/
accept/decline/cancel/failure branches against a fake session) -- no real
network, subprocess, or MCP transport. The real-handshake scenarios and
the real ClientSession elicitation round trips (see above) were run
manually, not part of the automated suite.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseBqualityDmaintenanceEnables secure SSH connections to remote servers for executing shell commands and managing active sessions. It supports authentication via passwords or private keys and provides optional host-based access control.4207MIT
- AlicenseBqualityCmaintenanceEnables secure remote and local command execution via SSH, with session management and environment variable support.1323MIT
- AlicenseCqualityFmaintenanceEnables SSH remote command execution on remote machines with persistent connections, supporting automatic key discovery and connection pooling.230MIT
- FlicenseBqualityDmaintenanceEnables SSH-based deployment operations such as git pull, command execution, script upload/run, and SSH config management.4
Related MCP Connectors
Let AI operate servers without SSH. Choose actions, approve risky changes, and audit every step.
Issue, rotate and revoke scoped API-key passes for 25+ providers — the agent never sees a real key
Operate Linux, macOS and Windows from your LLM. Every action runs through an auditable allowlist.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/thekk1/ssh-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server