@aaarc/handfree-ssh-mcp 1.0.0 → 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,76 +8,74 @@ 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 your security whitelists** 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
- | Whitelists | Single comma-separated string | **Per-server arrays** |
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", "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
- whitelist:
69
- - "^ls.*$"
70
- - "^cat.*$"
71
- - "^pwd$"
72
- - "^docker.*$"
73
- - "^git.*$"
74
- # Add whatever commands you trust the LLM to run
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.*$"
75
72
 
76
- prod:
77
- host: XXXXX
78
- port: 22
73
+ prod:
74
+ host: XXXXX
75
+ port: 22
79
76
  username: deploy
80
77
  privateKey: ~/.ssh/id_rsa
78
+ commandMode: whitelist
81
79
  whitelist:
82
80
  - "^ls.*$" # Read only
83
81
  - "^cat.*$"
@@ -86,26 +84,26 @@ servers:
86
84
  - "^rm.*$" # Never allow delete
87
85
  - "^shutdown.*$"
88
86
  - "^reboot.*$"
89
- ```
90
-
91
- ### 3. Add to MCP Config
92
-
93
- ```json
94
- {
87
+ ```
88
+
89
+ ### 3. Add to MCP Config
90
+
91
+ ```json
92
+ {
95
93
  "mcpServers": {
96
94
  "ssh": {
97
95
  "command": "node",
98
96
  "args": [
99
- "/path/to/handfree-ssh-mcp/build/index.js",
100
- "--config", "/path/to/servers.yaml",
101
- "--enable-servers", "dev,prod"
97
+ "/path/to/handfree-ssh-mcp/build/index.js",
98
+ "--config", "/path/to/servers.yaml",
99
+ "--enable-servers", "dev,prod"
102
100
  ]
103
101
  }
104
102
  }
105
- }
106
- ```
107
-
108
- ### 4. Done. Let the LLM Work.
103
+ }
104
+ ```
105
+
106
+ ### 4. Done. Let the LLM Work.
109
107
 
110
108
  The AI can now execute commands on your servers. All within your defined security boundaries.
111
109
 
@@ -116,16 +114,16 @@ The AI can now execute commands on your servers. All within your defined securit
116
114
  | Tool | Description |
117
115
  |------|-------------|
118
116
  | `execute-command` | Run SSH command (with optional `stream` for real-time output) |
119
- | `show-whitelist` | Show allowed commands + SFTP policy + output-log path for a server |
117
+ | `show-whitelist` | Show active command policy + SFTP policy + output-log path for a server |
120
118
  | `upload` | Upload local file to a remote server (CRLF-fix for shell scripts, skip-if-identical) |
121
119
  | `download` | Download remote file to local disk |
122
120
  | `transfer` | Unified upload / download / server-to-server relay (`mode`: `upload` / `download` / `relay`, optional `recursive`, optional `skipIfIdentical`) |
123
- | `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). |
124
122
  | `help` | Self-describing help text for the MCP client |
125
123
 
126
124
  ### show-whitelist
127
125
 
128
- **Use this first!** Let the LLM know what it's allowed to do:
126
+ **Use this first!** Let the LLM inspect the active command policy:
129
127
 
130
128
  ```json
131
129
  {
@@ -136,7 +134,7 @@ The AI can now execute commands on your servers. All within your defined securit
136
134
  }
137
135
  ```
138
136
 
139
- Returns a formatted list of allowed command patterns with examples.
137
+ Returns command mode, built-in command guards, configured whitelist/blacklist patterns, and examples when whitelist mode is active.
140
138
 
141
139
  ### execute-command
142
140
 
@@ -163,51 +161,55 @@ Returns a formatted list of allowed command patterns with examples.
163
161
  - Simple, fast commands (ls, pwd, cat)
164
162
  - When you don't need real-time feedback
165
163
 
166
- ## 📄 YAML Config Reference
167
-
168
- ```yaml
169
- # OpenSSH config loading is enabled by default.
170
- # true = load ~/.ssh/config, false = use YAML only.
171
- # You can also provide explicit paths:
172
- sshConfig:
173
- enabled: true
174
- paths:
175
- - ~/.ssh/config
176
- - ~/.ssh/config.d/work
177
-
178
- # Eagerly connect to all enabled servers on startup.
179
- # false (default) = lazy connect on first tool call.
180
- 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
181
179
 
182
180
  # Optional: root dir for execute-command full-output logs.
183
181
  # Per-call logs land under <outputLogDir>/<server>/<user>/<ts>-<pid>-<rand>.log.
184
182
  # Defaults to <cwd>/.handfree-output when unset. Supports ~ and relative paths.
185
183
  outputLogDir: ~/handfree-logs
186
184
 
187
- servers:
188
- server_name:
189
- # Required only for YAML-only servers. If a same-named Host exists in
190
- # ~/.ssh/config, these fields are optional overrides.
191
- host: 192.168.1.1
192
- port: 22
193
- 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
194
192
 
195
- # Auth: use ONE of these. Omit agent to use SSH_AUTH_SOCK when present;
196
- # set agent only when you need a specific socket path.
197
- password: xxx
198
- privateKey: ~/.ssh/id_rsa
199
- agent: /path/to/ssh-agent.sock
200
- 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
201
199
 
202
- # Network
203
- 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
+
204
204
 
205
- # Security (regex patterns)
206
- whitelist: # Only allow matching commands
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
207
209
  - "^ls.*$"
208
210
  - "^cat.*$"
209
211
  blacklist: # Block matching commands
210
- - "^rm -rf.*$"
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 whitelist
229
+ ### Security note: command policy
228
230
 
229
- If a server's YAML omits `whitelist:` (or sets it to an empty list), `execute-command` falls back to a built-in default whitelist. That default covers common read-mostly tools like `git .*`, `curl .*`, `find .*`, `awk .*`, `sed .*` broad enough that a determined LLM could write to disk via `awk 'BEGIN{system(...)}'` or similar. It is **strongly recommended to provide an explicit per-server `whitelist:`** narrowed to the commands you actually need. Startup logs a warning whenever a server is running on the default whitelist. See `show-whitelist` to inspect the effective whitelist at runtime.
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 @@ If a server's YAML omits `whitelist:` (or sets it to an empty list), `execute-co
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
- - **Whitelist everything**: Define exactly what commands are allowed
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,8 +133,17 @@ 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;
138
+ if (serverConfig.commandMode !== undefined) {
139
+ if (serverConfig.commandMode !== "blacklist" && serverConfig.commandMode !== "whitelist") {
140
+ throw new Error(`Server '${name}': 'commandMode' must be 'blacklist' or 'whitelist'`);
141
+ }
142
+ merged.commandMode = serverConfig.commandMode;
143
+ }
133
144
  if (serverConfig.whitelist !== undefined) {
134
145
  merged.commandWhitelist = serverConfig.whitelist ?? undefined;
146
+ merged.commandMode ??= "whitelist";
135
147
  }
136
148
  if (serverConfig.blacklist !== undefined) {
137
149
  merged.commandBlacklist = serverConfig.blacklist ?? undefined;
@@ -159,6 +171,8 @@ function mergeConfigMaps(sshConfigs, yamlConfigs) {
159
171
  name,
160
172
  allowedRemoteDirectories: yamlConfig.allowedRemoteDirectories ?? merged[name]?.allowedRemoteDirectories,
161
173
  allowedLocalDirectories: yamlConfig.allowedLocalDirectories ?? merged[name]?.allowedLocalDirectories,
174
+ jumpHost: yamlConfig.jumpHost ?? merged[name]?.jumpHost,
175
+ commandMode: yamlConfig.commandMode ?? merged[name]?.commandMode,
162
176
  commandWhitelist: yamlConfig.commandWhitelist ?? merged[name]?.commandWhitelist,
163
177
  commandBlacklist: yamlConfig.commandBlacklist ?? merged[name]?.commandBlacklist,
164
178
  safeDirectory: yamlConfig.safeDirectory ?? merged[name]?.safeDirectory,
@@ -186,6 +200,7 @@ function validateMergedConfigs(configs) {
186
200
  throw new Error(`Server '${name}': either 'password', 'privateKey', 'agent', or OpenSSH/default authentication is required`);
187
201
  }
188
202
  }
203
+ validateJumpHosts(configs);
189
204
  }
190
205
  function resolveSshConfigEnabled(options, yamlParsed) {
191
206
  if (options.loadUserSshConfig !== undefined) {
@@ -269,6 +284,51 @@ export function getLoadUserSshConfigFlag(args) {
269
284
  export function getPreConnectFlag(args) {
270
285
  return args.includes("--pre-connect");
271
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
+ }
272
332
  /**
273
333
  * Normalize and validate allowedRemoteDirectories.
274
334
  * Each entry must be an absolute POSIX path. Trailing slashes are stripped
@@ -1,25 +1,25 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.4.0",
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
 
7
7
  Recommended workflow:
8
8
  1. Call list-servers first to discover which server names are available, enabled, and currently connected.
9
- 2. Use show-whitelist before execute-command when you are unsure whether a command is allowed.
10
- 3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands such as "cmd1 && cmd2" may be rejected by whitelist rules 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
-
9
+ 2. Use show-whitelist before execute-command when you are unsure which command policy is active.
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
+
20
20
  Behavior notes:
21
21
  - A tool may automatically establish the SSH connection on first use.
22
- - Command execution is restricted by the configured whitelist and blacklist. If a command is rejected, inspect the whitelist rather than retrying the same command repeatedly.
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.
23
23
  - Some commands return no output on success; this is normal.
24
24
  - File transfer tools use SFTP and can fail if the remote path is missing or permissions are insufficient.
25
25
  - OpenSSH/YAML config changes are hot-reloaded without restarting the server. Connection-field changes close the old client so the next tool call reconnects with fresh settings.`;
@@ -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 = [
@@ -148,11 +148,6 @@ export class SshMcpServer {
148
148
  const parsedArgs = this.loadConfig();
149
149
  this.sshManager.setConfig(parsedArgs.configs, parsedArgs.enabledServers);
150
150
  this.sshManager.setOutputLogRoot(parsedArgs.outputLogDir);
151
- // Security warning
152
- const allConfigs = Object.values(parsedArgs.configs);
153
- if (allConfigs.some((c) => !c.commandWhitelist || c.commandWhitelist.length === 0)) {
154
- Logger.log("WARNING: Running without a command whitelist is strongly discouraged. Please configure a whitelist to restrict the commands that can be executed.", "info");
155
- }
156
151
  // Pre-connect to enabled servers if flag is set
157
152
  if (parsedArgs.preConnect) {
158
153
  Logger.log("Pre-connecting to enabled SSH servers...", "info");