ssh-mcp 1.5.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,204 +1,549 @@
1
- # SSH MCP Server
1
+ # SSH MCP Server v2
2
2
 
3
3
  [![NPM Version](https://img.shields.io/npm/v/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
4
4
  [![Downloads](https://img.shields.io/npm/dm/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
5
- [![Node Version](https://img.shields.io/node/v/ssh-mcp)](https://nodejs.org/)
6
5
  [![License](https://img.shields.io/github/license/tufantunc/ssh-mcp)](./LICENSE)
7
- [![GitHub Stars](https://img.shields.io/github/stars/tufantunc/ssh-mcp?style=social)](https://github.com/tufantunc/ssh-mcp/stargazers)
8
- [![GitHub Forks](https://img.shields.io/github/forks/tufantunc/ssh-mcp?style=social)](https://github.com/tufantunc/ssh-mcp/forks)
9
- [![Build Status](https://github.com/tufantunc/ssh-mcp/actions/workflows/publish.yml/badge.svg)](https://github.com/tufantunc/ssh-mcp/actions)
10
6
  [![GitHub issues](https://img.shields.io/github/issues/tufantunc/ssh-mcp)](https://github.com/tufantunc/ssh-mcp/issues)
11
7
 
12
- [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/tufantunc/ssh-mcp)](https://archestra.ai/mcp-catalog/tufantunc__ssh-mcp)
8
+ **SSH MCP Server** is a security-first Model Context Protocol server that gives LLM agents controlled SSH access to remote hosts — with command classification, policy-based authorization, human-in-the-loop approval, and full audit logging.
13
9
 
14
- **SSH MCP Server** is a local Model Context Protocol (MCP) server that exposes SSH control for Linux and Windows systems, enabling LLMs and other MCP clients to execute shell commands securely via SSH.
10
+ > **WARNING — Lethal Trifecta Risk.** Giving an LLM SSH access creates a ["Lethal Trifecta"](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/) (private data + untrusted input + network egress). **Never run as root. Never enable `auto` approval on production.** See [SECURITY.md](./SECURITY.md) for the full threat model and mitigation checklist.
15
11
 
16
- ## Contents
17
-
18
- - [Quick Start](#quick-start)
19
- - [Features](#features)
20
- - [Installation](#installation)
21
- - [Client Setup](#client-setup)
22
- - [Testing](#testing)
23
- - [Disclaimer](#disclaimer)
24
- - [Support](#support)
12
+ ---
25
13
 
26
14
  ## Quick Start
27
15
 
28
- - [Install](#installation) SSH MCP Server
29
- - [Configure](#configuration) SSH MCP Server
30
- - [Set up](#client-setup) your MCP Client (e.g. Claude Desktop, Cursor, etc)
31
- - Execute remote shell commands on your Linux or Windows server via natural language
16
+ ### 1. Install
32
17
 
33
- ## Features
18
+ ```bash
19
+ npm install -g ssh-mcp
20
+ ```
34
21
 
35
- - MCP-compliant server exposing SSH capabilities
36
- - Execute shell commands on remote Linux and Windows systems
37
- - Secure authentication via password or SSH key
38
- - Built with TypeScript and the official MCP SDK
39
- - **Configurable timeout protection** with automatic process abortion
40
- - **Graceful timeout handling** - attempts to kill hanging processes before closing connections
22
+ ### 2. Configure
41
23
 
42
- ### Tools
24
+ Create `~/.config/ssh-mcp/config.toml` (Linux/macOS) or `%APPDATA%\ssh-mcp\config.toml` (Windows):
25
+
26
+ ```toml
27
+ [defaults]
28
+ defaultProfile = "dev"
29
+ approvalMode = "ask-destructive"
30
+
31
+ [[profiles]]
32
+ name = "dev"
33
+ host = "192.168.1.100"
34
+ port = 22
35
+ user = "deploy" # NOT root!
36
+ auth = "key"
37
+ keyRef = "~/.ssh/id_ed25519"
38
+ role = "admin"
39
+ approvalPolicy = "auto" # dev is permissive
40
+ ```
41
+
42
+ ```bash
43
+ chmod 600 ~/.config/ssh-mcp/config.toml
44
+ ```
45
+
46
+ ### 3. Set credentials via environment variables
47
+
48
+ ```bash
49
+ export SSH_MCP_PASSWORD="your-password" # if using auth=password
50
+ # OR use SSH agent (recommended):
51
+ export SSH_AUTH_SOCK="$SSH_AUTH_SOCK" # already set if agent running
52
+ ```
53
+
54
+ ### 4. Connect from your MCP client
43
55
 
44
- - `exec`: Execute a shell command on the remote server
45
- - **Parameters:**
46
- - `command` (required): Shell command to execute on the remote SSH server
47
- - `description` (optional): Optional description of what this command will do (appended as a comment)
48
- - **Timeout Configuration:**
49
-
50
- - `sudo-exec`: Execute a shell command with sudo elevation
51
- - **Parameters:**
52
- - `command` (required): Shell command to execute as root using sudo
53
- - `description` (optional): Optional description of what this command will do (appended as a comment)
54
- - **Notes:**
55
- - Requires `--sudoPassword` to be set for password-protected sudo
56
- - Can be disabled by passing the `--disableSudo` flag at startup if sudo access is not needed or not available
57
- - For persistent root access, consider using `--suPassword` instead which establishes a root shell
58
- - Tool will not be available at all if server is started with `--disableSudo`
59
- - **Timeout Configuration:**
60
- - Timeout is configured via command line argument `--timeout` (in milliseconds)
61
- - Default timeout: 60000ms (1 minute)
62
- - When a command times out, the server automatically attempts to abort the running process before closing the connection
63
- - **Max Command Length Configuration:**
64
- - Max command characters are configured via `--maxChars`
65
- - Default: `1000`
66
- - No-limit mode: set `--maxChars=none` or any `<= 0` value (e.g. `--maxChars=0`)
67
-
68
- ## Installation
69
-
70
- 1. **Clone the repository:**
71
- ```bash
72
- git clone https://github.com/tufantunc/ssh-mcp.git
73
- cd ssh-mcp
74
- ```
75
- 2. **Install dependencies:**
76
- ```bash
77
- npm install
78
- ```
79
-
80
- ## Client Setup
81
-
82
- You can configure your IDE or LLM like Cursor, Windsurf, Claude Desktop to use this MCP Server.
83
-
84
- **Required Parameters:**
85
- - `host`: Hostname or IP of the Linux or Windows server
86
- - `user`: SSH username
87
-
88
- **Optional Parameters:**
89
- - `port`: SSH port (default: 22)
90
- - `password`: SSH password (or use `key` for key-based auth)
91
- - `key`: Path to private SSH key
92
- - `sudoPassword`: Password for sudo elevation (when executing commands with sudo)
93
- - `suPassword`: Password for su elevation (when you need a persistent root shell)
94
- - `timeout`: Command execution timeout in milliseconds (default: 60000ms = 1 minute)
95
- - `maxChars`: Maximum allowed characters for the `command` input (default: 1000). Use `none` or `0` to disable the limit.
96
- - `disableSudo`: Flag to disable the `sudo-exec` tool completely. Useful when sudo access is not needed or not available.
97
-
98
-
99
- ```commandline
56
+ **Claude Code:**
57
+ ```bash
58
+ claude mcp add --transport stdio ssh-mcp -- ssh-mcp
59
+ ```
60
+
61
+ **Claude Desktop / Cursor / Windsurf:**
62
+ ```json
100
63
  {
101
- "mcpServers": {
102
- "ssh-mcp": {
103
- "command": "npx",
104
- "args": [
105
- "ssh-mcp",
106
- "-y",
107
- "--",
108
- "--host=1.2.3.4",
109
- "--port=22",
110
- "--user=root",
111
- "--password=pass",
112
- "--key=path/to/key",
113
- "--timeout=30000",
114
- "--maxChars=none"
115
- ]
116
- }
64
+ "mcpServers": {
65
+ "ssh-mcp": {
66
+ "command": "ssh-mcp",
67
+ "env": {
68
+ "SSH_MCP_PASSWORD": "your-password"
69
+ }
117
70
  }
71
+ }
118
72
  }
119
73
  ```
120
74
 
121
- ### Claude Code
75
+ **Never pass passwords as CLI arguments** — they're visible via `ps aux`. Use env vars, config files, SSH agent, or OS keychain.
76
+
77
+ ---
78
+
79
+ ## Tools (11)
80
+
81
+ | Tool | Purpose | readOnly | destructive |
82
+ |------|---------|:--------:|:----------:|
83
+ | `list-connections` | Discover available hosts and connection status | ✅ | — |
84
+ | `list-sessions` | List active sessions per host | ✅ | — |
85
+ | `open-session` | Create a named interactive (stateful) or background session | — | — |
86
+ | `close-session` | Close a session, releasing resources | — | ✅ |
87
+ | `read-session-output` | Read output from background sessions (e.g., `tail -f`) | ✅ | — |
88
+ | `read-command` | Execute allowlisted read-only commands (`ls`, `cat`, `grep`, ...) | ✅ | — |
89
+ | `run-command` | Execute arbitrary commands (destructive ones need approval) | — | — |
90
+ | `privileged-command` | Execute with sudo (always requires approval) | — | ✅ |
91
+ | `sftp-upload` | Upload a file via SFTP | — | ✅ |
92
+ | `sftp-download` | Download a file via SFTP | ✅ | — |
93
+ | `signal-process` | Send INT/TERM/KILL to a remote PID | — | ✅ |
94
+
95
+ ### Interactive Sessions
96
+
97
+ Sessions maintain state (CWD, environment variables) between commands:
98
+
99
+ ```
100
+ Agent: open-session(name="deploy", type="interactive")
101
+ Agent: run-command(session="deploy", command="cd /opt/myapp")
102
+ Agent: run-command(session="deploy", command="git pull") # runs in /opt/myapp
103
+ Agent: run-command(session="deploy", command="npm ci") # CWD persists
104
+ Agent: close-session(name="deploy")
105
+ ```
106
+
107
+ ### Background Sessions
108
+
109
+ Long-running processes (logs, builds):
110
+
111
+ ```
112
+ Agent: open-session(name="logs", type="background", command="tail -f /var/log/syslog")
113
+ Agent: read-session-output(name="logs", lines=20) # poll
114
+ Agent: close-session(name="logs")
115
+ ```
116
+
117
+ ### Remote host support
118
+
119
+ Tested against Linux (Debian/bash, Alpine/busybox ash), Dropbear, and Windows
120
+ OpenSSH on Windows 11.
121
+
122
+ | | Linux / BSD / macOS | Windows OpenSSH |
123
+ |---|:---:|:---:|
124
+ | `read-command`, `run-command`, `privileged-command`, `signal-process` | ✅ | ✅ |
125
+ | `sftp-upload`, `sftp-download` | ✅ | ✅ |
126
+ | Background sessions | ✅ | ✅ |
127
+ | **Interactive sessions** | ✅ | ❌ |
128
+
129
+ **Interactive sessions require a POSIX shell** (sh, bash, ash, zsh). They work by
130
+ bracketing each command with `printf` markers and reading `$?` and `$PWD` from a
131
+ trailer — none of which exist in `cmd.exe`, the default shell for Windows
132
+ OpenSSH. Opening one against such a host fails immediately with an explicit
133
+ error rather than timing out; everything else works normally.
134
+
135
+ Setting PowerShell as the OpenSSH `DefaultShell` does not help: the protocol is
136
+ POSIX-specific, not merely non-`cmd`.
137
+
138
+ ---
139
+
140
+ ## Configuration
141
+
142
+ ### Profile options
143
+
144
+ ```toml
145
+ [defaults]
146
+ defaultProfile = "dev"
147
+ sessionMaxPerConnection = 5
148
+ sessionIdleTimeoutMs = 600000 # 10min
149
+ sessionBackgroundMaxMs = 3600000 # 1hr
150
+ commandTimeoutMs = 60000
151
+ commandMaxChars = 5000
152
+ commandMaxOutputBytes = 1048576 # 1MB
153
+ connectionIdleReapMs = 900000 # 15min
154
+ commandQuotaPerDay = 0 # 0 = unlimited; circuit breaker for runaway agents
155
+ approvalGrantTtlMs = 0 # 0 = always prompt; see "Approval Grants"
156
+ approvalMode = "ask-destructive" # auto | ask-destructive | ask-all | deny
157
+
158
+ [[profiles]]
159
+ name = "prod-web-1"
160
+ host = "10.0.1.50"
161
+ port = 22
162
+ user = "deploy"
163
+ auth = "agent" # agent | key | password | keychain
164
+ keyRef = "~/.ssh/id_ed25519" # for auth=key
165
+ keychainEntry = "ssh-mcp/prod" # for auth=keychain (requires @napi-rs/keyring)
166
+ via = "bastion" # ProxyJump — route through bastion profile
167
+ group = "prod" # Policy tier: prod | staging | dev
168
+ workdir = "/var/www"
169
+ trustedHostKey = "SHA256:..." # Pin host key (optional)
170
+ tty = false
171
+ role = "operator" # viewer | operator | admin
172
+ readOnly = false
173
+ approvalPolicy = "ask-all"
174
+ cert = false # SSH CA cert auth — auto-detects keyRef-cert.pub
175
+ sessionMaxPerConnection = 3 # per-profile override
176
+ sessionIdleTimeoutMs = 300000 # stricter for prod
177
+ commandQuotaPerDay = 200 # per-profile override
178
+ ```
179
+
180
+ ### ProxyJump (Bastion)
181
+
182
+ Reach internal hosts behind a bastion/jump server. The `via` field specifies a profile name to tunnel through:
183
+
184
+ ```toml
185
+ [[profiles]]
186
+ name = "bastion"
187
+ host = "bastion.example.com"
188
+ user = "deploy"
189
+ auth = "agent"
190
+
191
+ [[profiles]]
192
+ name = "internal-db"
193
+ host = "10.0.1.50" # private IP — not directly reachable
194
+ user = "dbadmin"
195
+ auth = "key"
196
+ keyRef = "~/.ssh/db_key"
197
+ via = "bastion" # tunnel through bastion
198
+ ```
199
+
200
+ No agent forwarding — only a TCP tunnel via `forwardOut`. The bastion stays connected and reusable for multiple internal hosts.
201
+
202
+ ### SSH CA Certificates
203
+
204
+ For enterprise setups with a central SSH Certificate Authority:
205
+
206
+ ```toml
207
+ [[profiles]]
208
+ name = "prod-db"
209
+ host = "db.internal"
210
+ user = "admin"
211
+ auth = "key"
212
+ keyRef = "~/.ssh/id_ed25519"
213
+ cert = true # enable CA cert auth
214
+ ```
215
+
216
+ The certificate file is auto-detected using OpenSSH convention (`keyRef` + `-cert.pub`, e.g. `~/.ssh/id_ed25519-cert.pub`). You can override the path with `SSH_MCP_<NAME>_CERT` env var. The cert is concatenated with the private key per ssh2 convention.
217
+
218
+ ### Credential Resolution Order
219
+
220
+ 1. **SSH agent** (`SSH_AUTH_SOCK`) — no key material in process memory
221
+ 2. **OS keychain** (macOS Keychain / Windows Credential Manager / Linux Secret Service) — requires `auth = "keychain"` and `@napi-rs/keyring`
222
+ 3. **Environment variables** — `SSH_MCP_PASSWORD`, `SSH_MCP_KEY`, `SSH_MCP_SUDO_PASSWORD`, or profile-specific `SSH_MCP_<NAME>_PASSWORD`
223
+ 4. **Key file** — `keyRef` path or `SSH_MCP_KEY` env var
224
+
225
+ **Never CLI arguments.** v2 removes `--password`, `--sudoPassword`, `--suPassword` entirely.
226
+
227
+ ---
228
+
229
+ ## Policy Engine
230
+
231
+ ### Roles
232
+
233
+ | Role | Dev | Staging | Prod |
234
+ |------|-----|---------|------|
235
+ | **viewer** | read-only | read-only | read-only |
236
+ | **operator** | read-only, safe, destructive | read-only, safe, destructive | read-only, safe |
237
+ | **admin** | all | all | read-only, safe, destructive |
238
+
239
+ Which column applies comes from the profile's `group`. Set it explicitly —
240
+ without it the tier is guessed from the profile name (`prod`/`staging`/`dev`,
241
+ `local`, `test`, `sandbox`), and **an unrecognised name resolves to `prod`**,
242
+ the strictest tier. A production host named `web-01` is therefore treated as
243
+ production rather than silently getting dev permissions.
244
+
245
+ ### Command Classification
246
+
247
+ Every command is classified before execution:
248
+
249
+ - **read-only**: Allowlisted commands (`ls`, `cat`, `grep`, `df`, `stat`, `systemctl status`, ...)
250
+ - **safe**: Non-destructive mutations (`npm install`, `git pull`, ...)
251
+ - **destructive**: mutations that need approval (`rm -rf /tmp/build`, ...)
252
+ - **privileged**: `sudo`, `su`, `doas`, `pkexec`
253
+
254
+ A separate **forbidden** list is never allowed, whatever the role or approval
255
+ policy: `rm -rf /`, `mkfs`, `dd of=/dev/`, `shutdown`, `curl|sh`, fork bombs,
256
+ writes to `/etc/cron`, `/etc/systemd` or `authorized_keys`, `iptables -F`, and
257
+ recursive `chmod 777 /` / `chown /`. Add your own patterns via the policy
258
+ denylist; an invalid pattern fails at startup rather than degrading silently.
259
+
260
+ ### Approval Modes
261
+
262
+ - `auto` — no prompts (dev only!)
263
+ - `ask-destructive` — prompt for destructive/privileged (default)
264
+ - `ask-all` — prompt for every command
265
+ - `deny` — reject destructive/privileged commands outright (no prompt)
266
+
267
+ ### Approval Grants (just-in-time)
122
268
 
123
- You can add this MCP server to Claude Code using the `claude mcp add` command. This is the recommended method for Claude Code.
269
+ `approvalGrantTtlMs` lets one explicit approval cover repeats of the **exact
270
+ same** command on the same profile for a bounded time (e.g. `300000` for five
271
+ minutes). It exists because approving `rm -rf /tmp/build` every few seconds
272
+ during an iterative task trains you to click through prompts — which is worse
273
+ for safety than a grant you chose deliberately.
124
274
 
125
- **Basic Installation:**
275
+ A grant is bound to the exact command text, the profile and the command class:
276
+ approving `rm -rf /tmp/build` does not cover `rm -rf /tmp/build-prod`, the same
277
+ command on another host, or the same command escalated to `sudo`. Runs covered
278
+ by a grant appear in the audit log with `approver: "jit-grant"`, so they stay
279
+ distinguishable from a fresh human answer.
280
+
281
+ **Off by default** (`0` = always prompt). Auto-approval weakens the gate that
282
+ makes destructive commands safe, so turning it on should be a decision.
283
+
284
+ ### Command Quota
285
+
286
+ `commandQuotaPerDay` bounds how many commands a profile may run in a rolling
287
+ 24-hour window (0 = unlimited). The approval gate stops *destructive* commands
288
+ and the HTTP rate limiter caps request rate, but neither bounds total work — a
289
+ prompt-injected agent looping over allowed commands stays under both. The quota
290
+ is the circuit breaker for that case.
291
+
292
+ Counted after policy allows a command and before it runs, so a denied command
293
+ does not spend budget. The window slides rather than resetting at midnight,
294
+ which would let an agent spend a full quota just before the reset and another
295
+ immediately after.
296
+
297
+ ### External Policy Engine (OPA)
298
+
299
+ For organizations that standardize on Open Policy Agent / Rego:
126
300
 
127
301
  ```bash
128
- claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
302
+ ssh-mcp --opaUrl=http://localhost:8181
129
303
  ```
130
304
 
131
- **Installation Examples:**
305
+ When `--opaUrl` is set, every command is additionally evaluated by OPA after the built-in policy engine. The request shape follows the AuthZEN Access Evaluation contract:
306
+
307
+ ```json
308
+ {
309
+ "input": {
310
+ "subject": { "role": "operator", "profile": "prod-web-1" },
311
+ "action": { "tool": "run-command", "commandClass": "destructive" },
312
+ "resource": { "command": "rm -rf /tmp/cache", "binary": "rm", "host": "10.0.1.50" },
313
+ "context": { "readOnly": false }
314
+ }
315
+ }
316
+ ```
317
+
318
+ OPA responds with `{ "result": true/false }`. If OPA denies (`result: false`), the command is blocked even if the built-in engine allows it. If OPA is unreachable, the built-in engine's decision stands (fail-open to avoid locking out access).
319
+
320
+ Example Rego policy (`ssh-mcp.rego`):
321
+ ```rego
322
+ package ssh.mcp
323
+
324
+ default allow := false
325
+
326
+ # Admins can run anything on dev hosts
327
+ allow if {
328
+ input.subject.role == "admin"
329
+ startswith(input.subject.profile, "dev")
330
+ }
331
+
332
+ # Deny all destructive commands on prod
333
+ deny if {
334
+ input.action.commandClass == "destructive"
335
+ startswith(input.subject.profile, "prod")
336
+ }
337
+ ```
338
+
339
+ ---
340
+
341
+ ## Security
342
+
343
+ ### Threat Model
344
+
345
+ See [SECURITY.md](./SECURITY.md) for the full threat model, vulnerability reporting policy, and deployment checklist.
346
+
347
+ ### Safe Defaults
348
+
349
+ - **Non-root** user in all examples
350
+ - **TOFU** host key verification (accept on first connect, verify after)
351
+ - **RFC 9142** algorithm allow-list (no SHA-1, no CBC, no ssh-rsa)
352
+ - **exec()-only** (no persistent su shells — fixes PTY leak)
353
+ - **Sudo via stdin** (not argv — fixes process list leak)
354
+ - **Sanitizer** strips CR/LF/NUL from all metadata
355
+ - **3-layer redaction** (field → regex → entropy) on audit logs
356
+ - **No CLI-arg secrets** (use env vars, keychain, or config)
357
+
358
+ ### Hardening Checklist
359
+
360
+ - [ ] Create dedicated low-privilege service account on target hosts
361
+ - [ ] Use command-specific `sudoers` instead of `NOPASSWD: ALL`
362
+ - [ ] Enable `ask-all` approval for production profiles
363
+ - [ ] Restrict network egress on target hosts
364
+ - [ ] Use `readOnly = true` for monitoring profiles
365
+ - [ ] Review audit logs regularly
366
+ - [ ] Run `chmod 600 config.toml`
367
+
368
+ ---
369
+
370
+ ## Transports
371
+
372
+ ### stdio (default)
373
+
374
+ For local MCP clients (Claude Code, Cursor, Windsurf). No network exposure.
132
375
 
133
- **With Password Authentication:**
134
376
  ```bash
135
- claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=192.168.1.100 --port=22 --user=admin --password=your_password
377
+ ssh-mcp # reads config from XDG path
378
+ ssh-mcp --config=/path/to.toml # custom config path
136
379
  ```
137
380
 
138
- **With SSH Key Authentication:**
381
+ ### HTTP (optional)
382
+
383
+ For remote/web clients behind a reverse proxy with TLS:
384
+
139
385
  ```bash
140
- claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=example.com --user=root --key=/path/to/private/key
386
+ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret
387
+ ssh-mcp --transport=http --httpPort=3000 --bearerToken=secret --rateLimit=60
141
388
  ```
142
389
 
143
- **With Custom Timeout and No Character Limit:**
390
+ | Flag | Default | Description |
391
+ |------|---------|-------------|
392
+ | `--bearerToken` | required | Bearer token for authentication (all routes except `GET /health`) |
393
+ | `--httpPort` | 3000 | HTTP listen port |
394
+ | `--httpHost` | 127.0.0.1 | Bind address |
395
+ | `--rateLimit` | 0 (off) | Max requests per minute (0 = unlimited) |
396
+
397
+ Endpoints: `POST /` (MCP Streamable HTTP), `GET /status`, `GET /health`
398
+
399
+ When rate limit is exceeded, the server returns HTTP 429 with `Retry-After` header and a JSON-RPC error body so MCP clients can handle it gracefully.
400
+
401
+ **Always terminate TLS at a reverse proxy** (Caddy/nginx). The server listens on `127.0.0.1` only.
402
+
403
+ ---
404
+
405
+ ## Docker
406
+
144
407
  ```bash
145
- claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=192.168.1.100 --user=admin --password=your_password --timeout=120000 --maxChars=none
408
+ # Build
409
+ docker build -t ssh-mcp .
410
+
411
+ # Run (config file + env vars for credentials)
412
+ docker run -i \
413
+ -v ./config.toml:/home/appuser/.config/ssh-mcp/config.toml:ro \
414
+ -e SSH_MCP_PASSWORD=secret \
415
+ ssh-mcp
146
416
  ```
147
417
 
148
- **With Sudo and Su Support:**
418
+ Or with docker-compose:
419
+
149
420
  ```bash
150
- claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=192.168.1.100 --user=admin --password=your_password --sudoPassword=sudo_pass --suPassword=root_pass
421
+ docker-compose --profile app up
151
422
  ```
152
423
 
153
- **Installation Scopes:**
424
+ The Docker image runs as non-root UID 65532, with a minimal `node:22-slim` base.
425
+
426
+ ---
427
+
428
+ ## CLI Flags (v2)
429
+
430
+ Secrets are **never** passed as CLI arguments.
431
+
432
+ | Flag | Default | Description |
433
+ |------|---------|-------------|
434
+ | `--config` | XDG path | Path to TOML config file |
435
+ | `--host` | — | Quick start: SSH host (creates single-profile config) |
436
+ | `--user` | — | Quick start: SSH username |
437
+ | `--port` | 22 | Quick start: SSH port |
438
+ | `--key` | — | Quick start: Path to private key |
439
+ | `--workdir` | — | Quick start: Working directory for commands and sessions |
440
+ | `--timeout` | 60000 | Command timeout in ms |
441
+ | `--maxChars` | 5000 | Max command length (`none` or `0` disables the limit) |
442
+ | `--sessionMax` | 5 | Max concurrent sessions per connection |
443
+ | `--sessionTtl` | 600000 | Session idle timeout in ms |
444
+ | `--transport` | stdio | `stdio` or `http` |
445
+ | `--httpPort` | 3000 | HTTP transport port |
446
+ | `--httpHost` | 127.0.0.1 | HTTP bind address |
447
+ | `--bearerToken` | — | Bearer token for HTTP transport auth (required for `--transport=http`) |
448
+ | `--rateLimit` | 0 | HTTP requests per minute on the MCP route (0 = unlimited) |
449
+ | `--allowedHosts` | bind address + localhost | Comma-separated Host headers accepted by the DNS-rebinding guard |
450
+ | `--insecureHostKey` | false | Disable host key verification (test only!) |
451
+ | `--disableApproval` | false | Skip the approval gate (quick start profile only) |
452
+ | `--opaUrl` | — | OPA sidecar URL for external policy |
453
+ | `--commandQuota` | 0 (off) | Max commands per rolling 24h per profile |
454
+ | `--approvalGrantTtl` | 0 (off) | Auto-approve an identical command for this many ms after approval |
455
+ | `--auditEntropyScan` | false | Enable entropy-based secret scanning in audit |
456
+ | `--auditTamperEvident` | false | Enable hash-chained tamper-evident audit log |
457
+ | `--otelEndpoint` | — | OTLP/HTTP endpoint for OpenTelemetry traces |
458
+ | `--otelServiceName` | ssh-mcp | Service name reported on trace spans |
459
+ | `--dumpToolHashes` | — | Print SHA-256 hashes of the tool descriptions and exit |
460
+
461
+ ---
462
+
463
+ ## Migrating from v1
464
+
465
+ v2 is a breaking release. Passing a removed flag now fails at startup with the
466
+ replacement, rather than failing later as a confusing auth error.
154
467
 
155
- You can specify the scope when adding the server:
468
+ ### Tools
156
469
 
157
- - **Local scope** (default): For personal use in the current project
158
- ```bash
159
- claude mcp add --transport stdio ssh-mcp --scope local -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
160
- ```
470
+ | v1 | v2 | Notes |
471
+ |----|----|-------|
472
+ | `exec` | `read-command` | Allowlisted read-only commands. Prefer this for reads. |
473
+ | `exec` | `run-command` | Arbitrary commands. Destructive ones go through the approval gate. |
474
+ | `sudo-exec` | `privileged-command` | Always requires approval. Password is piped via stdin. |
475
+ | `description` parameter | — | Removed. It was an injection vector (#44) and never reached the host. |
161
476
 
162
- - **Project scope**: Share with your team via `.mcp.json` file
163
- ```bash
164
- claude mcp add --transport stdio ssh-mcp --scope project -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
165
- ```
477
+ **Command results now carry status.** In v1 a failed command rejected with
478
+ `Error (code N)`. In v2 a non-zero exit comes back as an error result including
479
+ the exit code and stderr — so an empty response no longer means "it worked".
166
480
 
167
- - **User scope**: Available across all your projects
168
- ```bash
169
- claude mcp add --transport stdio ssh-mcp --scope user -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
170
- ```
481
+ ### Flags
171
482
 
483
+ | v1 flag | Replacement |
484
+ |---------|-------------|
485
+ | `--password` | `SSH_MCP_PASSWORD` env var (or `SSH_MCP_<PROFILE>_PASSWORD`) |
486
+ | `--suPassword` | `SSH_MCP_SUDO_PASSWORD` env var |
487
+ | `--sudoPassword` | `SSH_MCP_SUDO_PASSWORD` env var |
488
+ | `--disableSudo` | Use a role/policy that disallows the `privileged` class |
172
489
 
173
- **Verify Installation:**
490
+ Credentials moved off the command line because CLI arguments are world-readable
491
+ via `/proc/<pid>/cmdline` on Linux (CWE-214). Credentials now resolve through an
492
+ SSH agent → OS keychain → env var → key file cascade.
174
493
 
175
- After adding the server, restart Claude Code and ask Cascade to execute a command:
176
- ```
177
- "Can you run 'ls -la' on the remote server?"
494
+ ### Example
495
+
496
+ ```jsonc
497
+ // v1
498
+ { "command": "npx", "args": ["ssh-mcp", "--host=1.2.3.4", "--user=root", "--password=hunter2"] }
499
+
500
+ // v2 — credentials via env
501
+ {
502
+ "command": "npx",
503
+ "args": ["ssh-mcp", "--host=1.2.3.4", "--user=root"],
504
+ "env": { "SSH_MCP_PASSWORD": "hunter2" }
505
+ }
178
506
  ```
179
507
 
180
- For more information about MCP in Claude Code, see the [official documentation](https://docs.claude.com/en/docs/claude-code/mcp).
508
+ For more than one host, move to a TOML config file (see [Configuration](#configuration))
509
+ and pass `--config <path>`; profiles carry per-host roles and approval policy.
510
+
511
+ ### Host key verification
512
+
513
+ v1 did not verify host keys. v2 defaults to trust-on-first-use and records the
514
+ key; a later mismatch fails the connection. Pin explicitly with `trustedHostKey`
515
+ in a profile, or pass `--insecureHostKey` to opt out (test environments only).
516
+
517
+ ---
181
518
 
182
519
  ## Testing
183
520
 
184
- You can use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for visual debugging of this MCP Server.
521
+ ```bash
522
+ # Start test SSH server
523
+ docker-compose --profile test up -d
185
524
 
186
- ```sh
187
- npm run inspect
188
- ```
525
+ # Run all tests
526
+ npm test
189
527
 
190
- ## Disclaimer
528
+ # Run only unit tests
529
+ npm test -- test/unit/
191
530
 
192
- SSH MCP Server is provided under the [MIT License](./LICENSE). Use at your own risk. This project is not affiliated with or endorsed by any SSH or MCP provider.
531
+ # Run with coverage
532
+ npm run coverage
533
+ ```
193
534
 
194
- ## Contributing
535
+ ## MCP Inspector
195
536
 
196
- We welcome contributions! Please see our [Contributing Guidelines](./CONTRIBUTING.md) for more information.
537
+ ```bash
538
+ npm run inspect
539
+ ```
540
+
541
+ ---
197
542
 
198
- ## Code of Conduct
543
+ ## Contributing
199
544
 
200
- This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md) to ensure a welcoming environment for everyone.
545
+ See [CONTRIBUTING.md](./CONTRIBUTING.md). Please follow the [security checklist](./SECURITY.md#security-checklist-for-contributors) in all PRs.
201
546
 
202
547
  ## Support
203
548
 
204
- If you find SSH MCP Server helpful, consider starring the repository or contributing! Pull requests and feedback are welcome.
549
+ If you find SSH MCP Server helpful, consider starring the repository or [sponsoring](https://github.com/sponsors/tufantunc)!