# example-ssh-mcp **Repository Path**: bravomikekilo/example-ssh-mcp ## Basic Information - **Project Name**: example-ssh-mcp - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-06-18 - **Last Updated**: 2026-06-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # ssh-mcp An [MCP](https://modelcontextprotocol.io/) server that lets an LLM operate a **remote server over the system `ssh` client**. Pure Python standard library — no third‑party dependencies, no `paramiko`. Designed for [opencode](https://opencode.ai), exposing `read` / `write` / `glob` / `grep` / `edit` / `bash` tools whose input parameters and output formats mirror opencode's own built‑in tools (so the model can use them the same way). ## How it works - The server runs **locally** as an opencode "local" MCP server. - It speaks MCP over stdio (newline‑delimited JSON‑RPC 2.0). - Each tool call is translated into an `ssh '...'` invocation against the configured remote host. - On Linux, SSH `ControlMaster` is enabled automatically so the first call opens a connection and every subsequent call reuses it (no re‑auth, low latency). Connection details (identity file, port, jump host, `ProxyJump`, etc.) come from your **`~/.ssh/config`** for the host alias you configure — nothing about the connection lives in this project. ## Configure ### 1. Pick an ssh alias Make sure `ssh ` works non‑interactively, e.g. in `~/.ssh/config`: ```sshconfig Host dual-workstation1 HostName 10.0.0.5 User bamingkun IdentityFile ~/.ssh/id_ed25519 ``` ### 2. Create `.host-control.json` Put it at the project root (the server searches upward from its working directory). At minimum it needs `host`: ```jsonc { // required: an ~/.ssh/config alias or user@host "host": "dual-workstation1", // optional: base dir for resolving relative paths and for the sandbox "base_dir": "/home/baomingkun/test-ssh", // optional: when true, all tool paths must stay within base_dir "restrict_paths": false, // optional: deny list (exact or glob) "blocked_paths": ["/etc/shadow"], // optional: bash guardrails "bash": { "blocked_commands": ["rm -rf /"], "timeout_default_ms": 120000 } } ``` See [`.host-control.json.example`](./.host-control.json.example). **If the file is missing or has no `host`, the server exits immediately.** Path/command restrictions are opt‑in and off by default. ### 3. Register with opencode [`opencode.jsonc`](./opencode.jsonc): ```jsonc { "$schema": "https://opencode.ai/config.json", "mcp": { "ssh": { "type": "local", "command": ["python3", "-m", "ssh_mcp"], "cwd": ".", "enabled": true, "timeout": 10000 } } } ``` opencode auto‑prefixes MCP tool names with the server name, so the tools appear as `ssh_read`, `ssh_write`, `ssh_glob`, `ssh_grep`, `ssh_edit`, `ssh_bash`. Prompt with e.g. *"list the python files on the remote host using the ssh tools"*. ## Tools All accept remote paths (absolute, or relative to the remote home / `base_dir`). | Tool | Args | Behavior | | --- | --- | --- | | `ssh_read` | `filePath`, `offset?`, `limit?` | File → `N: ` (1‑indexed, lines >2000 chars truncated). Dir → entries, one per line, dirs get a trailing `/`. | | `ssh_write` | `filePath`, `content` | Overwrite (binary‑safe via stdin). | | `ssh_glob` | `pattern`, `path?` | `find` + glob matching supporting `**`, `*`, `?`, `[...]`. | | `ssh_grep` | `pattern` (regex), `path?`, `include?` | `grep -rnIH -E`, returns `file:line: content`. | | `ssh_edit` | `filePath`, `oldString`, `newString`, `replaceAll?` | Exact replacement; errors if not found / multiple matches (unless `replaceAll`). | | `ssh_bash` | `command`, `workdir?`, `timeout?` (ms), `description?` | Runs the command in a non‑interactive login shell (`bash -lc`, so `~/.profile` / `/etc/profile` are loaded); merges stdout+stderr, reports non‑zero exit. | ## Remote shell environment (`ssh_bash`) Plain `ssh host 'cmd'` runs in a **non‑login, non‑interactive** shell, so none of your bash config (PATH, exports, etc.) is loaded. To fix this, `ssh_bash` wraps every command in a **non‑interactive login shell** (`bash -lc ''`) by default, which sources `/etc/profile` and `~/.profile` (`~/.bash_profile`). Because it is still **non‑interactive**, anything behind `~/.bashrc`'s `[ -z "$PS1" ] && return` / `case $-` guard is **not** loaded — this typically includes `nvm`, `bun`, `conda`, and other interactive‑only setups. To pull those in, set `bash.init` to source them explicitly, e.g. in `.host-control.json`: ```jsonc "bash": { "shell_mode": "login", // "login" (default) | "none" "init": "source ~/.nvm/nvm.sh" // run after config load, before your command } ``` `shell_mode: "none"` disables the wrapper entirely (legacy behavior: nothing custom is loaded). `init` is a free‑form command string prepended to every `ssh_bash` call. ## Concurrency The server dispatches each request to a worker thread, so **opencode's parallel tool calls run truly in parallel** and a long‑running `ssh_bash` no longer blocks other calls. Concurrency is bounded by a thread pool (default 6 workers; override with the `SSH_MCP_MAX_WORKERS` env var). Each in‑flight call opens one multiplexed ssh session over the shared ControlMaster connection, so keep this comfortably below the remote sshd's `MaxSessions` (typical default 10). * `read` / `glob` / `grep` / `bash` run concurrently with no locking. * `write` / `edit` share a file lock so two writers can't interleave on the same remote file (they serialize; everything else stays parallel). * Responses are written to stdout under a lock, so output never interleaves. MCP matches requests and responses by `id`, so out‑of‑order replies are fine. A `ssh_bash` that overruns its timeout (default 120s) is killed locally and returns a timeout result for that one call — other in‑flight calls are unaffected. ## Layout ``` ssh_mcp/ __main__.py # python3 -m ssh_mcp server.py # MCP JSON-RPC loop ssh_conn.py # ssh argv builder + ControlMaster + run() policy.py # .host-control.json loading + enforcement tools.py # the 6 tools (schemas + handlers) opencode.jsonc .host-control.json.example tests/smoke.py # needs a reachable host (.host-control.json) tests/test_logic.py # pure-logic unit tests, no network ``` ## Test ```bash python3 tests/smoke.py # full tool regression against the configured host python3 tests/test_logic.py # pure-logic unit tests (path/regex/binary-IO), no ssh ``` ## Windows The server runs on Windows too, with a few notes: - **OpenSSH client required.** Enable the "OpenSSH Client" optional feature so `ssh.exe` is on PATH (the binary is resolved once via `shutil.which`). - **No ControlMaster.** Windows' OpenSSH port doesn't support connection multiplexing, so every tool call opens a fresh connection and re-authenticates (key-based = just latency). Concurrency still works — each call is independent. - **No CRLF corruption.** ssh I/O is done in **binary** (we UTF-8 encode/decode ourselves), so file content written to the remote stays LF — text-mode `subprocess` would otherwise turn `\n` into `\r\n` on Windows and contaminate remote files. - **Remote paths are POSIX.** `.host-control.json` paths (`base_dir`, `blocked_paths`) and tool paths refer to the **remote Unix** host; path normalization uses `posixpath` regardless of the local platform, so the path sandbox behaves identically on Windows. - **stdio is forced to UTF-8 / `\n`** at startup, so MCP message delimiters are never translated to `\r\n`. - **Launch command.** In `opencode.jsonc` use `python` (or `py -3`) instead of `python3` if that's what your Windows install provides: ```jsonc "command": ["python", "-m", "ssh_mcp"] ``` ## Notes / limitations - `BatchMode=yes` is on by default (fail fast, never hang on a password prompt). Set env `SSH_MCP_BATCH_MODE=0` to disable (e.g. for interactive auth). - The path sandbox is lexical (it collapses `..`); symlinks that escape `base_dir` are not followed/contained. - `ssh_bash` killing the local ssh on timeout does not reliably kill the remote process. Use `timeout` on the remote command if you need strict enforcement.