@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 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
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Export all configurations
3
+ */
4
+ export * from './server.js';
5
+ export * from './config-loader.js';