Skip to main content
Glama
thekk1
by thekk1

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 host

Credentials 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:

  1. 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).

  2. The real Unix permissions attached to whichever key gets used. ssh_exec runs 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-mcp

The /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 up

Verified 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]'
pytest

Unit 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.

F
license - not found
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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

  • A
    license
    B
    quality
    D
    maintenance
    Enables 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.
    4
    207
    MIT

View all related MCP servers

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.

View all MCP Connectors

Latest Blog Posts

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