@aaarc/handfree-ssh-mcp 1.0.1 → 1.0.2

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
@@ -8,102 +8,102 @@ A hands-free SSH automation tool via MCP. Fork of [ssh-mcp-server](https://githu
8
8
 
9
9
  ## 🎯 Philosophy
10
10
 
11
- The original ssh-mcp-server requires passing credentials and options via CLI arguments every time. That's tedious.
12
-
13
- **handfree-ssh-mcp** takes a different approach:
14
-
15
- 1. **Reuse your existing `~/.ssh/config`** automatically, or configure servers once in YAML
16
- 2. **Set command policies** per server through a YAML overlay
17
- 3. **Let the LLM call whatever it needs** - hands-free
11
+ The original ssh-mcp-server requires passing credentials and options via CLI arguments every time. That's tedious.
12
+
13
+ **handfree-ssh-mcp** takes a different approach:
14
+
15
+ 1. **Reuse your existing `~/.ssh/config`** automatically, or configure servers once in YAML
16
+ 2. **Set command policies** per server through a YAML overlay
17
+ 3. **Let the LLM call whatever it needs** - hands-free
18
18
 
19
19
  Less manual interventions. Just autonomous SSH execution with safeguards.
20
20
 
21
21
  ## ✨ What's New
22
22
 
23
- | Feature | Original | handfree-ssh-mcp |
24
- |---------|----------|------------------|
25
- | Configuration | CLI args | **OpenSSH `~/.ssh/config` + optional YAML overlay** |
26
- | Multi-server | Messy `--ssh` flags | **Clean YAML structure** |
27
- | Command policy | Single comma-separated whitelist | **Blacklist mode by default, optional whitelist mode** |
28
- | Streaming | Not supported | **Real-time output with `stream` param** |
29
- | Discoverability | None | **`show-whitelist` tool for LLM** |
23
+ | Feature | Original | handfree-ssh-mcp |
24
+ |---------|----------|------------------|
25
+ | Configuration | CLI args | **OpenSSH `~/.ssh/config` + optional YAML overlay** |
26
+ | Multi-server | Messy `--ssh` flags | **Clean YAML structure** |
27
+ | Command policy | Single comma-separated whitelist | **Blacklist mode by default, optional whitelist mode** |
28
+ | Streaming | Not supported | **Real-time output with `stream` param** |
29
+ | Discoverability | None | **`show-whitelist` tool for LLM** |
30
30
 
31
31
  ## 🚀 Quick Start
32
32
 
33
- ### 1. Use your existing `~/.ssh/config`
34
-
35
- If you already have:
36
-
37
- ```sshconfig
38
- Host dev
39
- HostName 192.168.1.100
40
- User root
41
- IdentityFile ~/.ssh/id_ed25519
42
- ```
43
-
44
- you can start the MCP without a YAML file:
45
-
46
- ```json
47
- {
48
- "mcpServers": {
49
- "ssh": {
50
- "command": "npx",
51
- "args": ["-y", "@aaarc/handfree-ssh-mcp", "--enable-servers", "dev"]
52
- }
53
- }
54
- }
55
- ```
56
-
57
- `--enable-servers` is optional. If you omit it, every concrete `Host` entry loaded from `~/.ssh/config` (plus YAML entries, if any) is enabled.
58
-
59
- ### 2. Optional: create `servers.yaml` for policies or overrides
60
-
61
- ```yaml
62
- sshConfig: true # default; loads ~/.ssh/config before applying this YAML
63
-
64
- servers:
65
- dev: # Server name - use this in --enable-servers
66
- # host / port / username / privateKey can be omitted when dev exists in ~/.ssh/config.
67
- # Values here override the OpenSSH config entry when present.
68
- # commandMode defaults to blacklist: commands are allowed unless they
69
- # match the built-in dangerous blacklist or a pattern below.
70
- blacklist:
71
- - "^docker system prune.*$"
72
-
73
- prod:
74
- host: XXXXX
75
- port: 22
76
- username: deploy
77
- privateKey: ~/.ssh/id_rsa
78
- commandMode: whitelist
79
- whitelist:
80
- - "^ls.*$" # Read only
81
- - "^cat.*$"
82
- - "^tail.*$"
33
+ ### 1. Use your existing `~/.ssh/config`
34
+
35
+ If you already have:
36
+
37
+ ```sshconfig
38
+ Host dev
39
+ HostName 192.168.1.100
40
+ User root
41
+ IdentityFile ~/.ssh/id_ed25519
42
+ ```
43
+
44
+ you can start the MCP without a YAML file:
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "ssh": {
50
+ "command": "npx",
51
+ "args": ["-y", "@aaarc/handfree-ssh-mcp", "--enable-servers", "dev"]
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ `--enable-servers` is optional. If you omit it, every concrete `Host` entry loaded from `~/.ssh/config` (plus YAML entries, if any) is enabled.
58
+
59
+ ### 2. Optional: create `servers.yaml` for policies or overrides
60
+
61
+ ```yaml
62
+ sshConfig: true # default; loads ~/.ssh/config before applying this YAML
63
+
64
+ servers:
65
+ dev: # Server name - use this in --enable-servers
66
+ # host / port / username / privateKey can be omitted when dev exists in ~/.ssh/config.
67
+ # Values here override the OpenSSH config entry when present.
68
+ # commandMode defaults to blacklist: commands are allowed unless they
69
+ # match the built-in dangerous blacklist or a pattern below.
70
+ blacklist:
71
+ - "^docker system prune.*$"
72
+
73
+ prod:
74
+ host: XXXXX
75
+ port: 22
76
+ username: deploy
77
+ privateKey: ~/.ssh/id_rsa
78
+ commandMode: whitelist
79
+ whitelist:
80
+ - "^ls.*$" # Read only
81
+ - "^cat.*$"
82
+ - "^tail.*$"
83
83
  blacklist:
84
84
  - "^rm.*$" # Never allow delete
85
85
  - "^shutdown.*$"
86
86
  - "^reboot.*$"
87
- ```
88
-
89
- ### 3. Add to MCP Config
90
-
91
- ```json
92
- {
87
+ ```
88
+
89
+ ### 3. Add to MCP Config
90
+
91
+ ```json
92
+ {
93
93
  "mcpServers": {
94
94
  "ssh": {
95
95
  "command": "node",
96
96
  "args": [
97
- "/path/to/handfree-ssh-mcp/build/index.js",
98
- "--config", "/path/to/servers.yaml",
99
- "--enable-servers", "dev,prod"
97
+ "/path/to/handfree-ssh-mcp/build/index.js",
98
+ "--config", "/path/to/servers.yaml",
99
+ "--enable-servers", "dev,prod"
100
100
  ]
101
101
  }
102
102
  }
103
- }
104
- ```
105
-
106
- ### 4. Done. Let the LLM Work.
103
+ }
104
+ ```
105
+
106
+ ### 4. Done. Let the LLM Work.
107
107
 
108
108
  The AI can now execute commands on your servers. All within your defined security boundaries.
109
109
 
@@ -114,16 +114,16 @@ The AI can now execute commands on your servers. All within your defined securit
114
114
  | Tool | Description |
115
115
  |------|-------------|
116
116
  | `execute-command` | Run SSH command (with optional `stream` for real-time output) |
117
- | `show-whitelist` | Show active command policy + SFTP policy + output-log path for a server |
117
+ | `show-whitelist` | Show active command policy + SFTP policy + output-log path for a server |
118
118
  | `upload` | Upload local file to a remote server (CRLF-fix for shell scripts, skip-if-identical) |
119
119
  | `download` | Download remote file to local disk |
120
120
  | `transfer` | Unified upload / download / server-to-server relay (`mode`: `upload` / `download` / `relay`, optional `recursive`, optional `skipIfIdentical`) |
121
- | `list-servers` | List configured (enabled) servers |
121
+ | `list-servers` | List configured (enabled) servers. Lean by default; `verbose:true` adds cached system status, `refresh:true` re-collects it (implies verbose). |
122
122
  | `help` | Self-describing help text for the MCP client |
123
123
 
124
124
  ### show-whitelist
125
125
 
126
- **Use this first!** Let the LLM inspect the active command policy:
126
+ **Use this first!** Let the LLM inspect the active command policy:
127
127
 
128
128
  ```json
129
129
  {
@@ -134,7 +134,7 @@ The AI can now execute commands on your servers. All within your defined securit
134
134
  }
135
135
  ```
136
136
 
137
- Returns command mode, built-in command guards, configured whitelist/blacklist patterns, and examples when whitelist mode is active.
137
+ Returns command mode, built-in command guards, configured whitelist/blacklist patterns, and examples when whitelist mode is active.
138
138
 
139
139
  ### execute-command
140
140
 
@@ -161,53 +161,55 @@ Returns command mode, built-in command guards, configured whitelist/blacklist pa
161
161
  - Simple, fast commands (ls, pwd, cat)
162
162
  - When you don't need real-time feedback
163
163
 
164
- ## 📄 YAML Config Reference
165
-
166
- ```yaml
167
- # OpenSSH config loading is enabled by default.
168
- # true = load ~/.ssh/config, false = use YAML only.
169
- # You can also provide explicit paths:
170
- sshConfig:
171
- enabled: true
172
- paths:
173
- - ~/.ssh/config
174
- - ~/.ssh/config.d/work
175
-
176
- # Eagerly connect to all enabled servers on startup.
177
- # false (default) = lazy connect on first tool call.
178
- preConnect: false
164
+ ## 📄 YAML Config Reference
165
+
166
+ ```yaml
167
+ # OpenSSH config loading is enabled by default.
168
+ # true = load ~/.ssh/config, false = use YAML only.
169
+ # You can also provide explicit paths:
170
+ sshConfig:
171
+ enabled: true
172
+ paths:
173
+ - ~/.ssh/config
174
+ - ~/.ssh/config.d/work
175
+
176
+ # Eagerly connect to all enabled servers on startup.
177
+ # false (default) = lazy connect on first tool call.
178
+ preConnect: false
179
179
 
180
180
  # Optional: root dir for execute-command full-output logs.
181
181
  # Per-call logs land under <outputLogDir>/<server>/<user>/<ts>-<pid>-<rand>.log.
182
182
  # Defaults to <cwd>/.handfree-output when unset. Supports ~ and relative paths.
183
183
  outputLogDir: ~/handfree-logs
184
184
 
185
- servers:
186
- server_name:
187
- # Required only for YAML-only servers. If a same-named Host exists in
188
- # ~/.ssh/config, these fields are optional overrides.
189
- host: 192.168.1.1
190
- port: 22
191
- username: root
185
+ servers:
186
+ server_name:
187
+ # Required only for YAML-only servers. If a same-named Host exists in
188
+ # ~/.ssh/config, these fields are optional overrides.
189
+ host: 192.168.1.1
190
+ port: 22
191
+ username: root
192
192
 
193
- # Auth: use ONE of these. Omit agent to use SSH_AUTH_SOCK when present;
194
- # set agent only when you need a specific socket path.
195
- password: xxx
196
- privateKey: ~/.ssh/id_rsa
197
- agent: /path/to/ssh-agent.sock
198
- passphrase: key_password # If privateKey is encrypted
193
+ # Auth: use ONE of these. Omit agent to use SSH_AUTH_SOCK when present;
194
+ # set agent only when you need a specific socket path.
195
+ password: xxx
196
+ privateKey: ~/.ssh/id_rsa
197
+ agent: /path/to/ssh-agent.sock
198
+ passphrase: key_password # If privateKey is encrypted
199
199
 
200
- # Network
201
- socksProxy: socks5://host:port
200
+ # Network — at most ONE of `socksProxy` or `jumpHost`. See "Jump host" below.
201
+ # socksProxy: socks5://host:port
202
+ # jumpHost: bastion
203
+
202
204
 
203
- # Command policy (regex patterns)
204
- # Default is blacklist. Set commandMode: whitelist to require a whitelist.
205
- commandMode: blacklist
206
- whitelist: # Active only in whitelist mode
207
- - "^ls.*$"
208
- - "^cat.*$"
209
- blacklist: # Block matching commands
210
- - "^docker system prune.*$"
205
+ # Command policy (regex patterns)
206
+ # Default is blacklist. Set commandMode: whitelist to require a whitelist.
207
+ commandMode: blacklist
208
+ whitelist: # Active only in whitelist mode
209
+ - "^ls.*$"
210
+ - "^cat.*$"
211
+ blacklist: # Block matching commands
212
+ - "^docker system prune.*$"
211
213
 
212
214
  # Safe directory for destructive commands (rm, etc.)
213
215
  safeDirectory: /home/user # rm allowed only within this path
@@ -224,9 +226,9 @@ servers:
224
226
  - /path/to/extra/local/dir
225
227
  ```
226
228
 
227
- ### Security note: command policy
228
-
229
- `execute-command` defaults to `commandMode: blacklist`. In that mode commands are allowed unless they match built-in destructive guards, the built-in dangerous-command blacklist, or a server's configured `blacklist:` patterns. Built-in blocked operations include hidden/chained destructive file operations, risky absolute-path output redirection, system power commands (`reboot`, `shutdown`, `halt`, `poweroff`), recursive force delete (`rm -rf`), destructive disk writes (`dd ... of=`), and filesystem formatting commands. Set `commandMode: whitelist` to require every command to match `whitelist:` after blacklist checks. For compatibility, a YAML server that contains `whitelist:` without `commandMode:` is treated as whitelist mode.
229
+ ### Security note: command policy
230
+
231
+ `execute-command` defaults to `commandMode: blacklist`. In that mode commands are allowed unless they match built-in destructive guards, the built-in dangerous-command blacklist, or a server's configured `blacklist:` patterns. Built-in blocked operations include hidden/chained destructive file operations, risky absolute-path output redirection, system power commands (`reboot`, `shutdown`, `halt`, `poweroff`), recursive force delete (`rm -rf`), destructive disk writes (`dd ... of=`), and filesystem formatting commands. Set `commandMode: whitelist` to require every command to match `whitelist:` after blacklist checks. For compatibility, a YAML server that contains `whitelist:` without `commandMode:` is treated as whitelist mode.
230
232
 
231
233
  ### SFTP path policy
232
234
 
@@ -237,6 +239,37 @@ servers:
237
239
 
238
240
  Path matching is exact-equal or `dir + separator` prefix. `..` segments and null bytes are rejected. Use `show-whitelist` to inspect a server's current SFTP policy.
239
241
 
242
+ ### Jump host (ProxyJump-style)
243
+
244
+ Tunnel a target's SSH connection through another server defined in the same YAML. Useful when the target isn't directly reachable from your machine but a bastion is.
245
+
246
+ ```yaml
247
+ servers:
248
+ bastion:
249
+ host: 1.2.3.4
250
+ username: gate
251
+ privateKey: ~/.ssh/id_rsa
252
+
253
+ target: # NOT directly reachable
254
+ host: 10.0.0.5
255
+ username: app
256
+ password: <target-password>
257
+ jumpHost: bastion # <- tunnel through `bastion`
258
+ whitelist: # target's own policy
259
+ - "^ls( .*)?$"
260
+ - "^pwd$"
261
+ ```
262
+
263
+ Rules (enforced at config load — bad configs fail fast):
264
+
265
+ - **Chaining to any depth.** The referenced jump host may itself set `jumpHost`, forming a chain `target -> J1 -> J2 -> ...`. The chain is built innermost-first (the deepest, directly-reachable hop connects first). Only **cycles** are rejected.
266
+ - **Mutually exclusive with `socksProxy`** on the same target.
267
+ - **Self-reference is rejected.** `target.jumpHost: target` is invalid.
268
+ - **Independent policy.** The target authenticates with its OWN `username` / `password` / `privateKey`, and its own `whitelist` / `blacklist` / `safeDirectory` / `allowed*Directories` apply. The jump host is purely transport.
269
+ - **Jump host is still a normal server.** You can run tools against `bastion` directly; its connection is separate from the tunneling one.
270
+
271
+ **Hot-reload note:** `jumpHost` is connection-level, not policy-level. Editing it in `servers.yaml` while the server is running will NOT take effect — the hot-reloader only refreshes whitelist / blacklist / `safeDirectory` / `allowed*Directories`. Restart the MCP server to pick up `jumpHost` changes.
272
+
240
273
  ### Connection lifecycle (connect / reconnect)
241
274
 
242
275
  - **Lazy by default.** A server's SSH client is created on its first tool call via `ensureConnected()`. Set `preConnect: true` (or pass `--pre-connect`) to open all enabled servers at startup in parallel; failures are logged but don't block startup.
@@ -269,27 +302,27 @@ servers:
269
302
  dev: { ... }
270
303
  ```
271
304
 
272
- ## ⚙️ CLI Options
273
-
274
- ```text
275
- --config Optional path to YAML config/policy overlay
276
- --ssh-config Optional OpenSSH config path(s), comma-separated or repeated
277
- --no-ssh-config Disable automatic ~/.ssh/config loading
278
- --enable-servers Optional comma-separated list of servers to enable
279
- --pre-connect Eagerly connect to all enabled servers on startup
280
- (overrides `preConnect` in YAML). Default: lazy connect.
281
- ```
282
-
283
- > **Note**: `--enable-servers` controls which servers are available. The first server listed becomes the default when `connectionName` is not specified.
284
- > If `--enable-servers` is omitted, all loaded servers are enabled; when more than one server is enabled, tools require `connectionName`.
285
-
286
- ### OpenSSH config support
287
-
288
- The loader understands concrete `Host` entries and applies normal OpenSSH-style first-value matching against wildcard defaults. It supports `HostName`, `User`, `Port`, `IdentityFile`, `IdentityAgent`, `Include`, and common tokens such as `%h`, `%n`, `%p`, `%r`, `%u`, `%d`, and `%%`. Wildcard-only `Host *` blocks are used as defaults but are not exposed as runnable server names. `Match` blocks are ignored.
289
-
290
- Connection settings are hot-reloaded when the loaded YAML/OpenSSH config files change. If host, user, port, identity, agent, passphrase, or proxy settings change, the existing SSH client for that server is closed and the next tool call reconnects with the new values. Whitelist, blacklist, SFTP policy, and output log settings also update live.
291
-
292
- Example with selective servers:
305
+ ## ⚙️ CLI Options
306
+
307
+ ```text
308
+ --config Optional path to YAML config/policy overlay
309
+ --ssh-config Optional OpenSSH config path(s), comma-separated or repeated
310
+ --no-ssh-config Disable automatic ~/.ssh/config loading
311
+ --enable-servers Optional comma-separated list of servers to enable
312
+ --pre-connect Eagerly connect to all enabled servers on startup
313
+ (overrides `preConnect` in YAML). Default: lazy connect.
314
+ ```
315
+
316
+ > **Note**: `--enable-servers` controls which servers are available. The first server listed becomes the default when `connectionName` is not specified.
317
+ > If `--enable-servers` is omitted, all loaded servers are enabled; when more than one server is enabled, tools require `connectionName`.
318
+
319
+ ### OpenSSH config support
320
+
321
+ The loader understands concrete `Host` entries and applies normal OpenSSH-style first-value matching against wildcard defaults. It supports `HostName`, `User`, `Port`, `IdentityFile`, `IdentityAgent`, `Include`, and common tokens such as `%h`, `%n`, `%p`, `%r`, `%u`, `%d`, and `%%`. Wildcard-only `Host *` blocks are used as defaults but are not exposed as runnable server names. `Match` blocks are ignored.
322
+
323
+ Connection settings are hot-reloaded when the loaded YAML/OpenSSH config files change. If host, user, port, identity, agent, passphrase, or proxy settings change, the existing SSH client for that server is closed and the next tool call reconnects with the new values. Whitelist, blacklist, SFTP policy, and output log settings also update live.
324
+
325
+ Example with selective servers:
293
326
 
294
327
  ```json
295
328
  {
@@ -302,7 +335,7 @@ Example with selective servers:
302
335
 
303
336
  ## 🛡️ Security
304
337
 
305
- - **Pick the right command policy**: use default blacklist mode for flexible automation, or `commandMode: whitelist` for locked-down hosts
338
+ - **Pick the right command policy**: use default blacklist mode for flexible automation, or `commandMode: whitelist` for locked-down hosts
306
339
  - **Keep secrets safe**: Add `servers.yaml` to `.gitignore`
307
340
  - **Per-server control**: Prod can be locked down, dev can be permissive
308
341
 
@@ -76,6 +76,9 @@ function loadYamlConfig(configPath, options) {
76
76
  if (options.requireServers && Object.keys(configMap).length === 0) {
77
77
  throw new Error("No servers defined in config file");
78
78
  }
79
+ if (options.requireServers) {
80
+ validateMergedConfigs(configMap);
81
+ }
79
82
  Logger.log(`Total YAML servers loaded: ${Object.keys(configMap).length}`, "info");
80
83
  // Resolve outputLogDir if provided. Default applied later (in ssh-connection-manager)
81
84
  // so it always tracks the current cwd at execution time.
@@ -130,6 +133,8 @@ function buildYamlServerConfig(name, serverConfig, options = {}) {
130
133
  }
131
134
  if (serverConfig.socksProxy !== undefined)
132
135
  merged.socksProxy = serverConfig.socksProxy ?? undefined;
136
+ if (serverConfig.jumpHost !== undefined)
137
+ merged.jumpHost = serverConfig.jumpHost ?? undefined;
133
138
  if (serverConfig.commandMode !== undefined) {
134
139
  if (serverConfig.commandMode !== "blacklist" && serverConfig.commandMode !== "whitelist") {
135
140
  throw new Error(`Server '${name}': 'commandMode' must be 'blacklist' or 'whitelist'`);
@@ -166,6 +171,7 @@ function mergeConfigMaps(sshConfigs, yamlConfigs) {
166
171
  name,
167
172
  allowedRemoteDirectories: yamlConfig.allowedRemoteDirectories ?? merged[name]?.allowedRemoteDirectories,
168
173
  allowedLocalDirectories: yamlConfig.allowedLocalDirectories ?? merged[name]?.allowedLocalDirectories,
174
+ jumpHost: yamlConfig.jumpHost ?? merged[name]?.jumpHost,
169
175
  commandMode: yamlConfig.commandMode ?? merged[name]?.commandMode,
170
176
  commandWhitelist: yamlConfig.commandWhitelist ?? merged[name]?.commandWhitelist,
171
177
  commandBlacklist: yamlConfig.commandBlacklist ?? merged[name]?.commandBlacklist,
@@ -194,6 +200,7 @@ function validateMergedConfigs(configs) {
194
200
  throw new Error(`Server '${name}': either 'password', 'privateKey', 'agent', or OpenSSH/default authentication is required`);
195
201
  }
196
202
  }
203
+ validateJumpHosts(configs);
197
204
  }
198
205
  function resolveSshConfigEnabled(options, yamlParsed) {
199
206
  if (options.loadUserSshConfig !== undefined) {
@@ -277,6 +284,51 @@ export function getLoadUserSshConfigFlag(args) {
277
284
  export function getPreConnectFlag(args) {
278
285
  return args.includes("--pre-connect");
279
286
  }
287
+ /**
288
+ * Validate every server's `jumpHost` reference.
289
+ *
290
+ * Rules:
291
+ * - target with `jumpHost` must not also set `socksProxy` (mutually exclusive).
292
+ * - referenced jump host must exist in the same config.
293
+ * - self-reference is rejected.
294
+ * - chained jumps are allowed to any depth (target -> J1 -> J2 -> ...), but the
295
+ * chain must be acyclic — a cycle is rejected to prevent infinite recursion.
296
+ */
297
+ function validateJumpHosts(configMap) {
298
+ for (const [name, cfg] of Object.entries(configMap)) {
299
+ if (cfg.jumpHost === undefined) {
300
+ continue;
301
+ }
302
+ if (typeof cfg.jumpHost !== "string" || cfg.jumpHost.length === 0) {
303
+ throw new Error(`Server '${name}': 'jumpHost' must be a non-empty string`);
304
+ }
305
+ if (cfg.socksProxy) {
306
+ throw new Error(`Server '${name}': 'jumpHost' and 'socksProxy' are mutually exclusive`);
307
+ }
308
+ if (cfg.jumpHost === name) {
309
+ throw new Error(`Server '${name}': 'jumpHost' must not reference itself`);
310
+ }
311
+ if (!configMap[cfg.jumpHost]) {
312
+ throw new Error(`Server '${name}': 'jumpHost' references unknown server '${cfg.jumpHost}'`);
313
+ }
314
+ // Walk the jump chain and detect cycles. Every intermediate hop is itself a
315
+ // server entry, so its own socksProxy / existence rules are enforced when it
316
+ // is visited by the outer loop — here we only need to guard against a loop.
317
+ const seen = [name];
318
+ let hop = cfg.jumpHost;
319
+ while (hop !== undefined) {
320
+ if (seen.includes(hop)) {
321
+ throw new Error(`Server '${name}': 'jumpHost' chain forms a cycle (${[...seen, hop].join(" -> ")})`);
322
+ }
323
+ seen.push(hop);
324
+ const next = configMap[hop];
325
+ if (!next) {
326
+ throw new Error(`Server '${hop}': 'jumpHost' references unknown server`);
327
+ }
328
+ hop = next.jumpHost;
329
+ }
330
+ }
331
+ }
280
332
  /**
281
333
  * Normalize and validate allowedRemoteDirectories.
282
334
  * Each entry must be an absolute POSIX path. Trailing slashes are stripped
@@ -1,6 +1,6 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.0.1",
3
+ version: "1.0.2",
4
4
  };
5
5
  export const SERVER_INSTRUCTIONS = `This server provides SSH access to servers loaded from OpenSSH config (~/.ssh/config by default) and optional YAML config/policy overlays.
6
6
 
@@ -8,15 +8,15 @@ Recommended workflow:
8
8
  1. Call list-servers first to discover which server names are available, enabled, and currently connected.
9
9
  2. Use show-whitelist before execute-command when you are unsure which command policy is active.
10
10
  3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands may be rejected by command policy even if each subcommand is individually safe.
11
- 4. Use stream=false for short commands that should finish quickly, such as pwd, ls, cat, head, tail, git status, or docker ps.
12
- 5. Use stream=true for commands that may take longer or where incremental output is useful.
13
- 6. Use upload and download for single-file SFTP transfers. Use transfer for recursive directory transfers or cross-server relay.
14
- 7. Call help or help { tool: "<name>" } for detailed per-tool parameter docs and examples.
15
-
16
- Server targeting:
17
- - When only one server is enabled, connectionName can be omitted and the server is auto-selected.
18
- - When multiple servers are enabled, connectionName is REQUIRED on execute-command, upload, download, show-whitelist, and transfer (upload/download mode). Omitting it returns an error listing the available names.
19
-
11
+ 4. Use stream=false for short commands that should finish quickly, such as pwd, ls, cat, head, tail, git status, or docker ps.
12
+ 5. Use stream=true for commands that may take longer or where incremental output is useful.
13
+ 6. Use upload and download for single-file SFTP transfers. Use transfer for recursive directory transfers or cross-server relay.
14
+ 7. Call help or help { tool: "<name>" } for detailed per-tool parameter docs and examples.
15
+
16
+ Server targeting:
17
+ - When only one server is enabled, connectionName can be omitted and the server is auto-selected.
18
+ - When multiple servers are enabled, connectionName is REQUIRED on execute-command, upload, download, show-whitelist, and transfer (upload/download mode). Omitting it returns an error listing the available names.
19
+
20
20
  Behavior notes:
21
21
  - A tool may automatically establish the SSH connection on first use.
22
22
  - Command execution uses blacklist mode by default, with a built-in dangerous-command blacklist. Servers can opt into whitelist mode through YAML. If a command is rejected, inspect the command policy rather than retrying the same command repeatedly.
@@ -9,6 +9,7 @@ const SUPPORTED_KEYS = new Set([
9
9
  "identityfile",
10
10
  "identityagent",
11
11
  "identitiesonly",
12
+ "proxyjump",
12
13
  ]);
13
14
  export function getDefaultUserSshConfigPath() {
14
15
  return path.join(os.homedir(), ".ssh", "config");
@@ -21,9 +22,10 @@ export function loadSshConfigFiles(configPaths = [getDefaultUserSshConfigPath()]
21
22
  parseConfigFile(resolveLocalPath(configPath, process.cwd()), ["*"], directives, loadedFiles, visitedFiles);
22
23
  }
23
24
  const rawHosts = buildRawHostConfigs(directives);
25
+ const aliases = new Map(rawHosts.map((raw) => [raw.alias.toLowerCase(), raw.alias]));
24
26
  const configs = {};
25
27
  for (const raw of rawHosts) {
26
- configs[raw.alias] = toSshConfig(raw);
28
+ configs[raw.alias] = toSshConfig(raw, aliases);
27
29
  }
28
30
  const configCount = Object.keys(configs).length;
29
31
  if (configCount > 0) {
@@ -126,7 +128,7 @@ function buildRawHostConfigs(directives) {
126
128
  }
127
129
  return rawHosts;
128
130
  }
129
- function toSshConfig(raw) {
131
+ function toSshConfig(raw, aliases) {
130
132
  const rawHost = raw.options.get("hostname") ?? raw.alias;
131
133
  const rawUser = raw.options.get("user") ?? os.userInfo().username;
132
134
  const rawPort = raw.options.get("port") ?? "22";
@@ -174,8 +176,49 @@ function toSshConfig(raw) {
174
176
  else if (identityAgent) {
175
177
  config.agent = resolveLocalPath(expandTokens(identityAgent, resolvedContext), process.cwd());
176
178
  }
179
+ const jumpHost = resolveProxyJumpAlias(raw.alias, raw.options.get("proxyjump"), aliases);
180
+ if (jumpHost) {
181
+ config.jumpHost = jumpHost;
182
+ }
177
183
  return config;
178
184
  }
185
+ function resolveProxyJumpAlias(alias, proxyJump, aliases) {
186
+ if (!proxyJump)
187
+ return undefined;
188
+ if (proxyJump.toLowerCase() === "none")
189
+ return undefined;
190
+ if (proxyJump.includes(",")) {
191
+ Logger.log(`SSH config Host '${alias}': ProxyJump chains are not supported by native jumpHost; ignoring '${proxyJump}'`, "info");
192
+ return undefined;
193
+ }
194
+ const jumpAlias = extractProxyJumpHost(proxyJump);
195
+ if (!jumpAlias)
196
+ return undefined;
197
+ const canonical = aliases.get(jumpAlias.toLowerCase());
198
+ if (!canonical) {
199
+ Logger.log(`SSH config Host '${alias}': ProxyJump '${proxyJump}' does not reference a loaded Host alias; ignoring`, "info");
200
+ return undefined;
201
+ }
202
+ return canonical;
203
+ }
204
+ function extractProxyJumpHost(proxyJump) {
205
+ let endpoint = proxyJump.trim();
206
+ if (!endpoint)
207
+ return undefined;
208
+ const atIndex = endpoint.lastIndexOf("@");
209
+ if (atIndex >= 0) {
210
+ endpoint = endpoint.slice(atIndex + 1);
211
+ }
212
+ if (endpoint.startsWith("[")) {
213
+ const closeIndex = endpoint.indexOf("]");
214
+ return closeIndex > 1 ? endpoint.slice(1, closeIndex) : undefined;
215
+ }
216
+ const colonIndex = endpoint.lastIndexOf(":");
217
+ if (colonIndex > 0 && endpoint.indexOf(":") === colonIndex) {
218
+ endpoint = endpoint.slice(0, colonIndex);
219
+ }
220
+ return endpoint || undefined;
221
+ }
179
222
  function findDefaultIdentityFile() {
180
223
  const sshDir = path.join(os.homedir(), ".ssh");
181
224
  const candidates = [
@@ -19,6 +19,7 @@ const CONNECTION_RESET_FIELDS = [
19
19
  "identitiesOnly",
20
20
  "authOptional",
21
21
  "socksProxy",
22
+ "jumpHost",
22
23
  ];
23
24
  export const BUILT_IN_COMMAND_BLACKLIST = [
24
25
  { regex: /^\s*(?:sudo\s+)?(?:reboot|shutdown|halt|poweroff)(?:\s|$)/i, reason: "system power command" },
@@ -63,6 +64,11 @@ export class SSHConnectionManager {
63
64
  // concurrent connect attempts so we never create two SSH clients for the
64
65
  // same server and leak the loser.
65
66
  connecting = new Map();
67
+ // Dedicated tunneling clients for targets that use a `jumpHost`. Keyed by
68
+ // target server name (NOT jump name), so each target that jumps through the
69
+ // same bastion still gets its own jump client. This keeps tunnel lifetimes
70
+ // tied 1:1 to the target connection and avoids cross-target interference.
71
+ jumpClients = new Map();
66
72
  defaultName = "default";
67
73
  enabledServers = null; // null = all servers enabled
68
74
  outputLogRoot = null; // null = use <cwd>/.handfree-output at write time
@@ -165,6 +171,7 @@ export class SSHConnectionManager {
165
171
  }
166
172
  this.clients.delete(name);
167
173
  }
174
+ this.teardownJumpChain(name);
168
175
  this.connected.set(name, false);
169
176
  this.connecting.delete(name);
170
177
  }
@@ -335,6 +342,24 @@ export class SSHConnectionManager {
335
342
  if (agent) {
336
343
  sshConfig.agent = agent;
337
344
  }
345
+ // Add jump-host tunnel if provided. Mutually exclusive with socksProxy
346
+ // (enforced at config load time).
347
+ if (config.jumpHost) {
348
+ try {
349
+ const sock = await this.openJumpTunnel(key, config);
350
+ sshConfig.sock = sock;
351
+ Logger.log(`Using jump host '${config.jumpHost}' for [${key}]`, "info");
352
+ }
353
+ catch (err) {
354
+ // A multi-hop chain may have partially connected (e.g. an inner hop
355
+ // came up but a later hop or the final forwardOut failed) before
356
+ // this rejected. Those already-connected hops were recorded in
357
+ // jumpClients as each one came up, so tear them down now instead of
358
+ // leaking live SSH sessions until the next connect attempt.
359
+ this.teardownJumpChain(key);
360
+ return reject(new ToolError("SSH_CONNECTION_FAILED", `Failed to open jump tunnel for [${key}] via '${config.jumpHost}': ${err.message}`, true));
361
+ }
362
+ }
338
363
  // Add SOCKS proxy configuration if provided
339
364
  if (config.socksProxy) {
340
365
  try {
@@ -405,6 +430,162 @@ export class SSHConnectionManager {
405
430
  }
406
431
  this.clients.set(key, client);
407
432
  }
433
+ /**
434
+ * Open a TCP tunnel from the target's jump chain to `config.host:config.port`
435
+ * and return the duplex stream to be used as `sock` for the target SSH client.
436
+ *
437
+ * Supports chained jumps to any depth: `target -> J1 -> J2 -> ...` where each
438
+ * hop's `jumpHost` names the next hop. The chain is built innermost-first —
439
+ * the deepest, directly-reachable hop connects normally, and each outer hop is
440
+ * connected through the previous hop's forwarded stream.
441
+ *
442
+ * Every hop's SSH client is cached (in order) under the target key so the whole
443
+ * chain can be torn down with the target connection. A hop's own client tracked
444
+ * in `this.clients` is intentionally NOT reused — jump usage and direct tool
445
+ * calls against a bastion stay isolated.
446
+ * @private
447
+ */
448
+ async openJumpTunnel(targetKey, config) {
449
+ // Tear down any stale jump chain for this target before opening a new one.
450
+ this.teardownJumpChain(targetKey);
451
+ this.jumpClients.set(targetKey, []);
452
+ const jumpClient = await this.connectJumpChain(targetKey, config.jumpHost);
453
+ return this.forwardOutStream(jumpClient, config.host, config.port);
454
+ }
455
+ /**
456
+ * Recursively connect the SSH client for `jumpName`, tunneling through its own
457
+ * `jumpHost` first when set. Each connected hop is appended (innermost-first)
458
+ * to the target's jump-client chain. Returns the connected client for this hop.
459
+ * @private
460
+ */
461
+ async connectJumpChain(targetKey, jumpName) {
462
+ const jumpConfig = this.configs[jumpName];
463
+ if (!jumpConfig) {
464
+ // Should be caught at config load, but guard at runtime too.
465
+ throw new Error(`jump host '${jumpName}' not found in config`);
466
+ }
467
+ // If this hop is itself reached through another jump, build that inner
468
+ // tunnel first and hand its stream to this hop as `sock`.
469
+ let sock;
470
+ if (jumpConfig.jumpHost) {
471
+ const innerClient = await this.connectJumpChain(targetKey, jumpConfig.jumpHost);
472
+ sock = await this.forwardOutStream(innerClient, jumpConfig.host, jumpConfig.port);
473
+ }
474
+ const jumpClient = await this.connectJumpClient(targetKey, jumpName, jumpConfig, sock);
475
+ const chain = this.jumpClients.get(targetKey);
476
+ if (chain) {
477
+ chain.push(jumpClient);
478
+ }
479
+ else {
480
+ this.jumpClients.set(targetKey, [jumpClient]);
481
+ }
482
+ return jumpClient;
483
+ }
484
+ /**
485
+ * Connect a single jump-host SSH client, optionally through `sock` (the stream
486
+ * from the previous hop). Wires a close handler that tears the whole target
487
+ * chain down so a dead hop surfaces as a clear failure on the next op.
488
+ * @private
489
+ */
490
+ connectJumpClient(targetKey, jumpName, jumpConfig, sock) {
491
+ return new Promise((resolve, reject) => {
492
+ const jumpClient = new Client();
493
+ jumpClient.on("ready", () => resolve(jumpClient));
494
+ jumpClient.on("error", (err) => {
495
+ reject(new Error(`jump SSH connect failed for '${jumpName}': ${err.message}`));
496
+ });
497
+ jumpClient.on("close", () => {
498
+ // If any hop dies, kill the target too so callers get a clear failure on
499
+ // the next op and reconnect through a fresh chain.
500
+ this.teardownTargetViaJump(targetKey);
501
+ });
502
+ const jumpSsh = {
503
+ host: jumpConfig.host,
504
+ port: jumpConfig.port,
505
+ username: jumpConfig.username,
506
+ };
507
+ if (sock) {
508
+ jumpSsh.sock = sock;
509
+ }
510
+ const jumpAgent = jumpConfig.agent === false
511
+ ? undefined
512
+ : jumpConfig.agent || (jumpConfig.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
513
+ if (jumpAgent) {
514
+ jumpSsh.agent = jumpAgent;
515
+ }
516
+ if (jumpConfig.privateKey) {
517
+ try {
518
+ jumpSsh.privateKey = fs.readFileSync(jumpConfig.privateKey, "utf8");
519
+ if (jumpConfig.passphrase)
520
+ jumpSsh.passphrase = jumpConfig.passphrase;
521
+ }
522
+ catch (err) {
523
+ return reject(new Error(`read jump private key failed: ${err.message}`));
524
+ }
525
+ }
526
+ else if (jumpConfig.password) {
527
+ jumpSsh.password = jumpConfig.password;
528
+ }
529
+ else if (jumpAgent || jumpConfig.authOptional) {
530
+ Logger.log(`Using SSH agent/default authentication for jump host '${jumpName}'`, "info");
531
+ }
532
+ else {
533
+ return reject(new Error(`jump host '${jumpName}' has no password, privateKey, or default authentication`));
534
+ }
535
+ jumpClient.connect(jumpSsh);
536
+ });
537
+ }
538
+ /**
539
+ * Open a forwarded TCP stream from `client` to `host:port`.
540
+ * @private
541
+ */
542
+ forwardOutStream(client, host, port) {
543
+ return new Promise((resolve, reject) => {
544
+ client.forwardOut("127.0.0.1", 0, host, port, (err, ch) => {
545
+ if (err)
546
+ return reject(err);
547
+ resolve(ch);
548
+ });
549
+ });
550
+ }
551
+ /**
552
+ * End and forget every jump client in a target's chain (no target teardown).
553
+ * @private
554
+ */
555
+ teardownJumpChain(targetKey) {
556
+ const chain = this.jumpClients.get(targetKey);
557
+ if (!chain) {
558
+ return;
559
+ }
560
+ this.jumpClients.delete(targetKey);
561
+ for (const client of chain) {
562
+ try {
563
+ client.end();
564
+ }
565
+ catch { /* ignore */ }
566
+ }
567
+ }
568
+ /**
569
+ * Tear the target connection and its whole jump chain down together. Idempotent
570
+ * so re-entrant close events (ending one hop closes the next) settle quietly.
571
+ * @private
572
+ */
573
+ teardownTargetViaJump(targetKey) {
574
+ const chain = this.jumpClients.get(targetKey);
575
+ const target = this.clients.get(targetKey);
576
+ if (!chain && !target) {
577
+ return; // already torn down
578
+ }
579
+ this.connected.set(targetKey, false);
580
+ if (target) {
581
+ this.clients.delete(targetKey);
582
+ try {
583
+ target.end();
584
+ }
585
+ catch { /* ignore */ }
586
+ }
587
+ this.teardownJumpChain(targetKey);
588
+ }
408
589
  /**
409
590
  * Get SSH Client with specified name
410
591
  */
@@ -1332,23 +1513,47 @@ export class SSHConnectionManager {
1332
1513
  }
1333
1514
  this.clients.clear();
1334
1515
  }
1516
+ if (this.jumpClients.size > 0) {
1517
+ for (const chain of this.jumpClients.values()) {
1518
+ for (const client of chain) {
1519
+ try {
1520
+ client.end();
1521
+ }
1522
+ catch { /* ignore */ }
1523
+ }
1524
+ }
1525
+ this.jumpClients.clear();
1526
+ }
1335
1527
  }
1336
1528
  /**
1337
- * Get basic information of all configured servers
1529
+ * Get basic information of all configured servers.
1530
+ *
1531
+ * Lean by default — returns only identity + connection state. Pass
1532
+ * `verbose: true` to include the cached `status` block (hostname, CPU,
1533
+ * memory, disk, GPUs, etc.). The status block is large and rarely useful
1534
+ * for routing decisions, so the LLM should opt in.
1338
1535
  */
1339
- getAllServerInfos() {
1536
+ getAllServerInfos(opts = {}) {
1537
+ const verbose = opts.verbose === true;
1340
1538
  return Object.keys(this.configs).map((key) => {
1341
1539
  const config = this.configs[key];
1342
- const status = this.statusCache.get(key);
1343
- return {
1540
+ const info = {
1344
1541
  name: key,
1345
1542
  host: config.host,
1346
1543
  port: config.port,
1347
1544
  username: config.username,
1348
1545
  connected: this.connected.get(key) === true,
1349
1546
  enabled: this.isServerEnabled(key),
1350
- status: status,
1351
1547
  };
1548
+ if (config.jumpHost) {
1549
+ info.jumpHost = config.jumpHost;
1550
+ }
1551
+ if (verbose) {
1552
+ const status = this.statusCache.get(key);
1553
+ if (status)
1554
+ info.status = status;
1555
+ }
1556
+ return info;
1352
1557
  });
1353
1558
  }
1354
1559
  /**
@@ -6,15 +6,17 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
6
6
  * Register list-servers tool
7
7
  */
8
8
  export function registerListServersTool(server) {
9
- server.tool("list-servers", "List the SSH servers that were loaded from OpenSSH config and/or YAML and enabled for this MCP process. Use this first to discover valid connectionName values, confirm whether a server is enabled, and see basic connection state before calling other tools. Set refresh=true to collect live system status (hostname, CPU, memory, disk, GPUs) from connected servers.", {
10
- refresh: z.boolean().optional().describe("When true, re-collects live system status from all enabled servers before returning. Without this, cached status from connection time is returned."),
11
- }, async ({ refresh }) => {
9
+ server.tool("list-servers", "List the SSH servers that were loaded from OpenSSH config and/or YAML and enabled for this MCP process. Use this first to discover valid connectionName values, confirm whether a server is enabled, see jumpHost wiring, and check basic connection state before calling other tools. By default the response is lean (identity + connection state only). Set verbose=true to include the cached system status block (hostname, CPU, memory, disk, GPUs). Set refresh=true to re-collect that status from live servers; refresh implies verbose.", {
10
+ verbose: z.boolean().optional().describe("Include the cached system status block (hostname, CPU, memory, disk, GPUs, etc.) for each server. Off by default to keep the response small. Implied by refresh=true."),
11
+ refresh: z.boolean().optional().describe("Re-collect live system status from all enabled servers before returning. Implies verbose=true. Without this, status (if requested via verbose) is read from the cache populated at connect time."),
12
+ }, async ({ verbose, refresh }) => {
12
13
  try {
13
14
  const sshManager = SSHConnectionManager.getInstance();
14
15
  if (refresh) {
15
16
  await sshManager.refreshStatus();
16
17
  }
17
- const servers = sshManager.getAllServerInfos();
18
+ const wantStatus = verbose === true || refresh === true;
19
+ const servers = sshManager.getAllServerInfos({ verbose: wantStatus });
18
20
  return {
19
21
  content: [
20
22
  {
package/package.json CHANGED
@@ -1,24 +1,24 @@
1
1
  {
2
- "name": "@aaarc/handfree-ssh-mcp",
3
- "version": "1.0.1",
2
+ "name": "@aaarc/handfree-ssh-mcp",
3
+ "version": "1.0.2",
4
4
  "description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "handfree-ssh-mcp": "build/index.js"
9
9
  },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/Ironboxplus/handfree-ssh-mcp.git"
13
- },
14
- "bugs": {
15
- "url": "https://github.com/Ironboxplus/handfree-ssh-mcp/issues"
16
- },
17
- "homepage": "https://github.com/Ironboxplus/handfree-ssh-mcp#readme",
18
- "publishConfig": {
19
- "access": "public"
20
- },
21
- "scripts": {
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Ironboxplus/handfree-ssh-mcp.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Ironboxplus/handfree-ssh-mcp/issues"
16
+ },
17
+ "homepage": "https://github.com/Ironboxplus/handfree-ssh-mcp#readme",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
22
  "test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
23
23
  "test:config": "npm run build && node --test build/tests/config.test.js",
24
24
  "test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
@@ -49,13 +49,13 @@
49
49
  "@types/ssh2": "^1.15.5",
50
50
  "typescript": "^5.8.2"
51
51
  },
52
- "files": [
53
- "build/config/**/*",
54
- "build/core/**/*",
55
- "build/models/**/*",
56
- "build/services/**/*",
57
- "build/tools/**/*",
58
- "build/utils/**/*",
59
- "build/index.js"
60
- ]
61
- }
52
+ "files": [
53
+ "build/config/**/*",
54
+ "build/core/**/*",
55
+ "build/models/**/*",
56
+ "build/services/**/*",
57
+ "build/tools/**/*",
58
+ "build/utils/**/*",
59
+ "build/index.js"
60
+ ]
61
+ }