Skip to main content
Glama

ssh-for-agents

An MCP (Model Context Protocol) server that lets AI agents talk to SSH servers — run commands, read files, and list directories on remote hosts — behind a configurable command-safety policy.

Because it speaks MCP, any MCP-capable agent (Claude Desktop, Claude Code, or your own client) can use it with no glue code. Connections are async (built on asyncssh) and pooled per host.

AI agent ──MCP(stdio)──▶ ssh-for-agents ──asyncssh──▶ remote host(s)
                              │
                         guardrails policy
                    (readonly / guarded / unrestricted)

Quickstart

git clone https://github.com/WilliamSmithEdward/pySSHForAgents.git
cd pySSHForAgents
python -m venv .venv
.venv\Scripts\Activate.ps1          # macOS/Linux: source .venv/bin/activate
pip install -e .

Store your SSH key's passphrase in an environment variable (it's never written to a config file) — Windows setx NAME "value" then open a new terminal, macOS/Linux export NAME=value — then register a host and test it end to end:

ssh-for-agents-config add myserver --hostname 203.0.113.10 --user deploy \
    --passphrase-env MYSERVER_SSH_PASSPHRASE
ssh-for-agents-run myserver "uptime"

That's it. Now point your agent at it — see Connect to Claude below, or Codex / a local Ollama model. The full walkthrough (keys, host-key verification, every agent) is in SETUP.md.

Related MCP server: MCP SSH Server

Tools exposed to the agent

Tool

What it does

list_hosts

List configured hosts and the active safety policy.

check_command

Dry-run the policy for a command (allow / needs_confirmation / block).

run_command

Run a shell command on a host. Destructive commands need confirm=true.

read_file

Read a remote file over SFTP (size-capped).

list_dir

List a remote directory over SFTP.

Requirements

  • Python 3.10+

  • An SSH key (password auth is not wired up by design — use keys).

Install

python -m venv .venv
.venv\Scripts\activate          # Windows
# source .venv/bin/activate     # macOS/Linux
pip install -e .

asyncssh needs bcrypt to decrypt passphrase-protected OpenSSH keys; it's a declared dependency, so the install above pulls it in.

Configure

Copy the example and edit it:

cp hosts.example.json hosts.json
{
  "hosts": {
    "myserver": {
      "hostname": "203.0.113.10",
      "username": "deploy",
      "port": 22,
      "private_key": "~/.ssh/id_ed25519",
      "passphrase_env": "MYSERVER_SSH_PASSPHRASE",
      "known_hosts": "~/.ssh/known_hosts",
      "verify_host_key": true
    }
  },
  "policy": {
    "mode": "guarded",
    "extra_denylist": [],
    "extra_readonly_allow": [],
    "max_output_bytes": 100000,
    "default_timeout": 60
  }
}

Host fields:

  • private_key — path to the private key (~ is expanded).

  • passphrase_env — name of an environment variable holding the key's passphrase. The passphrase itself is never stored in the config. Omit if the key has no passphrase.

  • known_hosts — path to a known_hosts file used to verify the server's host key. Omit to use the default (~/.ssh/known_hosts + system files).

  • verify_host_key — set false to skip host-key verification (insecure; only for throwaway hosts).

The server looks for hosts.json in the working directory, or wherever SSH_AGENT_CONFIG points.

hosts.json is git-ignored since it describes your infrastructure. Commit hosts.example.json instead.

Adding & managing hosts

The config holds any number of hosts — add as many as you like under hosts. Manage them with the bundled CLI instead of hand-editing JSON:

# add or update a host
ssh-for-agents-config add prod \
  --hostname 203.0.113.10 --user deploy \
  --key ~/.ssh/id_ed25519 --passphrase-env PROD_SSH_PASSPHRASE

ssh-for-agents-config list                 # show configured hosts + policy
ssh-for-agents-config remove prod          # drop a host
ssh-for-agents-config import-ssh-config    # pull hosts from ~/.ssh/config

--config <path> (or $SSH_AGENT_CONFIG) targets a specific hosts.json.

Hot-reload: a running server watches hosts.json and reloads it when it changes, so a newly added host is usable on the next tool call — no restart needed. Live connections to unchanged hosts are kept; a half-written or invalid edit is ignored (the last good config stays active).

Shell CLI — for agents that can't call MCP tools

Some agents run shell commands but can't reliably call MCP tools — notably a local (Ollama) model in Codex, which emits tool-call names the host rejects. For those, the same hosts and safety policy are exposed as a plain command, ssh-for-agents-run:

ssh-for-agents-run hosts                     # list aliases + policy
ssh-for-agents-run linode "df -h"            # run a command (default verb)
ssh-for-agents-run linode "rm /tmp/x" --confirm
ssh-for-agents-run check linode "rm -rf /"   # dry-run the policy
ssh-for-agents-run read linode /etc/os-release
ssh-for-agents-run ls linode /var/log

It reuses hosts.json and the guardrails, and on Windows resolves the key passphrase from the persisted user environment even when the launching shell filters env vars. See SETUP.md for wiring it into Codex (AGENTS.md + giving the sandbox read access to ~/.ssh).

Run

ssh-for-agents          # console script (serves over stdio)
python -m ssh_agent_mcp # equivalent

The server communicates over stdio — it's normally launched by an MCP client rather than run by hand.

Connect to Claude Code

claude mcp add ssh-for-agents -- /abs/path/to/.venv/Scripts/python.exe -m ssh_agent_mcp

Set the working directory (or SSH_AGENT_CONFIG) so it finds hosts.json.

Connect to Claude Desktop

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "ssh-for-agents": {
      "command": "C:\\path\\to\\.venv\\Scripts\\python.exe",
      "args": ["-m", "ssh_agent_mcp"],
      "env": {
        "SSH_AGENT_CONFIG": "C:\\path\\to\\hosts.json",
        "MYSERVER_SSH_PASSPHRASE": "..."
      }
    }
  }
}

Use it from a local Ollama model

Ollama runs models and does function calling, but it is not an MCP client — so a small host/bridge sits between them: it launches this server, hands the model the tools, runs the tool-calling loop, and routes tool calls back. A working one is included at examples/ollama_bridge.py:

pip install -e ".[examples]"      # adds the `ollama` client library
ollama pull qwen3-coder:30b       # any tool-calling model works
python examples/ollama_bridge.py "what is the uptime and disk usage on linode?"

The model discovers hosts via list_hosts and runs commands via run_command. When the policy returns needs_confirmation, the bridge prompts you on the terminal rather than letting the model authorize its own destructive command. Pick a model with OLLAMA_MODEL=...; it must support tool calling (qwen3-coder, llama3.1/3.2, mistral-nemo, …).

Ollama model ◀──function calling──▶  bridge  ◀──MCP(stdio)──▶ ssh-for-agents ──▶ host

Safety model

The policy runs before any command reaches the server. Pick a mode:

Mode

Behavior

readonly

Only commands on a read-only allowlist run; everything else is blocked.

guarded

(default) Most commands run freely. Destructive/state-changing commands return needs_confirmation until the agent re-calls with confirm=true. A few catastrophic patterns are hard-blocked.

unrestricted

Anything runs. Only sensible for disposable/sandboxed hosts.

In guarded mode:

  • Needs confirmation: rm, dd, mkfs, shutdown/reboot, kill, mount, firewall edits, crontab, package installs/removals (apt install, …), service changes (systemctl stop, …), git push/reset, curl … | sh, recursive chmod/chown, redirects into system paths, and more.

  • Hard-blocked (cannot be confirmed away): fork bombs, recursive deletes of root paths (rm -rf /, /home, …), and writing/formatting raw disk devices (dd of=/dev/sda, mkfs … /dev/nvme0n1).

  • Commands are parsed into segments across ;, &&, ||, and pipes, and sudo/env wrappers are seen through, so cd /x && sudo rm y is still flagged.

Extend it without editing code via extra_denylist (more binaries that need confirmation) and extra_readonly_allow (more binaries allowed in readonly mode).

Important — this is a safety net, not a sandbox. String-based inspection of shell commands can always be evaded by a determined caller (base64 … | sh, exotic quoting, writing then running a script, etc.). It exists to prevent accidents and obvious mistakes. For a real trust boundary, connect as a dedicated unprivileged user and constrain it with OS permissions and/or sshd ForceCommand. Treat the mode as defense-in-depth, not as containment of an adversarial agent.

Develop & test

pip install -e ".[dev]"
pytest                  # guardrail policy tests (no SSH needed)

Project layout

ssh_agent_mcp/
  guardrails.py   # command-safety policy engine (pure, well-tested)
  config.py       # hosts.json loading + validation
  ssh_client.py   # asyncssh connection pool (run / read_file / list_dir)
  server.py       # FastMCP server + tools (with hosts.json hot-reload)
  manage.py       # `ssh-for-agents-config` CLI: add / list / remove / import hosts
  run_cli.py      # `ssh-for-agents-run` CLI: run SSH from the shell (Codex/local models)
tests/
  test_guardrails.py
  test_manage.py
  test_run_cli.py
examples/
  ollama_bridge.py  # drive a local Ollama model with these tools
hosts.example.json
SETUP.md            # step-by-step reproduction guide

Available Tools

5 tools
check_commandA

Dry-run the safety policy for a command without executing it.

Returns the verdict (allow / needs_confirmation / block), a human-readable reason, and which rules matched. Use this to understand why a command would be refused before calling run_command.

Inputs: host (required), command (required). Returns: object

list_dirB

List the entries of a remote directory over SFTP.

Inputs: host (required), path. Returns: object

list_hostsA

List the SSH hosts this server can reach and the active safety policy.

Call this first: use the returned alias values as the host argument for the other tools. Never guess a hostname or IP — only configured aliases work.

Inputs: no named parameters. Returns: object

read_fileA

Read a remote file's contents over SFTP (size-capped).

Args: host: Alias of a configured host. path: Absolute or relative remote path. max_bytes: Max bytes to read (defaults to policy max_output_bytes).

Inputs: host (required), path (required), max_bytes. Returns: object

run_commandA

Run one shell command on an SSH host, subject to the safety policy.

host must be an alias from list_hosts (not a raw hostname/IP). Read exit_status, stdout, and stderr from the result. The result status:

  • "ok": executed.

  • "needs_confirmation": destructive command — explain it to the user and only re-call with confirm=true after they approve; do not retry blindly.

  • "blocked": catastrophic; cannot run — use a safer approach.

  • "error": see reason (unknown host, timeout, connection failure).

Args: host: Alias of a configured host (call list_hosts to discover them). command: A single shell command to execute. confirm: Authorize a command previously flagged needs_confirmation. timeout: Per-command timeout in seconds (defaults to policy setting).

Inputs: host (required), command (required), confirm, timeout. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.1/5.0

    Scored across 5 tools

    Disambiguation5/5

    Each tool has a clearly distinct purpose: check_command is for policy dry-run, list_dir for directory listing, list_hosts for host discovery, read_file for file reading, and run_command for command execution. There is no overlap.

    Naming Consistency5/5

    All tool names follow a consistent verb_noun pattern (e.g., check_command, list_dir) using snake_case. No mixing of conventions.

    Tool Count5/5

    With 5 tools, the server is well-scoped for its purpose of secure SSH command execution with file reading and directory listing. Each tool serves a necessary role without redundancy.

    Completeness4/5

    The tools cover the core workflow of host discovery, command safety checking, command execution, and file reading. However, there is a notable gap: no tool for writing or uploading files, which may limit some use cases.

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      D
      maintenance
      Enables AI assistants to securely execute remote SSH commands, perform file transfers, and monitor system status through a standardized interface. It features robust security controls including command whitelisting, blacklisting, and credential isolation to prevent unauthorized operations.
      10
      14 npm
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables AI assistants to securely execute commands, transfer files, and manage port forwarding on remote servers via SSH.
      158 npm
      38
      Apache 2.0
    • F
      license
      Not graded
      quality
      D
      maintenance
      Enables AI to execute commands on remote hosts via SSH, supporting password and key authentication.
      -