@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 +166 -133
- package/build/config/config-loader.js +60 -0
- package/build/config/server.js +13 -13
- package/build/config/ssh-config-loader.js +45 -2
- package/build/core/mcp-server.js +0 -5
- package/build/services/ssh-connection-manager.js +274 -164
- package/build/tools/execute-command.js +1 -1
- package/build/tools/help.js +4 -4
- package/build/tools/list-servers.js +6 -4
- package/build/tools/show-whitelist.js +67 -42
- package/package.json +20 -11
- package/build/tests/command-validation.test.js +0 -876
- package/build/tests/config.test.js +0 -617
- package/build/tests/integration.test.js +0 -348
- package/build/tests/output-collector.test.js +0 -70
- package/build/tests/output-log-writer.test.js +0 -205
- package/build/tests/recent-fixes.test.js +0 -229
- package/build/tests/tool-error.test.js +0 -26
- package/build/tests/tools.test.js +0 -486
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
|
|
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
|
-
|
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
- "^
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
206
|
-
|
|
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
|
-
- "^
|
|
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
|
|
229
|
+
### Security note: command policy
|
|
228
230
|
|
|
229
|
-
|
|
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
|
-
- **
|
|
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
|
package/build/config/server.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
export const SERVER_CONFIG = {
|
|
2
2
|
name: "ssh-mcp-server",
|
|
3
|
-
version: "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
|
|
|
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
|
|
10
|
-
3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands
|
|
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
|
|
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 = [
|
package/build/core/mcp-server.js
CHANGED
|
@@ -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");
|