Give any AI agent secure, persistent SSH access to your infrastructure.
AI agents are stateless. Every tool call is a fresh execution — no working directory, no connection reuse, no session memory. When an agent needs to debug a production server, it can't cd into a directory, run a command, read the output, and then run another command in the same context. Traditional approaches spin up a new SSH connection per command, losing all state.
SSH-MCP sits between AI agents and remote infrastructure as a persistent SSH session manager that speaks the Model Context Protocol. It maintains long-lived SSH connections with working directory tracking, so agents interact with remote servers the same way a human does in a terminal.
┌──────────────┐ ┌──────────────────────────────────┐
│ │ MCP Protocol │ SSH-MCP Server │
│ AI Agent │◄──────────────────────► │ │
│ │ stdio / HTTP+SSE │ ┌────────────────────────────┐ │
│ Claude │ │ │ Session Pool │ │
│ Cursor │ │ │ │ │
│ Any MCP │ │ │ Session A ─► SSH Manager │──┼──► Server 1
│ Client │ │ │ Session B ─► SSH Manager │──┼──► Server 2
│ │ │ │ Session C ─► SSH Manager │──┼──► Bastion ─► Server 3
└──────────────┘ │ └────────────────────────────┘ │
└──────────────────────────────────┘
Each session gets its own isolated SSH connection manager. Connections persist across tool calls. Working directories track between commands. Files stream over SFTP. One binary, zero runtime dependencies.
docker run -v ssh-keys:/data -p 8000:8000 firstfinger/ssh-mcp:latestgo build -o ssh-mcp ./cmd/server
./ssh-mcpSee scripts/macos/README.md for persistent background service setup.
Then point your MCP client at the configured endpoint (default http://127.0.0.1:8000/mcp).
| Flag | Env | Default | Description |
|---|---|---|---|
-mode |
SSH_MCP_MODE |
http |
Transport: stdio or http |
-port |
PORT |
8000 |
HTTP listen port |
-global |
SSH_MCP_GLOBAL |
false |
Share one SSH manager across all sessions |
SSH keys are auto-generated Ed25519 at first connection:
- Development:
./data/id_ed25519 - Production (Docker):
/data/id_ed25519(mount a volume)
Every MCP client gets an isolated SSH connection pool. Three modes, depending on deployment:
┌─ Session Pool ────────────────────────────────────────────────┐
│ │
│ Per-session (default) │
│ Each MCP session gets a UUIDv7 ID and its own SSH manager │
│ │
│ Header-based (X-Session-Key) │
│ Sticky routing for load balancers — sessions survive │
│ MCP reconnects as long as the header stays the same │
│ │
│ Global (-global flag) │
│ Single shared manager — for single-user / local use │
│ │
└───────────────────────────────────────────────────────────────┘
Header-based sessions expire after 5 minutes of inactivity. Per-session pools are destroyed when the MCP session ends.
connect(host, user) Creates SSH client, tracks as "primary"
connect(host2, user, alias) Creates second client with alias
run(command) Executes on primary, tracks CWD
run(command, target=alias) Executes on aliased connection
read/write/edit(path) SFTP operations on target
disconnect() Closes all connections in session
Working directory persists across run calls — cd /tmp && pwd followed by ls will list /tmp. This is the key difference from stateless execution.
Agent ──► SSH-MCP ──► Bastion ──► Internal Server
(via)
connect(bastion, admin, alias="jump")
connect(internal-host, admin, via="jump")
The second connection tunnels through the first. The agent doesn't need to know about the network topology.
43 tools organized by domain. The five core tools (connect, disconnect, run, read, write) are the foundation — everything else is a higher-level wrapper that constructs shell commands and calls the same SSH execution path.
| Tool | What it does |
|---|---|
connect |
Open SSH connection (password, key, or auto-generated key) |
disconnect |
Close one or all connections |
run |
Execute command with CWD tracking and configurable timeout |
identity |
Get server's public key for authorized_keys |
info |
Remote OS, kernel, hostname |
| Tool | What it does |
|---|---|
read |
Read file via SFTP (10MB cap) |
write |
Write file via SFTP — validates syntax before writing |
edit |
Sed-like operations: replace, regex, insert, append, delete |
validate |
Server-side syntax check (JSON, YAML, TOML, XML, INI, Dockerfile, ENV) |
list_dir |
Directory listing with metadata |
sync |
Stream file between two remote hosts |
Validation runs in Go on the MCP server — no jq, python3, or xmllint needed on remote hosts.
usage ps logs journal_read dmesg_read diagnose_system list_services
docker_ps docker_logs docker_op docker_ip docker_find_by_ip docker_networks docker_cp_from docker_cp_to
db_query db_schema list_db_containers
net_stat search_files search_text package_manage
voip_discover_containers voip_sip_capture voip_call_flow voip_registrations voip_call_stats voip_extract_sdp voip_packet_check voip_network_capture voip_rtp_capture voip_network_diagnostics
Connect and run commands:
{"tool": "connect", "arguments": {"host": "10.0.0.1", "username": "admin"}}
{"tool": "run", "arguments": {"command": "hostname && uptime"}}
{"tool": "read", "arguments": {"path": "/etc/hostname"}}Jump host:
{"tool": "connect", "arguments": {"host": "bastion.example.com", "username": "admin", "alias": "bastion"}}
{"tool": "connect", "arguments": {"host": "10.0.0.50", "username": "admin", "via": "bastion"}}Edit a config file:
{"tool": "edit", "arguments": {"path": "/etc/nginx/nginx.conf", "old_text": "worker_connections 512", "new_text": "worker_connections 1024"}}Sync between hosts:
{"tool": "connect", "arguments": {"host": "server-a", "username": "admin", "alias": "A"}}
{"tool": "connect", "arguments": {"host": "server-b", "username": "admin", "alias": "B"}}
{"tool": "sync", "arguments": {"source_node": "A", "source_path": "/data/dump.sql", "dest_node": "B", "dest_path": "/data/dump.sql"}}cmd/server/main.go Entry point, HTTP/stdio transport, session hooks
internal/ssh/
├── pool.go Session pool — isolation, cleanup, header routing
├── manager.go Per-session connection manager — connect, execute, SFTP
├── client.go SSH client — CWD tracking, output caps, reconnection
└── keys.go Ed25519 key generation and loading
internal/tools/
├── core.go connect, disconnect, run, identity, info
├── files.go read, write, edit, validate, list_dir, sync
├── monitoring.go usage, ps, logs, journal, dmesg, diagnose, services
├── docker.go Container management tools
├── network.go net_stat, search, packages
├── voip.go SIP/RTP capture and analysis
└── utils.go Shell quoting, sed escaping, input sanitization
- POSIX-compliant commands — all shell commands work on Linux, macOS, and BSD. GNU-specific flags (
sed -i,ps --sort,/proc/*) replaced with portable alternatives. - Shell quoting everywhere — all user input goes through
shellQuote()before reaching a shell. Sed operations use separate escape functions for patterns, literals, and replacements. - No path restrictions — file access follows the SSH user's OS permissions. No artificial sandboxing that breaks real-world DevOps workflows.
- Server-side validation — file syntax is validated in Go on the MCP server, not by shelling out to tools on remote hosts.
- Output caps — stdout capped at 10MB, stderr at 1MB, file reads at 10MB. Prevents memory exhaustion from runaway commands.
- Reconnection with backoff — lost SSH connections auto-reconnect with a 5-second cooldown to avoid hammering a down host.
- TLS: run behind a reverse proxy or use HTTPS termination
- Network: restrict access to private network or VPN
- Auth: validate
X-Session-Keyagainst authorized keys - Volume: mount persistent storage at
/datafor SSH keys
POST/GET http://host:port/mcp
Header: X-Session-Key: <your-session-key>
Use consistent hashing on X-Session-Key for sticky routing across multiple instances.
MIT