@aaarc/handfree-ssh-mcp 1.0.0
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/LICENSE +16 -0
- package/README.md +329 -0
- package/build/config/config-loader.js +329 -0
- package/build/config/index.js +5 -0
- package/build/config/server.js +25 -0
- package/build/config/ssh-config-loader.js +376 -0
- package/build/core/mcp-server.js +176 -0
- package/build/index.js +11 -0
- package/build/models/types.js +1 -0
- package/build/services/ssh-connection-manager.js +1726 -0
- package/build/tests/command-validation.test.js +876 -0
- package/build/tests/config.test.js +617 -0
- package/build/tests/integration.test.js +348 -0
- package/build/tests/output-collector.test.js +70 -0
- package/build/tests/output-log-writer.test.js +205 -0
- package/build/tests/recent-fixes.test.js +229 -0
- package/build/tests/tool-error.test.js +26 -0
- package/build/tests/tools.test.js +486 -0
- package/build/tools/download.js +31 -0
- package/build/tools/execute-command.js +80 -0
- package/build/tools/help.js +147 -0
- package/build/tools/index.js +20 -0
- package/build/tools/list-servers.js +36 -0
- package/build/tools/show-whitelist.js +216 -0
- package/build/tools/transfer.js +96 -0
- package/build/tools/upload.js +36 -0
- package/build/utils/logger.js +27 -0
- package/build/utils/output-collector.js +76 -0
- package/build/utils/output-log-writer.js +232 -0
- package/build/utils/status-collector.js +212 -0
- package/build/utils/tool-error.js +26 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 junki.cn (original ssh-mcp-server)
|
|
4
|
+
Copyright (c) 2026 woqucc (handfree-ssh-mcp modifications)
|
|
5
|
+
|
|
6
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
7
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
8
|
+
copyright notice and this permission notice appear in all copies.
|
|
9
|
+
|
|
10
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
11
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
12
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
13
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
14
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
15
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
16
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# ๐ค handfree-ssh-mcp
|
|
2
|
+
|
|
3
|
+
**Configure once. Let the LLM handle the rest.**
|
|
4
|
+
|
|
5
|
+
> ๐งช 99.9% AI-coded [Include this Readme]. No artisanal hand-crafted code here.
|
|
6
|
+
|
|
7
|
+
A hands-free SSH automation tool via MCP. Fork of [ssh-mcp-server](https://github.com/classfang/ssh-mcp-server) designed for autonomous AI agent operations.
|
|
8
|
+
|
|
9
|
+
## ๐ฏ Philosophy
|
|
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
|
|
18
|
+
|
|
19
|
+
Less manual interventions. Just autonomous SSH execution with safeguards.
|
|
20
|
+
|
|
21
|
+
## โจ What's New
|
|
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** |
|
|
30
|
+
|
|
31
|
+
## ๐ Quick Start
|
|
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
|
|
75
|
+
|
|
76
|
+
prod:
|
|
77
|
+
host: XXXXX
|
|
78
|
+
port: 22
|
|
79
|
+
username: deploy
|
|
80
|
+
privateKey: ~/.ssh/id_rsa
|
|
81
|
+
whitelist:
|
|
82
|
+
- "^ls.*$" # Read only
|
|
83
|
+
- "^cat.*$"
|
|
84
|
+
- "^tail.*$"
|
|
85
|
+
blacklist:
|
|
86
|
+
- "^rm.*$" # Never allow delete
|
|
87
|
+
- "^shutdown.*$"
|
|
88
|
+
- "^reboot.*$"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Add to MCP Config
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"mcpServers": {
|
|
96
|
+
"ssh": {
|
|
97
|
+
"command": "node",
|
|
98
|
+
"args": [
|
|
99
|
+
"/path/to/handfree-ssh-mcp/build/index.js",
|
|
100
|
+
"--config", "/path/to/servers.yaml",
|
|
101
|
+
"--enable-servers", "dev,prod"
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 4. Done. Let the LLM Work.
|
|
109
|
+
|
|
110
|
+
The AI can now execute commands on your servers. All within your defined security boundaries.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## ๐ ๏ธ Available Tools
|
|
115
|
+
|
|
116
|
+
| Tool | Description |
|
|
117
|
+
|------|-------------|
|
|
118
|
+
| `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 |
|
|
120
|
+
| `upload` | Upload local file to a remote server (CRLF-fix for shell scripts, skip-if-identical) |
|
|
121
|
+
| `download` | Download remote file to local disk |
|
|
122
|
+
| `transfer` | Unified upload / download / server-to-server relay (`mode`: `upload` / `download` / `relay`, optional `recursive`, optional `skipIfIdentical`) |
|
|
123
|
+
| `list-servers` | List configured (enabled) servers |
|
|
124
|
+
| `help` | Self-describing help text for the MCP client |
|
|
125
|
+
|
|
126
|
+
### show-whitelist
|
|
127
|
+
|
|
128
|
+
**Use this first!** Let the LLM know what it's allowed to do:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"tool": "show-whitelist",
|
|
133
|
+
"params": {
|
|
134
|
+
"connectionName": "dev"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Returns a formatted list of allowed command patterns with examples.
|
|
140
|
+
|
|
141
|
+
### execute-command
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"tool": "execute-command",
|
|
146
|
+
"params": {
|
|
147
|
+
"cmdString": "docker ps",
|
|
148
|
+
"connectionName": "dev",
|
|
149
|
+
"timeout": 300000,
|
|
150
|
+
"stream": true
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
| Param | Required | Default | Description |
|
|
156
|
+
|-------|----------|---------|-------------|
|
|
157
|
+
| `cmdString` | โ
| - | Command to execute |
|
|
158
|
+
| `connectionName` | โ | First in `--enable-servers` | Which server to run on |
|
|
159
|
+
| `timeout` | โ | 300000ms (stream) / 30000ms (no stream) | Timeout in ms |
|
|
160
|
+
| `stream` | โ | `true` | Real-time streaming output |
|
|
161
|
+
|
|
162
|
+
**When to use `stream: false`:**
|
|
163
|
+
- Simple, fast commands (ls, pwd, cat)
|
|
164
|
+
- When you don't need real-time feedback
|
|
165
|
+
|
|
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
|
|
181
|
+
|
|
182
|
+
# Optional: root dir for execute-command full-output logs.
|
|
183
|
+
# Per-call logs land under <outputLogDir>/<server>/<user>/<ts>-<pid>-<rand>.log.
|
|
184
|
+
# Defaults to <cwd>/.handfree-output when unset. Supports ~ and relative paths.
|
|
185
|
+
outputLogDir: ~/handfree-logs
|
|
186
|
+
|
|
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
|
|
194
|
+
|
|
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
|
|
201
|
+
|
|
202
|
+
# Network
|
|
203
|
+
socksProxy: socks5://host:port
|
|
204
|
+
|
|
205
|
+
# Security (regex patterns)
|
|
206
|
+
whitelist: # Only allow matching commands
|
|
207
|
+
- "^ls.*$"
|
|
208
|
+
- "^cat.*$"
|
|
209
|
+
blacklist: # Block matching commands
|
|
210
|
+
- "^rm -rf.*$"
|
|
211
|
+
|
|
212
|
+
# Safe directory for destructive commands (rm, etc.)
|
|
213
|
+
safeDirectory: /home/user # rm allowed only within this path
|
|
214
|
+
|
|
215
|
+
# SFTP path policy โ applies ONLY to upload / download / transfer.
|
|
216
|
+
# execute-command is NOT affected by these lists.
|
|
217
|
+
# If `allowedRemoteDirectories` is unset or empty, SFTP is DISABLED for the server.
|
|
218
|
+
allowedRemoteDirectories:
|
|
219
|
+
- /home/user
|
|
220
|
+
- /tmp
|
|
221
|
+
# Extra local dirs allowed as SFTP source/target.
|
|
222
|
+
# The MCP working directory is always permitted implicitly.
|
|
223
|
+
allowedLocalDirectories:
|
|
224
|
+
- /path/to/extra/local/dir
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Security note: command whitelist
|
|
228
|
+
|
|
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.
|
|
230
|
+
|
|
231
|
+
### SFTP path policy
|
|
232
|
+
|
|
233
|
+
| Field | Scope | Default behavior |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| `allowedRemoteDirectories` | `upload` / `download` / `transfer` only | **Unset = SFTP disabled.** Must list absolute POSIX directories. |
|
|
236
|
+
| `allowedLocalDirectories` | `upload` / `download` only | Unset = only the MCP working directory is allowed. |
|
|
237
|
+
|
|
238
|
+
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
|
+
|
|
240
|
+
### Connection lifecycle (connect / reconnect)
|
|
241
|
+
|
|
242
|
+
- **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.
|
|
243
|
+
- **Auto-reconnect on `execute-command`.** Every command runs inside a retry loop (default 3 attempts: 1 initial + 2 retries) with exponential backoff. If the underlying error matches a connection-shaped pattern (`econnreset`, `epipe`, `socket`, `closed`, `channel`, `end of stream`, or a `SSH_CONNECTION_FAILED` ToolError), the manager closes the dead client, reconnects, and retries the command. Non-connection errors (permission denied, validation, command-not-found) are returned immediately without retry.
|
|
244
|
+
- **SFTP transfers do NOT auto-retry.** `upload` / `download` / `transfer` lazy-connect via the same path, but a mid-transfer disconnect surfaces as a single failure โ re-issue the call manually. This is a known gap.
|
|
245
|
+
- **No background keepalive or health probe.** Dead connections are only discovered on the next tool call. If you idle for hours through a NATed network, expect the first call after the gap to fail-then-reconnect on its own (you'll see one retry in the logs).
|
|
246
|
+
|
|
247
|
+
### Upload behaviors
|
|
248
|
+
|
|
249
|
+
- **CRLF auto-fix for shell scripts.** When uploading a `.sh`, `.bash`, or `.zsh` file, any `\r\n` line endings are automatically converted to `\n` before the bytes are sent. The response notes when this happens and how many line endings were rewritten. The local file on disk is left untouched.
|
|
250
|
+
- **Skip-if-identical (default on).** Before transferring, `upload` checks whether the remote file already matches the local payload. Files โค 256 MiB are compared byte-for-byte; larger files are compared via MD5 (using `md5sum` on the remote host). **Shell scripts (`.sh` / `.bash` / `.zsh`) are compared in a line-ending-agnostic way โ both sides are LF-normalized before the comparison, so a CRLF-only diff is treated as identical and the upload is still skipped.** If they match, the upload is skipped and the response says so. Pass `skipIfIdentical: false` to force a re-upload. Recursive `transfer` (`mode: upload`, `recursive: true`) applies the same check per file.
|
|
251
|
+
- **Relay skip-if-identical (default on).** `transfer mode=relay` does the same check between two remote servers: matching size on both sides plus matching `md5sum` skips the transfer. If `md5sum` is missing on either server, the check falls back to a normal transfer.
|
|
252
|
+
|
|
253
|
+
### `execute-command` output capping & full logs
|
|
254
|
+
|
|
255
|
+
`execute-command` always persists the FULL stdout and stderr of every invocation to a local plain-text log file, then returns only a tail-truncated view to the caller. This keeps the LLM-visible payload small without losing any data.
|
|
256
|
+
|
|
257
|
+
- **Default cap:** 65536 bytes (64 KiB) per stream, tail-only. Set `maxOutputBytes` on the tool call to raise/lower the cap.
|
|
258
|
+
- **Streaming is unaffected.** When `stream: true`, every chunk still reaches the progress channel live; only the final aggregated return value is capped.
|
|
259
|
+
- **Log path:** `<outputLogDir>/<server-name>/<username>/<timestamp>-<pid>-<rand>.log`. Default `outputLogDir` is `<cwd>/.handfree-output`. Override with a top-level `outputLogDir:` entry in `servers.yaml` (supports `~` and relative paths).
|
|
260
|
+
- **Log format:** plain UTF-8 text with `=== META ===` / `=== STDOUT ===` / `=== STDERR ===` / `=== END ===` separators. Tail with `tail -f` while a command is running? Not yet โ the file is finalized on close.
|
|
261
|
+
- **Truncation marker:** when output is trimmed, the returned text starts with an `[OUTPUT TRUNCATED]` header that lists total bytes, bytes dropped, and the on-disk log path. When output fits within the cap, no header is added and no log path is reported (the file is still written).
|
|
262
|
+
- **Retention:** none. Manage cleanup yourself (e.g. `find .handfree-output -mtime +7 -delete`).
|
|
263
|
+
- **Failures are non-fatal.** If the log file cannot be written, the command still completes and the failure is logged as an MCP-side warning.
|
|
264
|
+
|
|
265
|
+
```yaml
|
|
266
|
+
# servers.yaml
|
|
267
|
+
outputLogDir: ~/handfree-logs # optional; defaults to <cwd>/.handfree-output
|
|
268
|
+
servers:
|
|
269
|
+
dev: { ... }
|
|
270
|
+
```
|
|
271
|
+
|
|
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:
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{
|
|
296
|
+
"args": [
|
|
297
|
+
"--config", "servers.yaml",
|
|
298
|
+
"--enable-servers", "dev,staging"
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## ๐ก๏ธ Security
|
|
304
|
+
|
|
305
|
+
- **Whitelist everything**: Define exactly what commands are allowed
|
|
306
|
+
- **Keep secrets safe**: Add `servers.yaml` to `.gitignore`
|
|
307
|
+
- **Per-server control**: Prod can be locked down, dev can be permissive
|
|
308
|
+
|
|
309
|
+
## ๐ TODO & PLAN
|
|
310
|
+
|
|
311
|
+
### High Priority
|
|
312
|
+
- [x] **Complete test coverage**: Add tests for `list-servers`, `upload`, `download`, `show-whitelist`, streaming mode, timeout/kill
|
|
313
|
+
- [ ] **Session support**: Add tools to list/create/resume/close persistent SSH sessions (for multi-command workflows)
|
|
314
|
+
- [ ] **LLM-based whitelist**: Allow LLM to propose commands, with human approval adding to dynamic whitelist
|
|
315
|
+
|
|
316
|
+
### Nice to Have
|
|
317
|
+
- [x] **Command history / output archive**: full stdout/stderr of every `execute-command` is persisted under `<outputLogDir>/<server>/<user>/*.log`
|
|
318
|
+
- [x] **Multi-command execution**: Execute multiple commands in sequence with `&&` or `;` safely (fixed: `2>/dev/null` now allowed)
|
|
319
|
+
- [x] **Connection auto-recovery**: `execute-command` retries with exponential backoff and forced reconnect on connection-shaped errors
|
|
320
|
+
- [ ] **SFTP retry parity**: extend the retry-with-reconnect loop to `upload` / `download` / `transfer`
|
|
321
|
+
- [ ] **TCP keepalive**: pass `keepaliveInterval` / `keepaliveCountMax` to `ssh2.Client` so half-open connections are detected without waiting for the next command
|
|
322
|
+
- [ ] **Server health check**: optional periodic ping to detect drops proactively
|
|
323
|
+
|
|
324
|
+
## ๐ License
|
|
325
|
+
|
|
326
|
+
ISC License
|
|
327
|
+
|
|
328
|
+
- Original work: ยฉ 2025 junki.cn ([ssh-mcp-server](https://github.com/classfang/ssh-mcp-server))
|
|
329
|
+
- Modifications: ยฉ 2026 woqucc (handfree-ssh-mcp)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import yaml from "js-yaml";
|
|
5
|
+
import { Logger } from "../utils/logger.js";
|
|
6
|
+
import { getDefaultUserSshConfigPath, loadSshConfigFiles, } from "./ssh-config-loader.js";
|
|
7
|
+
/**
|
|
8
|
+
* Expand ~ to home directory in paths
|
|
9
|
+
*/
|
|
10
|
+
function expandTilde(filePath) {
|
|
11
|
+
if (filePath.startsWith("~")) {
|
|
12
|
+
return path.join(os.homedir(), filePath.slice(1));
|
|
13
|
+
}
|
|
14
|
+
return filePath;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Load and parse YAML config file
|
|
18
|
+
*/
|
|
19
|
+
export function loadConfigFromYaml(configPath) {
|
|
20
|
+
return loadYamlConfig(configPath, { requireServers: true });
|
|
21
|
+
}
|
|
22
|
+
export function loadConfigFromSources(options = {}) {
|
|
23
|
+
const yamlConfigPath = options.yamlConfigPath ?? null;
|
|
24
|
+
const yamlParsed = yamlConfigPath
|
|
25
|
+
? loadYamlConfig(yamlConfigPath, { requireServers: false })
|
|
26
|
+
: null;
|
|
27
|
+
const resolvedYamlConfigPath = yamlConfigPath ? resolveConfigPath(yamlConfigPath) : undefined;
|
|
28
|
+
const sshConfigEnabled = resolveSshConfigEnabled(options, yamlParsed);
|
|
29
|
+
const sshConfigPaths = sshConfigEnabled
|
|
30
|
+
? resolveSshConfigPaths(options.sshConfigPaths, yamlParsed)
|
|
31
|
+
: [];
|
|
32
|
+
const sshConfigResult = sshConfigEnabled
|
|
33
|
+
? loadSshConfigFiles(sshConfigPaths)
|
|
34
|
+
: { configs: {}, files: [] };
|
|
35
|
+
const configs = mergeConfigMaps(sshConfigResult.configs, yamlParsed?.configs ?? {});
|
|
36
|
+
if (Object.keys(configs).length === 0) {
|
|
37
|
+
throw new Error("No SSH servers loaded. Add Host entries to ~/.ssh/config, pass --ssh-config <path>, or provide --config <servers.yaml>.");
|
|
38
|
+
}
|
|
39
|
+
validateMergedConfigs(configs);
|
|
40
|
+
return {
|
|
41
|
+
configs,
|
|
42
|
+
preConnect: yamlParsed?.preConnect === true,
|
|
43
|
+
outputLogDir: yamlParsed?.outputLogDir,
|
|
44
|
+
watchPaths: [
|
|
45
|
+
...(resolvedYamlConfigPath ? [resolvedYamlConfigPath] : []),
|
|
46
|
+
...sshConfigResult.files,
|
|
47
|
+
],
|
|
48
|
+
loadedSshConfigPaths: sshConfigResult.files,
|
|
49
|
+
loadedYamlConfigPath: resolvedYamlConfigPath,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function loadYamlConfig(configPath, options) {
|
|
53
|
+
const expandedPath = expandTilde(configPath);
|
|
54
|
+
const absolutePath = resolveConfigPath(expandedPath);
|
|
55
|
+
Logger.log(`Loading config from: ${absolutePath}`, "info");
|
|
56
|
+
if (!fs.existsSync(absolutePath)) {
|
|
57
|
+
throw new Error(`Config file not found: ${absolutePath}`);
|
|
58
|
+
}
|
|
59
|
+
const fileContent = fs.readFileSync(absolutePath, "utf8");
|
|
60
|
+
let config;
|
|
61
|
+
try {
|
|
62
|
+
config = yaml.load(fileContent);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new Error(`Failed to parse YAML config: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
if (!config || (options.requireServers && !config.servers)) {
|
|
68
|
+
throw new Error("Invalid config: 'servers' section is required");
|
|
69
|
+
}
|
|
70
|
+
const configMap = {};
|
|
71
|
+
for (const [name, serverConfig] of Object.entries(config.servers ?? {})) {
|
|
72
|
+
configMap[name] = buildYamlServerConfig(name, serverConfig, {
|
|
73
|
+
allowPartial: !options.requireServers,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (options.requireServers && Object.keys(configMap).length === 0) {
|
|
77
|
+
throw new Error("No servers defined in config file");
|
|
78
|
+
}
|
|
79
|
+
Logger.log(`Total YAML servers loaded: ${Object.keys(configMap).length}`, "info");
|
|
80
|
+
// Resolve outputLogDir if provided. Default applied later (in ssh-connection-manager)
|
|
81
|
+
// so it always tracks the current cwd at execution time.
|
|
82
|
+
let outputLogDir;
|
|
83
|
+
if (config.outputLogDir !== undefined) {
|
|
84
|
+
if (typeof config.outputLogDir !== "string" || config.outputLogDir.length === 0) {
|
|
85
|
+
throw new Error("'outputLogDir' must be a non-empty string");
|
|
86
|
+
}
|
|
87
|
+
outputLogDir = path.resolve(expandTilde(config.outputLogDir));
|
|
88
|
+
Logger.log(`Output log dir (from YAML): ${outputLogDir}`, "info");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
configs: configMap,
|
|
92
|
+
preConnect: config.preConnect === true,
|
|
93
|
+
outputLogDir,
|
|
94
|
+
sshConfig: config.sshConfig,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function buildYamlServerConfig(name, serverConfig, options = {}) {
|
|
98
|
+
const allowPartial = options.allowPartial === true;
|
|
99
|
+
if (!allowPartial && !serverConfig.host) {
|
|
100
|
+
throw new Error(`Server '${name}': 'host' is required`);
|
|
101
|
+
}
|
|
102
|
+
if (!allowPartial && !serverConfig.username) {
|
|
103
|
+
throw new Error(`Server '${name}': 'username' is required`);
|
|
104
|
+
}
|
|
105
|
+
if (!allowPartial &&
|
|
106
|
+
!serverConfig.password &&
|
|
107
|
+
!serverConfig.privateKey &&
|
|
108
|
+
!serverConfig.agent) {
|
|
109
|
+
throw new Error(`Server '${name}': either 'password', 'privateKey', or 'agent' is required`);
|
|
110
|
+
}
|
|
111
|
+
const merged = { name };
|
|
112
|
+
const port = serverConfig.port ?? (allowPartial ? undefined : 22);
|
|
113
|
+
if (serverConfig.host !== undefined)
|
|
114
|
+
merged.host = serverConfig.host ?? undefined;
|
|
115
|
+
if (port !== undefined)
|
|
116
|
+
merged.port = port;
|
|
117
|
+
if (serverConfig.username !== undefined)
|
|
118
|
+
merged.username = serverConfig.username ?? undefined;
|
|
119
|
+
if (serverConfig.password !== undefined)
|
|
120
|
+
merged.password = serverConfig.password ?? undefined;
|
|
121
|
+
if (serverConfig.privateKey)
|
|
122
|
+
merged.privateKey = expandTilde(serverConfig.privateKey);
|
|
123
|
+
if (serverConfig.passphrase !== undefined)
|
|
124
|
+
merged.passphrase = serverConfig.passphrase ?? undefined;
|
|
125
|
+
if (serverConfig.agent !== undefined) {
|
|
126
|
+
merged.agent = serverConfig.agent ? expandTilde(serverConfig.agent) : undefined;
|
|
127
|
+
}
|
|
128
|
+
if (serverConfig.password || serverConfig.privateKey || serverConfig.agent) {
|
|
129
|
+
merged.authOptional = false;
|
|
130
|
+
}
|
|
131
|
+
if (serverConfig.socksProxy !== undefined)
|
|
132
|
+
merged.socksProxy = serverConfig.socksProxy ?? undefined;
|
|
133
|
+
if (serverConfig.whitelist !== undefined) {
|
|
134
|
+
merged.commandWhitelist = serverConfig.whitelist ?? undefined;
|
|
135
|
+
}
|
|
136
|
+
if (serverConfig.blacklist !== undefined) {
|
|
137
|
+
merged.commandBlacklist = serverConfig.blacklist ?? undefined;
|
|
138
|
+
}
|
|
139
|
+
if (serverConfig.safeDirectory !== undefined) {
|
|
140
|
+
merged.safeDirectory = serverConfig.safeDirectory ?? undefined;
|
|
141
|
+
}
|
|
142
|
+
if (serverConfig.allowedRemoteDirectories !== undefined) {
|
|
143
|
+
merged.allowedRemoteDirectories = normalizeAllowedRemoteDirectories(name, serverConfig.allowedRemoteDirectories);
|
|
144
|
+
}
|
|
145
|
+
if (serverConfig.allowedLocalDirectories !== undefined) {
|
|
146
|
+
merged.allowedLocalDirectories = normalizeAllowedLocalDirectories(name, serverConfig.allowedLocalDirectories);
|
|
147
|
+
}
|
|
148
|
+
if (!allowPartial) {
|
|
149
|
+
Logger.log(`Loaded server config: ${name} -> ${merged.username}@${merged.host}:${merged.port}`, "info");
|
|
150
|
+
}
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
function mergeConfigMaps(sshConfigs, yamlConfigs) {
|
|
154
|
+
const merged = { ...sshConfigs };
|
|
155
|
+
for (const [name, yamlConfig] of Object.entries(yamlConfigs)) {
|
|
156
|
+
merged[name] = {
|
|
157
|
+
...merged[name],
|
|
158
|
+
...yamlConfig,
|
|
159
|
+
name,
|
|
160
|
+
allowedRemoteDirectories: yamlConfig.allowedRemoteDirectories ?? merged[name]?.allowedRemoteDirectories,
|
|
161
|
+
allowedLocalDirectories: yamlConfig.allowedLocalDirectories ?? merged[name]?.allowedLocalDirectories,
|
|
162
|
+
commandWhitelist: yamlConfig.commandWhitelist ?? merged[name]?.commandWhitelist,
|
|
163
|
+
commandBlacklist: yamlConfig.commandBlacklist ?? merged[name]?.commandBlacklist,
|
|
164
|
+
safeDirectory: yamlConfig.safeDirectory ?? merged[name]?.safeDirectory,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
function validateMergedConfigs(configs) {
|
|
170
|
+
for (const [name, config] of Object.entries(configs)) {
|
|
171
|
+
if (typeof config.host !== "string" || config.host.length === 0) {
|
|
172
|
+
throw new Error(`Server '${name}': 'host' is required`);
|
|
173
|
+
}
|
|
174
|
+
if (typeof config.username !== "string" || config.username.length === 0) {
|
|
175
|
+
throw new Error(`Server '${name}': 'username' is required`);
|
|
176
|
+
}
|
|
177
|
+
if (!Number.isInteger(config.port) ||
|
|
178
|
+
config.port <= 0 ||
|
|
179
|
+
config.port > 65535) {
|
|
180
|
+
throw new Error(`Server '${name}': 'port' must be an integer from 1 to 65535`);
|
|
181
|
+
}
|
|
182
|
+
if (!config.authOptional &&
|
|
183
|
+
!config.password &&
|
|
184
|
+
!config.privateKey &&
|
|
185
|
+
!config.agent) {
|
|
186
|
+
throw new Error(`Server '${name}': either 'password', 'privateKey', 'agent', or OpenSSH/default authentication is required`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function resolveSshConfigEnabled(options, yamlParsed) {
|
|
191
|
+
if (options.loadUserSshConfig !== undefined) {
|
|
192
|
+
return options.loadUserSshConfig;
|
|
193
|
+
}
|
|
194
|
+
if (options.sshConfigPaths && options.sshConfigPaths.length > 0) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (typeof yamlParsed?.sshConfig === "boolean") {
|
|
198
|
+
return yamlParsed.sshConfig;
|
|
199
|
+
}
|
|
200
|
+
if (typeof yamlParsed?.sshConfig === "object" && yamlParsed.sshConfig.enabled !== undefined) {
|
|
201
|
+
return yamlParsed.sshConfig.enabled;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
function resolveSshConfigPaths(cliPaths, yamlParsed) {
|
|
206
|
+
if (cliPaths && cliPaths.length > 0) {
|
|
207
|
+
return cliPaths;
|
|
208
|
+
}
|
|
209
|
+
if (typeof yamlParsed?.sshConfig === "object" && yamlParsed.sshConfig.paths?.length) {
|
|
210
|
+
return yamlParsed.sshConfig.paths;
|
|
211
|
+
}
|
|
212
|
+
return [getDefaultUserSshConfigPath()];
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Check if --config argument is provided
|
|
216
|
+
*/
|
|
217
|
+
export function getConfigPath(args) {
|
|
218
|
+
const configIndex = args.indexOf("--config");
|
|
219
|
+
if (configIndex !== -1 && args[configIndex + 1]) {
|
|
220
|
+
return args[configIndex + 1];
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Get --enable-servers argument if provided
|
|
226
|
+
* Returns array of enabled server names, or null if not specified
|
|
227
|
+
*/
|
|
228
|
+
export function getEnabledServersArg(args) {
|
|
229
|
+
const index = args.indexOf("--enable-servers");
|
|
230
|
+
if (index !== -1 && args[index + 1]) {
|
|
231
|
+
return args[index + 1].split(",").map(s => s.trim()).filter(Boolean);
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Get optional --ssh-config paths. Accepts comma-separated values and can be repeated.
|
|
237
|
+
*/
|
|
238
|
+
export function getSshConfigPathsArg(args) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (let i = 0; i < args.length; i++) {
|
|
241
|
+
if (args[i] !== "--ssh-config")
|
|
242
|
+
continue;
|
|
243
|
+
const value = args[i + 1];
|
|
244
|
+
if (!value)
|
|
245
|
+
continue;
|
|
246
|
+
out.push(...value.split(",").map((s) => s.trim()).filter(Boolean));
|
|
247
|
+
i++;
|
|
248
|
+
}
|
|
249
|
+
return out.length > 0 ? out : null;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Disable automatic ~/.ssh/config loading.
|
|
253
|
+
*/
|
|
254
|
+
export function getNoSshConfigFlag(args) {
|
|
255
|
+
return args.includes("--no-ssh-config");
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Return the CLI override for user OpenSSH config loading.
|
|
259
|
+
* undefined means "let YAML/defaults decide"; false is the explicit
|
|
260
|
+
* --no-ssh-config override.
|
|
261
|
+
*/
|
|
262
|
+
export function getLoadUserSshConfigFlag(args) {
|
|
263
|
+
return getNoSshConfigFlag(args) ? false : undefined;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Check if --pre-connect flag is present on the CLI.
|
|
267
|
+
* Overrides the YAML `preConnect` setting when set.
|
|
268
|
+
*/
|
|
269
|
+
export function getPreConnectFlag(args) {
|
|
270
|
+
return args.includes("--pre-connect");
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Normalize and validate allowedRemoteDirectories.
|
|
274
|
+
* Each entry must be an absolute POSIX path. Trailing slashes are stripped
|
|
275
|
+
* (except for the root "/"). ".." segments are rejected after normalization.
|
|
276
|
+
*/
|
|
277
|
+
function normalizeAllowedRemoteDirectories(serverName, raw) {
|
|
278
|
+
if (!Array.isArray(raw)) {
|
|
279
|
+
throw new Error(`Server '${serverName}': 'allowedRemoteDirectories' must be a list of absolute POSIX paths`);
|
|
280
|
+
}
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const entry of raw) {
|
|
283
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
284
|
+
throw new Error(`Server '${serverName}': 'allowedRemoteDirectories' entries must be non-empty strings`);
|
|
285
|
+
}
|
|
286
|
+
if (entry.includes("\0")) {
|
|
287
|
+
throw new Error(`Server '${serverName}': 'allowedRemoteDirectories' entry contains a null byte: ${entry}`);
|
|
288
|
+
}
|
|
289
|
+
if (!path.posix.isAbsolute(entry)) {
|
|
290
|
+
throw new Error(`Server '${serverName}': 'allowedRemoteDirectories' entry must be an absolute POSIX path, got: ${entry}`);
|
|
291
|
+
}
|
|
292
|
+
// Reject '..' BEFORE normalization, otherwise '/a/../b' would silently collapse to '/b'.
|
|
293
|
+
if (entry.split("/").includes("..")) {
|
|
294
|
+
throw new Error(`Server '${serverName}': 'allowedRemoteDirectories' entry must not contain '..' segments: ${entry}`);
|
|
295
|
+
}
|
|
296
|
+
const normalized = path.posix.normalize(entry);
|
|
297
|
+
const trimmed = normalized.length > 1 && normalized.endsWith("/")
|
|
298
|
+
? normalized.slice(0, -1)
|
|
299
|
+
: normalized;
|
|
300
|
+
out.push(trimmed);
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Normalize allowedLocalDirectories. Resolves each entry to an absolute
|
|
306
|
+
* host path (with ~ expansion). The MCP working directory is implicitly
|
|
307
|
+
* allowed and does NOT need to appear here.
|
|
308
|
+
*/
|
|
309
|
+
function normalizeAllowedLocalDirectories(serverName, raw) {
|
|
310
|
+
if (!Array.isArray(raw)) {
|
|
311
|
+
throw new Error(`Server '${serverName}': 'allowedLocalDirectories' must be a list of paths`);
|
|
312
|
+
}
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const entry of raw) {
|
|
315
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
316
|
+
throw new Error(`Server '${serverName}': 'allowedLocalDirectories' entries must be non-empty strings`);
|
|
317
|
+
}
|
|
318
|
+
const expanded = expandTilde(entry);
|
|
319
|
+
const resolved = path.resolve(expanded);
|
|
320
|
+
out.push(resolved);
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
function resolveConfigPath(configPath) {
|
|
325
|
+
const expandedPath = expandTilde(configPath);
|
|
326
|
+
return path.isAbsolute(expandedPath)
|
|
327
|
+
? path.normalize(expandedPath)
|
|
328
|
+
: path.resolve(process.cwd(), expandedPath);
|
|
329
|
+
}
|