Metadata-Version: 2.4
Name: mcp-ssh-live
Version: 0.1.0
Summary: Interactive, streaming SSH tool for LLM agents via MCP (Model Context Protocol)
Project-URL: Homepage, https://github.com/pmboxbiz/mcp-ssh-live
Project-URL: Repository, https://github.com/pmboxbiz/mcp-ssh-live
Project-URL: Issues, https://github.com/pmboxbiz/mcp-ssh-live/issues
Author: pmboxbiz
License: MIT
License-File: LICENSE
Keywords: agent,fastmcp,llm,mcp,paramiko,ssh,streaming
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Networking
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.10
Requires-Dist: fastmcp>=2.0
Requires-Dist: paramiko>=3.4
Requires-Dist: typer>=0.12
Provides-Extra: dev
Requires-Dist: black>=24.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: toml-py310
Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'toml-py310'
Description-Content-Type: text/markdown

# 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 | ❌ |

---

## Credits

Built as infrastructure for [`TronVanity`](https://github.com/pmboxbiz/TronVanity) — a TRON ECDSA signature analysis project that needed a proper way for LLM agents to orchestrate multi-hour LevelDB parses on remote servers.