@aaarc/handfree-ssh-mcp 1.0.1 → 1.0.3
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 +177 -144
- package/build/config/config-loader.js +52 -0
- package/build/config/server.js +10 -10
- package/build/config/ssh-config-loader.js +45 -2
- package/build/services/ssh-connection-manager.js +249 -10
- package/build/tools/list-servers.js +6 -4
- package/package.json +24 -24
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 a connection-level field, so it hot-reloads. Editing a server's `jumpHost` (or any hop's connection fields) in `servers.yaml` resets the affected connection on the fly — the change takes effect on the next tool call, no restart needed. This is chain-aware: if an intermediate hop's host / port / credentials change, every target that tunnels through it is reset too.
|
|
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
|
package/build/config/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const SERVER_CONFIG = {
|
|
2
2
|
name: "ssh-mcp-server",
|
|
3
|
-
version: "1.0.
|
|
3
|
+
version: "1.0.3",
|
|
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
|
|
@@ -110,18 +116,52 @@ export class SSHConnectionManager {
|
|
|
110
116
|
*/
|
|
111
117
|
replaceConfig(configs, enabledServers) {
|
|
112
118
|
const previous = this.configs;
|
|
113
|
-
|
|
119
|
+
// Pass 1: servers whose own connection fields changed (or that vanished).
|
|
120
|
+
const directlyChanged = new Set();
|
|
114
121
|
for (const [name, oldConfig] of Object.entries(previous)) {
|
|
115
122
|
const nextConfig = configs[name];
|
|
116
123
|
if (!nextConfig || this.connectionFieldsChanged(oldConfig, nextConfig)) {
|
|
117
|
-
|
|
118
|
-
this.statusCache.delete(name);
|
|
119
|
-
changedConnections.push(name);
|
|
124
|
+
directlyChanged.add(name);
|
|
120
125
|
}
|
|
121
126
|
}
|
|
127
|
+
// Pass 2: a target must ALSO reset if any hop in its jump chain directly
|
|
128
|
+
// changed — otherwise it keeps tunneling through a stale intermediate hop.
|
|
129
|
+
// Walk both the old and new chains so topology shifts (added/removed hops)
|
|
130
|
+
// are covered too.
|
|
131
|
+
const toReset = new Set(directlyChanged);
|
|
132
|
+
for (const name of Object.keys(previous)) {
|
|
133
|
+
if (toReset.has(name))
|
|
134
|
+
continue;
|
|
135
|
+
if (this.jumpChainTouchesChanged(name, previous, directlyChanged) ||
|
|
136
|
+
this.jumpChainTouchesChanged(name, configs, directlyChanged)) {
|
|
137
|
+
toReset.add(name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const name of toReset) {
|
|
141
|
+
this.closeClient(name, true);
|
|
142
|
+
this.statusCache.delete(name);
|
|
143
|
+
}
|
|
122
144
|
this.setConfig(configs, enabledServers);
|
|
123
145
|
Logger.log(`Hot-reloaded SSH config: ${Object.keys(configs).length} server(s), ` +
|
|
124
|
-
`${
|
|
146
|
+
`${toReset.size} connection(s) reset`, "info");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Walk `name`'s jump chain in `configMap` and report whether any hop is in the
|
|
150
|
+
* `changed` set. Guards against cycles defensively (config load rejects them).
|
|
151
|
+
* @private
|
|
152
|
+
*/
|
|
153
|
+
jumpChainTouchesChanged(name, configMap, changed) {
|
|
154
|
+
const seen = new Set([name]);
|
|
155
|
+
let hop = configMap[name]?.jumpHost;
|
|
156
|
+
while (hop !== undefined) {
|
|
157
|
+
if (changed.has(hop))
|
|
158
|
+
return true;
|
|
159
|
+
if (seen.has(hop))
|
|
160
|
+
break;
|
|
161
|
+
seen.add(hop);
|
|
162
|
+
hop = configMap[hop]?.jumpHost;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
125
165
|
}
|
|
126
166
|
/**
|
|
127
167
|
* Set the root directory under which execute-command full-output logs are
|
|
@@ -165,6 +205,7 @@ export class SSHConnectionManager {
|
|
|
165
205
|
}
|
|
166
206
|
this.clients.delete(name);
|
|
167
207
|
}
|
|
208
|
+
this.teardownJumpChain(name);
|
|
168
209
|
this.connected.set(name, false);
|
|
169
210
|
this.connecting.delete(name);
|
|
170
211
|
}
|
|
@@ -335,6 +376,24 @@ export class SSHConnectionManager {
|
|
|
335
376
|
if (agent) {
|
|
336
377
|
sshConfig.agent = agent;
|
|
337
378
|
}
|
|
379
|
+
// Add jump-host tunnel if provided. Mutually exclusive with socksProxy
|
|
380
|
+
// (enforced at config load time).
|
|
381
|
+
if (config.jumpHost) {
|
|
382
|
+
try {
|
|
383
|
+
const sock = await this.openJumpTunnel(key, config);
|
|
384
|
+
sshConfig.sock = sock;
|
|
385
|
+
Logger.log(`Using jump host '${config.jumpHost}' for [${key}]`, "info");
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
// A multi-hop chain may have partially connected (e.g. an inner hop
|
|
389
|
+
// came up but a later hop or the final forwardOut failed) before
|
|
390
|
+
// this rejected. Those already-connected hops were recorded in
|
|
391
|
+
// jumpClients as each one came up, so tear them down now instead of
|
|
392
|
+
// leaking live SSH sessions until the next connect attempt.
|
|
393
|
+
this.teardownJumpChain(key);
|
|
394
|
+
return reject(new ToolError("SSH_CONNECTION_FAILED", `Failed to open jump tunnel for [${key}] via '${config.jumpHost}': ${err.message}`, true));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
338
397
|
// Add SOCKS proxy configuration if provided
|
|
339
398
|
if (config.socksProxy) {
|
|
340
399
|
try {
|
|
@@ -405,6 +464,162 @@ export class SSHConnectionManager {
|
|
|
405
464
|
}
|
|
406
465
|
this.clients.set(key, client);
|
|
407
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Open a TCP tunnel from the target's jump chain to `config.host:config.port`
|
|
469
|
+
* and return the duplex stream to be used as `sock` for the target SSH client.
|
|
470
|
+
*
|
|
471
|
+
* Supports chained jumps to any depth: `target -> J1 -> J2 -> ...` where each
|
|
472
|
+
* hop's `jumpHost` names the next hop. The chain is built innermost-first —
|
|
473
|
+
* the deepest, directly-reachable hop connects normally, and each outer hop is
|
|
474
|
+
* connected through the previous hop's forwarded stream.
|
|
475
|
+
*
|
|
476
|
+
* Every hop's SSH client is cached (in order) under the target key so the whole
|
|
477
|
+
* chain can be torn down with the target connection. A hop's own client tracked
|
|
478
|
+
* in `this.clients` is intentionally NOT reused — jump usage and direct tool
|
|
479
|
+
* calls against a bastion stay isolated.
|
|
480
|
+
* @private
|
|
481
|
+
*/
|
|
482
|
+
async openJumpTunnel(targetKey, config) {
|
|
483
|
+
// Tear down any stale jump chain for this target before opening a new one.
|
|
484
|
+
this.teardownJumpChain(targetKey);
|
|
485
|
+
this.jumpClients.set(targetKey, []);
|
|
486
|
+
const jumpClient = await this.connectJumpChain(targetKey, config.jumpHost);
|
|
487
|
+
return this.forwardOutStream(jumpClient, config.host, config.port);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Recursively connect the SSH client for `jumpName`, tunneling through its own
|
|
491
|
+
* `jumpHost` first when set. Each connected hop is appended (innermost-first)
|
|
492
|
+
* to the target's jump-client chain. Returns the connected client for this hop.
|
|
493
|
+
* @private
|
|
494
|
+
*/
|
|
495
|
+
async connectJumpChain(targetKey, jumpName) {
|
|
496
|
+
const jumpConfig = this.configs[jumpName];
|
|
497
|
+
if (!jumpConfig) {
|
|
498
|
+
// Should be caught at config load, but guard at runtime too.
|
|
499
|
+
throw new Error(`jump host '${jumpName}' not found in config`);
|
|
500
|
+
}
|
|
501
|
+
// If this hop is itself reached through another jump, build that inner
|
|
502
|
+
// tunnel first and hand its stream to this hop as `sock`.
|
|
503
|
+
let sock;
|
|
504
|
+
if (jumpConfig.jumpHost) {
|
|
505
|
+
const innerClient = await this.connectJumpChain(targetKey, jumpConfig.jumpHost);
|
|
506
|
+
sock = await this.forwardOutStream(innerClient, jumpConfig.host, jumpConfig.port);
|
|
507
|
+
}
|
|
508
|
+
const jumpClient = await this.connectJumpClient(targetKey, jumpName, jumpConfig, sock);
|
|
509
|
+
const chain = this.jumpClients.get(targetKey);
|
|
510
|
+
if (chain) {
|
|
511
|
+
chain.push(jumpClient);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
this.jumpClients.set(targetKey, [jumpClient]);
|
|
515
|
+
}
|
|
516
|
+
return jumpClient;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Connect a single jump-host SSH client, optionally through `sock` (the stream
|
|
520
|
+
* from the previous hop). Wires a close handler that tears the whole target
|
|
521
|
+
* chain down so a dead hop surfaces as a clear failure on the next op.
|
|
522
|
+
* @private
|
|
523
|
+
*/
|
|
524
|
+
connectJumpClient(targetKey, jumpName, jumpConfig, sock) {
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
const jumpClient = new Client();
|
|
527
|
+
jumpClient.on("ready", () => resolve(jumpClient));
|
|
528
|
+
jumpClient.on("error", (err) => {
|
|
529
|
+
reject(new Error(`jump SSH connect failed for '${jumpName}': ${err.message}`));
|
|
530
|
+
});
|
|
531
|
+
jumpClient.on("close", () => {
|
|
532
|
+
// If any hop dies, kill the target too so callers get a clear failure on
|
|
533
|
+
// the next op and reconnect through a fresh chain.
|
|
534
|
+
this.teardownTargetViaJump(targetKey);
|
|
535
|
+
});
|
|
536
|
+
const jumpSsh = {
|
|
537
|
+
host: jumpConfig.host,
|
|
538
|
+
port: jumpConfig.port,
|
|
539
|
+
username: jumpConfig.username,
|
|
540
|
+
};
|
|
541
|
+
if (sock) {
|
|
542
|
+
jumpSsh.sock = sock;
|
|
543
|
+
}
|
|
544
|
+
const jumpAgent = jumpConfig.agent === false
|
|
545
|
+
? undefined
|
|
546
|
+
: jumpConfig.agent || (jumpConfig.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
|
|
547
|
+
if (jumpAgent) {
|
|
548
|
+
jumpSsh.agent = jumpAgent;
|
|
549
|
+
}
|
|
550
|
+
if (jumpConfig.privateKey) {
|
|
551
|
+
try {
|
|
552
|
+
jumpSsh.privateKey = fs.readFileSync(jumpConfig.privateKey, "utf8");
|
|
553
|
+
if (jumpConfig.passphrase)
|
|
554
|
+
jumpSsh.passphrase = jumpConfig.passphrase;
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
return reject(new Error(`read jump private key failed: ${err.message}`));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
else if (jumpConfig.password) {
|
|
561
|
+
jumpSsh.password = jumpConfig.password;
|
|
562
|
+
}
|
|
563
|
+
else if (jumpAgent || jumpConfig.authOptional) {
|
|
564
|
+
Logger.log(`Using SSH agent/default authentication for jump host '${jumpName}'`, "info");
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
return reject(new Error(`jump host '${jumpName}' has no password, privateKey, or default authentication`));
|
|
568
|
+
}
|
|
569
|
+
jumpClient.connect(jumpSsh);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Open a forwarded TCP stream from `client` to `host:port`.
|
|
574
|
+
* @private
|
|
575
|
+
*/
|
|
576
|
+
forwardOutStream(client, host, port) {
|
|
577
|
+
return new Promise((resolve, reject) => {
|
|
578
|
+
client.forwardOut("127.0.0.1", 0, host, port, (err, ch) => {
|
|
579
|
+
if (err)
|
|
580
|
+
return reject(err);
|
|
581
|
+
resolve(ch);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* End and forget every jump client in a target's chain (no target teardown).
|
|
587
|
+
* @private
|
|
588
|
+
*/
|
|
589
|
+
teardownJumpChain(targetKey) {
|
|
590
|
+
const chain = this.jumpClients.get(targetKey);
|
|
591
|
+
if (!chain) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
this.jumpClients.delete(targetKey);
|
|
595
|
+
for (const client of chain) {
|
|
596
|
+
try {
|
|
597
|
+
client.end();
|
|
598
|
+
}
|
|
599
|
+
catch { /* ignore */ }
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Tear the target connection and its whole jump chain down together. Idempotent
|
|
604
|
+
* so re-entrant close events (ending one hop closes the next) settle quietly.
|
|
605
|
+
* @private
|
|
606
|
+
*/
|
|
607
|
+
teardownTargetViaJump(targetKey) {
|
|
608
|
+
const chain = this.jumpClients.get(targetKey);
|
|
609
|
+
const target = this.clients.get(targetKey);
|
|
610
|
+
if (!chain && !target) {
|
|
611
|
+
return; // already torn down
|
|
612
|
+
}
|
|
613
|
+
this.connected.set(targetKey, false);
|
|
614
|
+
if (target) {
|
|
615
|
+
this.clients.delete(targetKey);
|
|
616
|
+
try {
|
|
617
|
+
target.end();
|
|
618
|
+
}
|
|
619
|
+
catch { /* ignore */ }
|
|
620
|
+
}
|
|
621
|
+
this.teardownJumpChain(targetKey);
|
|
622
|
+
}
|
|
408
623
|
/**
|
|
409
624
|
* Get SSH Client with specified name
|
|
410
625
|
*/
|
|
@@ -1332,23 +1547,47 @@ export class SSHConnectionManager {
|
|
|
1332
1547
|
}
|
|
1333
1548
|
this.clients.clear();
|
|
1334
1549
|
}
|
|
1550
|
+
if (this.jumpClients.size > 0) {
|
|
1551
|
+
for (const chain of this.jumpClients.values()) {
|
|
1552
|
+
for (const client of chain) {
|
|
1553
|
+
try {
|
|
1554
|
+
client.end();
|
|
1555
|
+
}
|
|
1556
|
+
catch { /* ignore */ }
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
this.jumpClients.clear();
|
|
1560
|
+
}
|
|
1335
1561
|
}
|
|
1336
1562
|
/**
|
|
1337
|
-
* Get basic information of all configured servers
|
|
1563
|
+
* Get basic information of all configured servers.
|
|
1564
|
+
*
|
|
1565
|
+
* Lean by default — returns only identity + connection state. Pass
|
|
1566
|
+
* `verbose: true` to include the cached `status` block (hostname, CPU,
|
|
1567
|
+
* memory, disk, GPUs, etc.). The status block is large and rarely useful
|
|
1568
|
+
* for routing decisions, so the LLM should opt in.
|
|
1338
1569
|
*/
|
|
1339
|
-
getAllServerInfos() {
|
|
1570
|
+
getAllServerInfos(opts = {}) {
|
|
1571
|
+
const verbose = opts.verbose === true;
|
|
1340
1572
|
return Object.keys(this.configs).map((key) => {
|
|
1341
1573
|
const config = this.configs[key];
|
|
1342
|
-
const
|
|
1343
|
-
return {
|
|
1574
|
+
const info = {
|
|
1344
1575
|
name: key,
|
|
1345
1576
|
host: config.host,
|
|
1346
1577
|
port: config.port,
|
|
1347
1578
|
username: config.username,
|
|
1348
1579
|
connected: this.connected.get(key) === true,
|
|
1349
1580
|
enabled: this.isServerEnabled(key),
|
|
1350
|
-
status: status,
|
|
1351
1581
|
};
|
|
1582
|
+
if (config.jumpHost) {
|
|
1583
|
+
info.jumpHost = config.jumpHost;
|
|
1584
|
+
}
|
|
1585
|
+
if (verbose) {
|
|
1586
|
+
const status = this.statusCache.get(key);
|
|
1587
|
+
if (status)
|
|
1588
|
+
info.status = status;
|
|
1589
|
+
}
|
|
1590
|
+
return info;
|
|
1352
1591
|
});
|
|
1353
1592
|
}
|
|
1354
1593
|
/**
|
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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.
|
|
2
|
+
"name": "@aaarc/handfree-ssh-mcp",
|
|
3
|
+
"version": "1.0.3",
|
|
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
|
+
}
|