Skip to main content

A stdio MCP server that exposes Bash-like SSH execution, sessions, and remote file read/edit/search.

Project description

ssh-mcp

An MCP server that gives AI agents SSH access to remote machines through your local OpenSSH client. It wraps ssh, scp, and rsync so agents can run remote commands, transfer files, maintain persistent shell sessions, set up port forwards, and read/edit/search remote files directly — all using your existing SSH config, keys, and credentials.

Why ssh-mcp?

  • Uses your local SSH — host aliases, ~/.ssh/config, ProxyJump, agent forwarding, and existing credentials all work naturally. No SSH libraries or key management.
  • Native-feeling remote editingssh_view/ssh_create/ssh_edit/ssh_grep/ssh_glob mirror the read/edit/search tools agents already use locally, so remote files can be read, searched, and edited exactly like local ones instead of through ad hoc cat/sed/grep commands.
  • Persistent sessions — agents can keep a shell open across multiple tool calls, just like a human would. Sessions survive context window resets when you give them a session_name.
  • Observable — every session records a transcript and optionally launches a detached tmux viewer so you can watch what the agent is doing in real time.
  • Permission-gatable — port forwarding is a separate tool from command execution, so MCP clients can allow SSH access without allowing port forwards.
  • Pure Python — no third-party runtime dependencies. Runs anywhere Python 3.10+ and OpenSSH are available.

Requirements

  • Python 3.10+
  • ssh and scp on PATH (or set SSH_MCP_SSH_BIN / SSH_MCP_SCP_BIN)
  • rsync on PATH (or set SSH_MCP_RSYNC_BIN) — only needed for ssh_sync
  • grep and find on the remote host — needed for ssh_grep / ssh_glob (present on effectively all POSIX systems)
  • tmux — optional, for live session observation

Installation

With uv (recommended)

uvx --from slepp-ssh-mcp ssh-mcp

Or install persistently:

uv tool install slepp-ssh-mcp

With pip

pip install slepp-ssh-mcp

Setup

Claude Code

claude mcp add --transport stdio --scope user ssh-mcp -- uvx --from slepp-ssh-mcp ssh-mcp

Or commit a .mcp.json to share with your team:

{
  "mcpServers": {
    "ssh-mcp": {
      "type": "stdio",
      "command": "uvx",
      "args": ["--from", "slepp-ssh-mcp", "ssh-mcp"]
    }
  }
}

Codex CLI

codex mcp add ssh-mcp -- uvx --from slepp-ssh-mcp ssh-mcp

GitHub Copilot

Add to ~/.copilot/mcp-config.json (or .vscode/mcp.json per-project):

{
  "mcpServers": {
    "ssh-mcp": {
      "type": "stdio",
      "command": "uvx",
      "args": ["--from", "slepp-ssh-mcp", "ssh-mcp"]
    }
  }
}

Generic MCP client

Any stdio MCP client works. Point it at uvx --from slepp-ssh-mcp ssh-mcp or at a virtualenv's ssh-mcp entrypoint.

How it works

ssh-mcp runs as a stdio process that your MCP client spawns. It receives JSON-RPC tool calls and translates them into local ssh/scp/rsync commands. Because it uses your local SSH binary, everything in your ~/.ssh/config works — jump hosts, custom ports, key selection, SSH_AUTH_SOCK, proxy commands.

There are five modes of operation:

One-off commands (ssh_exec)

Run a command, get stdout/stderr/exit code back. Works like ssh host 'command'.

{
  "target": "prod-web01",
  "command": "systemctl status nginx",
  "timeout": 10
}

Use cwd to set the working directory, env to export variables, and tty: true for commands that need a terminal (like sudo with a password prompt). Note that tty merges stdout and stderr.

Interactive sessions (ssh_ensure_session + ssh_write_session + ssh_read_session)

For multi-step work, open a persistent shell. The agent writes commands and reads output just like typing in a terminal.

Start or reuse a session:

{
  "target": "prod-web01",
  "session_name": "deploy-api"
}

Always use a descriptive session_name. It serves three purposes:

  1. The agent can find the same session across multiple tool calls
  2. The tmux observer window gets a human-readable name (e.g., ssh-mcp-prod-web01-deploy-api)
  3. A different agent or conversation can recover the session by name

Write a command:

{
  "session_id": "a1b2c3d4e5f6",
  "input": "cd /app && git pull\n",
  "wait_seconds": 5
}

Always include \n to press Enter. Use \u0003 for Ctrl-C, \u0004 for Ctrl-D. Set wait_seconds high enough for the command to produce output (default: 1 second).

Read more output:

{
  "session_id": "a1b2c3d4e5f6",
  "wait_seconds": 10
}

Check pending_output_chars in the response — if non-zero, call again to drain the buffer.

Session lifecycle:

  • ssh_ensure_session is idempotent — call it at the start of each step
  • Sessions auto-detect dead connections via SSH keepalive (~90 seconds)
  • Response includes uptime_seconds, idle_seconds, and exit_reason for health monitoring
  • Use auto_close: true for one-shot commands that should clean up when done
  • Exited sessions are pruned from memory after 1 hour (5 minutes for auto_close)
  • cwd, env, and shell only apply when creating a new session — they are ignored when reusing an existing one

File transfer (ssh_scp, ssh_sync)

Copy files between local and remote machines.

scp — simple file/directory copy:

{
  "target": "prod-web01",
  "direction": "upload",
  "sources": ["/local/path/app.tar.gz"],
  "destination": "/tmp/"
}

For upload, sources are local paths and destination is remote. For download, it's reversed. The target parameter specifies the host — don't include the host in sources or destination.

rsync — incremental sync with --delete and --exclude:

{
  "target": "prod-web01",
  "direction": "upload",
  "source": "./dist/",
  "destination": "/var/www/app/",
  "delete": true,
  "exclude": ["*.log", ".git"]
}

Remote file access (ssh_view, ssh_create, ssh_edit, ssh_grep, ssh_glob)

These mirror the read/edit/search tools agents use locally, but operate on files on the remote host — so remote editing feels the same as local editing instead of composing cat/sed/grep by hand over ssh_exec.

ssh_view — read a file or list a directory:

{
  "target": "prod-web01",
  "path": "/etc/nginx/nginx.conf"
}

Returns content, size_bytes, and total_lines. Content is truncated at 20KB by default — pass view_range: [start, end] (1-based, inclusive; end: -1 means "to end of file") to page through large files, or force_read_large_files: true to read the whole thing anyway. If path is a directory, returns non-hidden entries up to 2 levels deep instead.

ssh_create — write a brand-new remote file:

{
  "target": "prod-web01",
  "path": "/etc/systemd/system/myapp.service",
  "content": "[Unit]\nDescription=My app\n..."
}

Fails if path already exists or its parent directory doesn't, exactly like the local file-creation tool.

ssh_edit — exact string replacement in an existing file:

{
  "target": "prod-web01",
  "path": "/etc/nginx/nginx.conf",
  "edits": [
    {"old_str": "worker_processes 1;", "new_str": "worker_processes auto;"}
  ]
}

Each old_str must match exactly one location in the file (as it stands after any earlier edits in the same call) — ambiguous or missing matches fail without writing anything. Pass multiple {old_str, new_str} entries in edits to batch several changes into one round trip instead of one SSH connection per edit.

ssh_grep — search remote file contents:

{
  "target": "prod-web01",
  "pattern": "ERROR|WARN",
  "path": "/var/log/myapp",
  "glob": "*.log",
  "output_mode": "content"
}

output_mode is files_with_matches (default), content (matching lines, with line numbers and optional context/context_before/context_after), or count (per-file match counts; files with zero matches are omitted). Backed by remote grep, preferring PCRE-like -P when available and falling back to POSIX extended regex otherwise. Always skips .git/.hg/.svn.

ssh_glob — find remote files by name:

{
  "target": "prod-web01",
  "pattern": "src/**/*.ts",
  "path": "/srv/app"
}

Supports *, ?, [seq]/[!seq], {a,b} alternation, and ** (zero or more path segments). A path segment starting with . is only matched by a pattern segment that itself starts with ., matching classic Unix glob behavior.

All five tools raise a remote_file_error (see Tools reference) for problems like a missing path, a path that already exists, or an ambiguous/missing edit match — the on-the-wire outcome you'd expect from the equivalent local tool.

Port forwarding (ssh_forward)

Create local or remote port forwards. This is a separate tool from ssh_exec so MCP clients can grant SSH access without granting port forwarding.

Local forward — make a remote service reachable locally:

{
  "target": "prod-web01",
  "direction": "local",
  "local_port": 15432,
  "remote_host": "prod-db.internal",
  "remote_port": 5432
}

This binds localhost:15432 and tunnels it to prod-db.internal:5432 through prod-web01.

Remote forward — expose a local service on the remote host:

{
  "target": "prod-web01",
  "direction": "remote",
  "local_port": 3000,
  "remote_host": "localhost",
  "remote_port": 8080
}

Forwards bind to 127.0.0.1 by default. Set bind_address: "0.0.0.0" to expose on all interfaces (use with caution).

Use ssh_list_forwards to see active forwards and ssh_stop_forward to tear them down.

Watching sessions

Every interactive session records a transcript to ~/.local/state/ssh-mcp/<session_id>/transcript.log.

By default, sessions also launch a detached tmux window so you can watch in real time. The tmux session name includes the target and session name for easy identification:

# List ssh-mcp tmux sessions
tmux ls | grep ssh-mcp

# Attach to watch
tmux attach -t ssh-mcp-prod-web01-deploy-api

If you prefer not to use tmux, set observer_mode: "transcript" and tail the transcript file directly — the response includes an observer_command you can copy-paste.

The tmux observer is tied to the session lifecycle: stopping a session closes its tmux window. The MCP server also cleans up tmux on shutdown.

Environment variables

Variable Default Description
SSH_MCP_SSH_BIN ssh Path to the SSH client
SSH_MCP_SCP_BIN scp Path to the SCP client
SSH_MCP_RSYNC_BIN rsync Path to rsync
SSH_MCP_TMUX_BIN tmux Path to tmux
SSH_MCP_STATE_DIR ~/.local/state/ssh-mcp Where transcripts are stored

Security

ssh-mcp is designed for single-developer use on your own machine. It runs SSH commands as your user with your credentials.

What's protected:

  • Port forwarding flags (-L, -R, -D, -W) and dangerous SSH options (ProxyCommand, LocalCommand, LocalForward, RemoteForward, DynamicForward) are blocked in extra_ssh_args. The only way to create forwards is through the explicit ssh_forward tool, which MCP clients can permission-gate.
  • Transcript files are created with mode 0600 and the state directory with 0700.
  • All command arguments use shlex.quote() to prevent shell injection. Subprocess calls use list arguments, never shell=True.
  • Environment variable names are validated against ^[A-Za-z_][A-Za-z0-9_]*$.

What's not protected:

  • An agent with ssh_exec access can run arbitrary commands on any host your SSH config can reach. The security boundary is SSH itself (keys, known_hosts).
  • Transcript files persist on disk after sessions end and may contain secrets (passwords typed at sudo prompts, API keys in output). Clean up SSH_MCP_STATE_DIR when you no longer need them.
  • The shell parameter lets agents choose any remote executable. This is by design — the tool is for remote execution.

Known limitations

  • POSIX-only remotescwd, env, and shell wrapping assumes a POSIX shell on the remote side. Windows SSH targets need commands written for their shell.
  • PTY output — interactive sessions use a PTY, so output includes terminal formatting (ANSI escape codes, command echo, line wrapping). This is intentional — it matches what a human would see.
  • No multiplexing — each ssh_exec call opens a new SSH connection. If your agent runs many rapid commands to the same host, consider using a session instead, or configure ControlMaster in your ~/.ssh/config.
  • Transcript growth — transcripts grow without bound for long-running sessions. The response includes transcript_size_bytes so you can monitor this. Restart the session if it gets too large.
  • Forward connections are standalone — each ssh_forward opens its own SSH connection. Forwards are not tied to sessions.
  • Remote file tools are text-orientedssh_view/ssh_create/ssh_edit decode remote content as UTF-8 with errors="replace"; binary files may come back with stray replacement characters. They're built for source/config files, like their local counterparts.
  • ssh_edit isn't fully atomic — it reads the file, applies edits locally, then writes the result back in a second SSH round trip. A concurrent external write between the two round trips could be overwritten, same class of risk as editing any file that's being modified elsewhere.
  • ssh_grep regex flavor depends on the remote — it prefers PCRE-like -P (closer to what agents expect) when the remote grep/ggrep supports it, otherwise falls back to POSIX extended regex (-E), which lacks things like \d/\w/\b. multiline matching isn't supported.
  • ssh_glob/ssh_grep list the whole subtree — matching happens after enumerating files under path via remote find; scope path to something reasonable on very large trees.

Tools reference

Tool Description
ssh_exec Run a one-off remote command
ssh_scp Copy files via scp
ssh_sync Incremental sync via rsync
ssh_view Read a remote file (with paging) or list a remote directory
ssh_create Create a new remote file
ssh_edit Exact string replacement in an existing remote file
ssh_grep Search remote file contents
ssh_glob Find remote files by name pattern
ssh_start_session Start a new interactive session
ssh_ensure_session Reuse or start an interactive session (recommended)
ssh_read_session Read output from a session
ssh_write_session Write input to a session
ssh_stop_session Stop a session
ssh_list_sessions List tracked sessions
ssh_forward Start a port forward
ssh_list_forwards List tracked forwards
ssh_stop_forward Stop a port forward

All session and forward tools accept standard SSH connection parameters: port, identity_file, known_hosts_file, strict_host_key_checking, and extra_ssh_args. So do ssh_view, ssh_create, ssh_edit, ssh_grep, and ssh_glob.

Development

python3 -m pip install build
python3 -m unittest discover -s tests -v
python3 -m compileall src
python3 -m build

License

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

slepp_ssh_mcp-0.2.0.tar.gz (52.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

slepp_ssh_mcp-0.2.0-py3-none-any.whl (39.8 kB view details)

Uploaded Python 3

File details

Details for the file slepp_ssh_mcp-0.2.0.tar.gz.

File metadata

  • Download URL: slepp_ssh_mcp-0.2.0.tar.gz
  • Upload date:
  • Size: 52.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for slepp_ssh_mcp-0.2.0.tar.gz
Algorithm Hash digest
SHA256 f505e147726f0678ff4e4010f8d6627934517342a39cac56d58cedd8d3512751
MD5 d4e93f477944c3f6597f0bed64426e81
BLAKE2b-256 be6a09ffe5e378dcdc91347a28b65f31aba6e02862cd9c7d6a493e7d6bdf6f86

See more details on using hashes here.

Provenance

The following attestation bundles were made for slepp_ssh_mcp-0.2.0.tar.gz:

Publisher: publish.yml on slepp/ssh-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file slepp_ssh_mcp-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: slepp_ssh_mcp-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 39.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for slepp_ssh_mcp-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e26b17406b68996ffdf2bf14659d99759c9b4803be299f0b0534fa27523aeaa5
MD5 33bf67380312c30b98773e0a1ee08489
BLAKE2b-256 e4874bb17e06842990efa166f91d52bc11361bacbfab6837a1016a795013e218

See more details on using hashes here.

Provenance

The following attestation bundles were made for slepp_ssh_mcp-0.2.0-py3-none-any.whl:

Publisher: publish.yml on slepp/ssh-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page