mcp-ssh-live
# mcp-ssh-live
**Interactive, streaming SSH tool for LLM agents via MCP (Model Context Protocol).**
Lets LLM agents (Claude, Cursor, Zed) spawn long-running remote commands and watch their output arrive line-by-line in chat — instead of blocking for hours waiting for a `ssh_exec` to return.
```
┌─────────────┐ JSON-RPC/stdio ┌──────────────────┐ SSH ┌─────────────┐
│ Zed / │ ────────────────── │ mcp-ssh-live │ ───────────────── │ your remote │
│ Claude / │ │ (Python) │ │ server(s) │
│ Cursor │ │ │ │ │
└─────────────┘ └──────────────────┘ └─────────────┘
^ ^
| |
ring buffers SFTP + exec
+ reader threads
```
---
## Why this exists
Other SSH-MCP servers (`tufantunc/ssh-mcp`, `classfang/ssh-mcp-server`, `AiondaDotCom/mcp-ssh`, …) are all **request/response**: send a command, wait for it to finish, get the full output. Long jobs — parsers, builds, deploys, log tails — are unusable. The LLM either blocks past the MCP client's 60 s timeout, or falls back to `nohup … &` + `tail -n 10 log` polling that loses lines and has no kill-signal story.
`mcp-ssh-live` splits the one SSH primitive into **spawn + tail + signal + stdin**, so an agent can:
- Start `python parser.py` on a remote box, get a `job_id` in <100 ms.
- Poll `ssh_tail(job_id, since_line_no, wait_ms=5000)` in a loop and stream output into the chat live.
- Send SIGTERM / SIGKILL / SIGINT via `ssh_signal` when the user wants to stop.
- Feed `sudo` passwords or REPL input via `ssh_send_stdin`.
- Upload / download files via SFTP (`ssh_upload` / `ssh_download`) — binary-safe, with sha256 on both sides.
- Manage jobs across multiple hosts simultaneously.
---
## Features
- **10 MCP tools** covering synchronous exec, streaming spawn+tail, signals, stdin, SFTP, host/job registry management.
- **OpenSSH-compatible signal delivery.** Captures the remote PID at spawn time, falls back to `kill -SIG <pid>` on a fresh exec channel when paramiko's in-channel `send_signal` is ignored by the server (which is the common case).
- **Ring-buffered output** (default 10 000 lines per stream) with condition-variable-backed blocking `wait_ms` so `ssh_tail` is near-live without busy-polling.
- **Multi-host**: one process manages several SSH targets; each tool call can pick a host by alias.
- **Auto-reconnect** on transient network / sshd hiccups (configurable retries + delay).
- **Graceful shutdown**: TERM → 5 s grace → KILL for every running remote process when the server exits.
- **Opt-in disk log mirror** (`--log-dir`) — every streamed line also written to `<log_dir>/<job_id>.log` so full output survives ring-buffer eviction and server restarts.
- **Secrets never leak** into MCP responses: passwords live in env vars, only variable *names* appear in `ssh_list_hosts`.
---
## Quick start
### 1. Install
```bash
pipx install mcp-ssh-live
```
Or, for a project-local editable install:
```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
```
Requires **Python 3.10+**.
### 2. Try it from the command line
```bash
# Start the MCP server — no SSH credentials needed at startup
mcp-ssh-live
```
The server starts empty and waits for an MCP client to connect. Credentials are provided at runtime by the agent via `ssh_connect` (see [step 4](#4-ask-your-agent)). If you prefer to pre-configure a host at startup, you can still pass `--host`, `--user`, `--password-env` flags — see [Configuration](#configuration).
### 3. Register with an MCP client
#### Zed
Edit `~/.config/zed/settings.json` (or `.zed/settings.json` for per-project).
**Windows** (use the Python executable directly — Zed doesn't pick up PATH the same way as a terminal):
> Find your Python path: open a terminal and run `where python`.
```json
{
"context_servers": {
"ssh-live": {
"enabled": true,
"command": "C:/path/to/python.exe",
"args": ["-m", "mcp_ssh_live"],
"env": {
"FASTMCP_DISABLE_VERSION_CHECK": "1",
"PYTHONUNBUFFERED": "1"
},
"settings": {}
}
},
"agent": {
"always_allow_tool_actions": true,
"always_allowed_tools": {
"ssh_connect": true, "ssh_disconnect": true,
"ssh_exec": true, "ssh_spawn": true, "ssh_tail": true,
"ssh_signal": true, "ssh_send_stdin": true,
"ssh_list_jobs": true, "ssh_remove_job": true,
"ssh_upload": true, "ssh_download": true, "ssh_list_hosts": true
}
}
}
```
**macOS / Linux** (if installed via `pipx` — binary is in PATH):
```json
{
"context_servers": {
"ssh-live": {
"source": "custom",
"enabled": true,
"command": "mcp-ssh-live",
"args": [],
"env": {
"FASTMCP_DISABLE_VERSION_CHECK": "1",
"PYTHONUNBUFFERED": "1"
}
}
},
"agent": {
"always_allow_tool_actions": true,
"always_allowed_tools": {
"ssh_connect": true, "ssh_disconnect": true,
"ssh_exec": true, "ssh_spawn": true, "ssh_tail": true,
"ssh_signal": true, "ssh_send_stdin": true,
"ssh_list_jobs": true, "ssh_remove_job": true,
"ssh_upload": true, "ssh_download": true, "ssh_list_hosts": true
}
}
}
```
Save the file. No restart needed in most cases, but if the indicator stays red — close and reopen Zed.
#### 3b. Enable in the Agent Panel
1. Open the **Agent Panel** (right sidebar).
2. Click **`···`** (top-right of the panel) → **Settings** → **MCP Servers**.
3. Find **`ssh-live`** and toggle it **ON**.
4. The indicator next to `ssh-live` should turn green within a few seconds.
> If it stays red — check `Zed.log`: **Command Palette** → `zed: open log` and look for `ssh-live`.
> The most common fix: the `command` path in settings is wrong. Run `where python` (Windows) or `which python` (macOS/Linux) to get the correct path.
There's a full config template at [`.zed/settings.json.example`](./.zed/settings.json.example).
#### Claude Desktop
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"ssh-live": {
"command": "mcp-ssh-live",
"args": [],
"env": { "FASTMCP_DISABLE_VERSION_CHECK": "1" }
}
}
}
```
#### Cursor
`~/.cursor/mcp.json` — same shape as Claude Desktop.
### 4. Ask your agent
Once registered, just tell the agent your SSH details in chat. It calls `ssh_connect(...)` automatically — credentials never touch the config file.
**Stream a live log:**
> Connect to **1.2.3.4** as **root**, password is **`···`**. Run `tail -f /var/log/syslog` for 30 seconds, then stop.
The agent picks `ssh_spawn` → loops `ssh_tail(wait_ms=5000)` → `ssh_signal("TERM")` → `ssh_remove_job`, streaming lines into the chat the whole time.
**Upload a local file to the server:**
> Connect to **1.2.3.4** as **root**, password is **`···`**. Upload `C:/Users/me/builds/app-1.0.zip` to `/opt/app/releases/app-1.0.zip` on the server.
The agent calls `ssh_connect(...)` then `ssh_upload(local_path="C:/Users/me/builds/app-1.0.zip", remote_path="/opt/app/releases/app-1.0.zip")` — binary-safe, atomic, with sha256 verification printed in the reply.
**Key auth instead of password:**
> Connect to **1.2.3.4** as **deploy** using key `~/.ssh/id_ed25519`. Upload `./dist/bundle.js` to `/var/www/html/bundle.js`.
**Multiple servers at once:**
> Connect to prod at **1.2.3.4** and stage at **10.0.0.5** (both as root, password `···`). Deploy `./release.tar.gz` to `/srv/app/` on both.
**Staged deploy — save credentials once, deploy step by step:**
First, register both servers by alias so you don't repeat credentials every time:
> Save SSH connection to **1.2.3.4** as `deploy_test` — user `deploy`, password `···`.
> Save SSH connection to **5.6.7.8** as `deploy_production` — user `deploy`, password `···`.
The agent calls `ssh_connect(host="1.2.3.4", user="deploy", password="...", alias="deploy_test")` and the same for `deploy_production`. Both aliases stay active for the rest of the session.
Then deploy to test first:
> Upload everything from the local `./deploy` directory to `/srv/app/` on `deploy_test`. Run `/srv/app/healthcheck.sh` afterwards and show me the output.
The agent calls `ssh_upload` for each file in `./deploy`, then `ssh_exec(command="/srv/app/healthcheck.sh", host="deploy_test")` and streams the result.
If the output looks good, deploy to production:
> Looks good. Now upload the same `./deploy` directory to `/srv/app/` on `deploy_production`.
The agent reuses the saved `deploy_production` alias — no need to re-enter credentials — and repeats the upload.
---
## Tools
| Tool | What it does |
|---|---|
| `ssh_connect(host, user?, password?, key_path?, port?, alias?, insecure_auto_add?)` | Add and open an SSH connection at runtime. Returns an `alias` used by all other tools. Credentials never go into config files. |
| `ssh_disconnect(alias)` | Close a connection and remove it from the registry. |
| `ssh_exec(command, host?, timeout_ms?, cwd?, env?)` | Run a short command synchronously; returns full stdout/stderr/exit_status. Timeout triggers SIGTERM → SIGKILL. |
| `ssh_spawn(command, host?, pty?, label?, cwd?, env?)` | Start a background job, return `job_id` in <100 ms. |
| `ssh_tail(job_id, since_line_no, wait_ms, max_lines, stream)` | Stream new lines incrementally. Blocks up to `wait_ms` for new output. Returns `still_running`, `exit_status`, `buffer_truncated`. |
| `ssh_signal(job_id, signal, wait_ms?)` | Send TERM/KILL/INT/… via paramiko + `kill -SIG <pid>` fallback. Optionally waits for exit. |
| `ssh_send_stdin(job_id, data, newline?, close_stdin?)` | Write to a running job's stdin — sudo passwords, REPL input. |
| `ssh_list_jobs(host?, include_finished?)` | List every job the server knows about: id, cmd, label, status, exit_status, line counts. |
| `ssh_remove_job(job_id, force?)` | Drop a job from the registry. Running jobs need `force=True` (use `ssh_signal` first for a clean kill). |
| `ssh_run_persistent(command, host?, cwd?, env?, label?, work_dir?)` | Run a command via `nohup` — survives MCP disconnect. Returns PID + log paths. |
| `ssh_persistent_status(pid, out_log, err_log, host?, tail_lines?)` | Check status and tail output of a persistent job. |
| `ssh_upload(local_path, remote_path, host?, mode?, max_bytes?)` | SFTP upload, atomic `.partial` + rename, auto-mkdir parents, scp-style dir target, sha256 on both sides. |
| `ssh_download(remote_path, local_path, host?, max_bytes?)` | SFTP download, atomic `.partial` + rename, size cap enforced pre- and during-transfer. |
| `ssh_list_hosts()` | Enumerate configured hosts (alias, address, auth method, connected, active_jobs, is_default). Never leaks passwords. |
Full JSON schemas and examples: [`SPEC.md`](./SPEC.md).
---
## Configuration
### Credential-free mode (recommended)
Start the server with no SSH arguments. The agent receives credentials from the user in chat and connects via `ssh_connect`:
```bash
mcp-ssh-live
```
No credentials in any config file. The agent connects on demand:
```
> "Connect to 1.2.3.4 as root, password is …"
Agent → ssh_connect(host="1.2.3.4", user="root", password="…")
Agent → ssh_exec(command="hostname")
```
### Pre-configured hosts (optional)
If you prefer hosts to be available immediately without an `ssh_connect` call, pass them via CLI flags or a TOML file. Credentials go into env vars — never on the command line.
```bash
export SSH_PASSWORD='your-password'
mcp-ssh-live \
--host prod=1.2.3.4:22 \
--host stage=10.0.0.5 \
--user root \
--password-env SSH_PASSWORD \
--default-host prod \
--insecure-auto-add \
--log-dir ~/.cache/mcp-ssh-live/logs \
--log-level INFO
```
Run `mcp-ssh-live --help` for the full flag list.
### TOML config file
`~/.config/mcp-ssh-live/config.toml`:
```toml
default_host = "prod"
[hosts.prod]
host = "1.2.3.4"
port = 22
user = "root"
password_env = "PROD_PASS"
[hosts.stage]
host = "10.0.0.5"
user = "deploy"
key = "~/.ssh/stage_key"
[limits]
ring_buffer_lines = 10000
max_jobs_per_host = 50
reap_finished_after_sec = 600
reconnect_retries = 3
reconnect_delay_sec = 2.0
[server]
insecure_auto_add = false
known_hosts = "~/.ssh/known_hosts"
log_dir = "~/.cache/mcp-ssh-live/logs"
```
Full reference: [`docs/CONFIG.md`](./docs/CONFIG.md).
### Auth
- **Password**: `--password-env SSH_PASSWORD` — name of the env var. Never pass passwords on the command line (visible in `ps`).
- **Key**: `--key ~/.ssh/id_ed25519`. Optional passphrase via `--key-passphrase-env NAME`.
- **Agent**: omit both — paramiko will try the SSH agent and default key locations.
---
## Common gotchas
### "I ran `python parser.py` and see no output for minutes"
Python (and many other programs) **block-buffer stdout when stdin is not a TTY**. The output is fine — it's sitting in a buffer on the remote side. Three fixes, pick one:
```python
# Option 1: pty=True
ssh_spawn(command="python parser.py", pty=True)
# Option 2: unbuffered Python
ssh_spawn(command="python -u parser.py")
# Option 3: env var
ssh_spawn(command="python parser.py", env={"PYTHONUNBUFFERED": "1"})
```
For non-Python tools: `stdbuf -oL <cmd>` or `unbuffer <cmd>` (from expect).
### "`ssh_signal` returns `sent=True` but the job keeps running"
OpenSSH's `sshd` typically ignores `channel.send_signal` (the RFC 4254 in-channel way). `mcp-ssh-live` falls back to `kill -<SIG> <pid>` on a fresh exec channel, using the PID captured from the `PID=<n>` wrapper at spawn time. The response tells you which path worked:
```json
{"sent_via_channel": false, "sent_via_pid": true, ...}
```
If **both** are false, the spawn wrapper couldn't capture a PID (extremely rare — only happens if `capture_pid=False` was forced or the remote shell is very non-standard).
### "Host key verification failed"
By default `mcp-ssh-live` respects `~/.ssh/known_hosts` and refuses unknown keys. For the first connection either:
- SSH into the host once by hand so the key is trusted, or
- Pass `--insecure-auto-add` (INSECURE — vulnerable to MITM on first connect), or
- Point at a custom file via `--known-hosts /path/to/file`.
### "Tool calls hang for 60 seconds then error out"
If you see `csp request task for "initialize" took over 60s` in Zed's log: FastMCP tries to fetch its latest version from PyPI on startup. On air-gapped networks or slow DNS, this can time out. Fix with the env var:
```
FASTMCP_DISABLE_VERSION_CHECK=1
```
More in [`docs/TROUBLESHOOTING.md`](./docs/TROUBLESHOOTING.md).
---
## Security
- **Passwords pass through env vars only**, never command-line arguments (which would show up in `ps`).
- **`ssh_list_hosts` never returns the password itself** — only the env var *name* and the key file *path*.
- **Known-hosts is enforced** by default.
- **Sandboxing is at the MCP client level**: whichever client you use (Zed, Claude Desktop, Cursor) is the thing asking the user to approve each tool call. Once approved, `mcp-ssh-live` runs whatever the LLM supplied verbatim — there is no command-level sanitization. This is the whole point of the tool.
- **The LLM gets a shell on your server.** If that bothers you, use a dedicated restricted user, jailed with `ForceCommand` / rbash / containers.
---
## Documentation
- **[Quick start](#quick-start)** — install, register, run in 4 steps (this file).
- [`SPEC.md`](./SPEC.md) — full technical specification, all tool contracts with JSON shapes.
- [`docs/CONFIG.md`](./docs/CONFIG.md) — complete CLI + TOML reference.
- [`docs/USAGE.md`](./docs/USAGE.md) — real-world examples per tool.
- [`docs/TROUBLESHOOTING.md`](./docs/TROUBLESHOOTING.md) — common failure modes with fixes.
- [`docs/DEVELOPMENT.md`](./docs/DEVELOPMENT.md) — how to hack on the server itself.
- [`CHANGELOG.md`](./CHANGELOG.md) — release history.
---
## Project status
**v0.1.0 — phases 1-5 complete.**
| Phase | Feature | Status |
|---|---|---|
| 1 | Skeleton + `ssh_exec` | ✅ |
| 2 | Jobs + streaming tail | ✅ |
| 3 | Signals + stdin | ✅ |
| 4 | SFTP + multi-host | ✅ |
| 5 | Auto-reconnect + graceful shutdown + disk log mirror | ✅ |
| 6 | Docs + CI + PyPI publish | in progress |
68 unit tests. Acceptance-tested end-to-end against a real Ubuntu 24.04 sshd: streaming, signals, SFTP round-trip, disk mirror, multi-host.
---
## Contributing
Design feedback on `SPEC.md` and `docs/DEVELOPMENT.md` is always welcome. Before opening a code PR:
1. `pip install -e ".[dev]"`
2. `pytest` — all 68 tests must pass.
3. `ruff check .` and `black --check .` for style.
4. If the change touches a tool schema, update `SPEC.md` first and explain *why* in the PR.
---
## License
MIT. See [`LICENSE`](./LICENSE).
---
## Related projects
Reviewed before building this; all of them are request/response only, which is why this project exists:
| Project | Stars | Language | Streaming? |
|---|---|---|---|
| [`tufantunc/ssh-mcp`](https://github.com/tufantunc/ssh-mcp) | 392 | TS | ❌ |
| [`classfang/ssh-mcp-server`](https://github.com/classfang/ssh-mcp-server) | 332 | TS | ❌ |
| [`bvisible/mcp-ssh-manager`](https://github.com/bvisible/mcp-ssh-manager) | 159 | JS | ❌ |
| [`AiondaDotCom/mcp-ssh`](https://github.com/AiondaDotCom/mcp-ssh) | 63 | JS | ❌ |
---
TDQS
Scored across 14 tools
Every tool has a clearly distinct purpose: connect/disconnect manage sessions, list hosts/jobs provide introspection, exec/spawn/persistent cover execution modes, upload/download handle file transfer, and tail/signal/send_stdin/remove_job/status manage spawned jobs. No two tools could be confused.
All tools follow a consistent snake_case pattern with the 'ssh_' prefix and a verb_noun structure (e.g., ssh_connect, ssh_download, ssh_list_hosts). There are no deviations or mixed conventions.
14 tools cover the full lifecycle of SSH operations—connection, command execution (synchronous, async with streaming, persistent), file transfer, job monitoring, and signaling—without being excessive. Each tool serves a distinct and necessary purpose.
The tool set provides a comprehensive surface for remote host management: connection management, multiple execution modes (blocking, streaming, persistent), file upload/download, stdin interaction, signaling, and comprehensive job tracking. There are no obvious gaps for the intended purpose of an SSH live server.