@aaarc/handfree-ssh-mcp 1.0.6 → 1.0.7
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 +14 -10
- package/build/config/server.js +8 -6
- package/build/services/ssh-connection-manager.js +425 -189
- package/build/tools/download.js +15 -2
- package/build/tools/help.js +63 -27
- package/build/tools/transfer.js +13 -6
- package/build/tools/upload.js +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,9 +116,9 @@ The AI can now execute commands on your servers. All within your defined securit
|
|
|
116
116
|
| `execute-command` | Run SSH command (with optional `stream` for real-time output) |
|
|
117
117
|
| `show-whitelist` | Show active command policy + SFTP policy + output-log path for a server |
|
|
118
118
|
| `close-connection` | Close a cached SSH connection for a server |
|
|
119
|
-
| `upload` | Upload local file to a remote server (CRLF-fix for shell scripts, skip-if-identical) |
|
|
120
|
-
| `download` | Download remote file to local disk |
|
|
121
|
-
| `transfer` | Unified upload / download / server-to-server relay (`mode`: `upload` / `download` / `relay`, optional `recursive`,
|
|
119
|
+
| `upload` | Upload local file to a remote server (CRLF-fix for shell scripts, skip-if-identical, optional `reuseConnection`, `vvv`, `fast`) |
|
|
120
|
+
| `download` | Download remote file to local disk (optional `reuseConnection`, `vvv`, `fast`) |
|
|
121
|
+
| `transfer` | Unified upload / download / server-to-server relay (`mode`: `upload` / `download` / `relay`, optional `recursive`, `skipIfIdentical`, `reuseConnection`, `vvv`, `fast`) |
|
|
122
122
|
| `list-servers` | List configured (enabled) servers. Lean by default; `verbose:true` adds cached system status, `refresh:true` re-collects it (implies verbose). |
|
|
123
123
|
| `help` | Self-describing help text for the MCP client |
|
|
124
124
|
|
|
@@ -298,16 +298,18 @@ Rules (enforced at config load — bad configs fail fast):
|
|
|
298
298
|
- **Cached clients stay open while reused.** With the default `reuseConnection: true`, `execute-command` does not close the SSH client after each command. If a cached client must be opened first, SSH setup, jump, SOCKS, and channel-open waits are bounded by the call's `timeout`. The cached client is closed on explicit disconnect/server shutdown, config hot-reload for changed connection fields, underlying SSH close/error cleanup, or reconnect after a connection-shaped command failure.
|
|
299
299
|
- **Manual cached-client close.** Use `close-connection { connectionName: "name" }` to drop a cached SSH client on demand. If the named server is a jump host, cached targets that jump through it are closed too.
|
|
300
300
|
- **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.
|
|
301
|
-
- **Optional fresh command connections.** `execute-command`
|
|
301
|
+
- **Optional fresh command/SFTP connections.** `execute-command`, `upload`, `download`, and `transfer` reuse cached SSH clients by default (`reuseConnection: true`). If a call times out or you suspect the cached `ssh2.Client` is stale while native `ssh` works, retry with `reuseConnection: false`; that one operation opens a fresh SSH connection and closes it afterwards. SFTP still opens a new SFTP channel/session per operation.
|
|
302
302
|
- **Optional SSH debug output.** Set `vvv: true` on `execute-command` to append bounded SSH/channel debug output to the result or error. For full ssh2 handshake logs, combine it with `reuseConnection: false`; an already-open cached client cannot retroactively emit handshake debug.
|
|
303
|
-
- **SFTP
|
|
303
|
+
- **Optional SFTP debug output.** Set `vvv: true` on `upload`, `download`, or `transfer` to append bounded SSH/SFTP debug where the result is textual. Recursive `transfer` success keeps its structured `{ summary, files }` JSON response, so recursive debug is surfaced on errors rather than appended to successful summaries.
|
|
304
|
+
- **SFTP transfers do NOT auto-retry.** `upload` / `download` / `transfer` lazy-connect via the same acquisition path and close stale cached clients on connection-shaped SFTP/channel errors, but a mid-transfer disconnect surfaces as a single failure — re-issue the call manually, preferably with `reuseConnection: false` if you suspect the cached SSH client.
|
|
304
305
|
- **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).
|
|
305
306
|
|
|
306
307
|
### Upload behaviors
|
|
307
308
|
|
|
308
|
-
- **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.
|
|
309
|
-
- **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.
|
|
310
|
-
- **
|
|
309
|
+
- **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.
|
|
310
|
+
- **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.
|
|
311
|
+
- **Fast single-file SFTP (default off).** Set `fast: true` on `upload`, `download`, or `transfer` upload/download mode to use ssh2 `fastPut` / `fastGet` for host-to-remote or remote-to-host single-file throughput. Optional `sftpConcurrency` and `chunkSize` are passed to ssh2 only when `fast: true`; omitted values use ssh2 defaults. This is not multi-file concurrency: recursive directory transfer remains sequential, and relay mode keeps its remote-A → MCP host → remote-B streaming pipe path. If a shell-script upload needs CRLF-to-LF conversion, it falls back to the normal safe upload path.
|
|
312
|
+
- **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.
|
|
311
313
|
|
|
312
314
|
### `execute-command` output capping & full logs
|
|
313
315
|
|
|
@@ -375,8 +377,10 @@ Example with selective servers:
|
|
|
375
377
|
### Nice to Have
|
|
376
378
|
- [x] **Command history / output archive**: full stdout/stderr of every `execute-command` is persisted under `<outputLogDir>/<server>/<user>/*.log`
|
|
377
379
|
- [x] **Multi-command execution**: Execute multiple commands in sequence with `&&` or `;` safely (fixed: `2>/dev/null` now allowed)
|
|
378
|
-
- [x] **Connection auto-recovery**: `execute-command` retries with exponential backoff and forced reconnect on connection-shaped errors
|
|
379
|
-
- [
|
|
380
|
+
- [x] **Connection auto-recovery**: `execute-command` retries with exponential backoff and forced reconnect on connection-shaped errors
|
|
381
|
+
- [x] **SFTP connection reuse controls**: `upload` / `download` / `transfer` support `reuseConnection`, `vvv`, and explicit fresh one-shot SSH clients
|
|
382
|
+
- [x] **Fast single-file SFTP**: optional ssh2 `fastPut` / `fastGet` via `fast`, `sftpConcurrency`, and `chunkSize`
|
|
383
|
+
- [ ] **SFTP retry parity**: extend the retry-with-reconnect loop to `upload` / `download` / `transfer`
|
|
380
384
|
- [ ] **TCP keepalive**: pass `keepaliveInterval` / `keepaliveCountMax` to `ssh2.Client` so half-open connections are detected without waiting for the next command
|
|
381
385
|
- [ ] **Server health check**: optional periodic ping to detect drops proactively
|
|
382
386
|
|
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.7",
|
|
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
|
|
|
@@ -11,10 +11,12 @@ Recommended workflow:
|
|
|
11
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
12
|
5. Use stream=true for commands that may take longer or where incremental output is useful.
|
|
13
13
|
6. execute-command reuses SSH connections by default. If an execute-command call times out, retry the next command with reuseConnection=false to bypass a potentially stale cached SSH connection.
|
|
14
|
-
7.
|
|
15
|
-
8.
|
|
16
|
-
9.
|
|
17
|
-
10.
|
|
14
|
+
7. SFTP tools also reuse SSH connections by default. If upload/download/transfer times out or reports a connection-shaped SFTP/channel error, retry with reuseConnection=false; the fresh SSH connection closes after that transfer.
|
|
15
|
+
8. Use close-connection to close a cached SSH connection for a server before retrying with a clean reused connection. Closing a jump host also closes cached targets that jump through it.
|
|
16
|
+
9. For SSH/channel diagnostics, add vvv=true. Use it with reuseConnection=false when you need fresh handshake/debug output.
|
|
17
|
+
10. Use upload and download for single-file SFTP transfers. Use transfer for recursive directory transfers or cross-server relay.
|
|
18
|
+
11. For large host-to-remote or remote-to-host single-file SFTP transfers, fast=true enables ssh2 fastPut/fastGet with optional sftpConcurrency and chunkSize. It is off by default and does not add multi-file concurrency; relay keeps the streaming pipe path.
|
|
19
|
+
12. Call help or help { tool: "<name>" } for detailed per-tool parameter docs and examples.
|
|
18
20
|
|
|
19
21
|
Server targeting:
|
|
20
22
|
- When only one server is enabled, connectionName can be omitted and the server is auto-selected.
|
|
@@ -24,5 +26,5 @@ Behavior notes:
|
|
|
24
26
|
- A tool may automatically establish the SSH connection on first use.
|
|
25
27
|
- 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.
|
|
26
28
|
- Some commands return no output on success; this is normal.
|
|
27
|
-
- File transfer tools use SFTP and can fail if the remote path is missing or permissions are insufficient.
|
|
29
|
+
- File transfer tools use SFTP and can fail if the remote path is missing or permissions are insufficient. They open a new SFTP channel per operation; they reuse the underlying SSH client unless reuseConnection=false is set.
|
|
28
30
|
- OpenSSH/YAML config changes are hot-reloaded without restarting the server. Connection-field changes close the old client so the next tool call reconnects with fresh settings.`;
|
|
@@ -125,10 +125,7 @@ export class SSHConnectionManager {
|
|
|
125
125
|
toReset.add(name);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
-
|
|
129
|
-
this.closeClient(name, true);
|
|
130
|
-
this.statusCache.delete(name);
|
|
131
|
-
}
|
|
128
|
+
this.closeClientSet(toReset, true);
|
|
132
129
|
this.setConfig(configs, enabledServers);
|
|
133
130
|
Logger.log(`Hot-reloaded SSH config: ${Object.keys(configs).length} server(s), ` +
|
|
134
131
|
`${toReset.size} connection(s) reset`, "info");
|
|
@@ -197,6 +194,12 @@ export class SSHConnectionManager {
|
|
|
197
194
|
this.connected.set(name, false);
|
|
198
195
|
this.connecting.delete(name);
|
|
199
196
|
}
|
|
197
|
+
closeClientSet(names, bumpGeneration = true) {
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
this.closeClient(name, bumpGeneration);
|
|
200
|
+
this.statusCache.delete(name);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
200
203
|
closeConnection(name) {
|
|
201
204
|
const key = this.resolveServer(name);
|
|
202
205
|
this.getConfig(key);
|
|
@@ -209,10 +212,7 @@ export class SSHConnectionManager {
|
|
|
209
212
|
affected.add(target);
|
|
210
213
|
}
|
|
211
214
|
}
|
|
212
|
-
|
|
213
|
-
this.closeClient(target, true);
|
|
214
|
-
this.statusCache.delete(target);
|
|
215
|
-
}
|
|
215
|
+
this.closeClientSet(affected, true);
|
|
216
216
|
return {
|
|
217
217
|
requested: key,
|
|
218
218
|
closed: Array.from(affected),
|
|
@@ -496,15 +496,34 @@ export class SSHConnectionManager {
|
|
|
496
496
|
}
|
|
497
497
|
this.clients.set(key, client);
|
|
498
498
|
}
|
|
499
|
+
async acquireSshClient(key, options = {}) {
|
|
500
|
+
const reuseConnection = options.reuseConnection !== false;
|
|
501
|
+
const purpose = options.purpose ?? "command";
|
|
502
|
+
if (reuseConnection) {
|
|
503
|
+
const client = await this.ensureConnected(key, options.timeout);
|
|
504
|
+
options.debug?.(`[mcp] using cached SSH connection for [${key}]; set reuseConnection=false to capture SSH handshake debug`);
|
|
505
|
+
return { client, close: () => { } };
|
|
506
|
+
}
|
|
507
|
+
if (purpose === "command") {
|
|
508
|
+
return this.connectCommandClient(key, options.timeout ?? 30000, options.debug);
|
|
509
|
+
}
|
|
510
|
+
return this.connectOneShotClient(key, options.timeout ?? 30000, options.debug, purpose);
|
|
511
|
+
}
|
|
499
512
|
/**
|
|
500
|
-
*
|
|
501
|
-
* Used when the caller disables connection reuse after a timeout or when a
|
|
502
|
-
* fresh TCP/SSH handshake is more important than latency.
|
|
513
|
+
* Compatibility wrapper for tests and existing command call sites.
|
|
503
514
|
*/
|
|
504
515
|
async connectCommandClient(key, timeout, debug) {
|
|
516
|
+
return this.connectOneShotClient(key, timeout, debug, "command");
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Open a one-shot SSH client for a single operation. Used when the caller
|
|
520
|
+
* disables connection reuse after a timeout or when a fresh TCP/SSH
|
|
521
|
+
* handshake is more important than latency.
|
|
522
|
+
*/
|
|
523
|
+
async connectOneShotClient(key, timeout, debug, purpose = "command") {
|
|
505
524
|
const config = this.getConfig(key);
|
|
506
525
|
const client = new Client();
|
|
507
|
-
const jumpChainKey = `
|
|
526
|
+
const jumpChainKey = `__${purpose}__:${key}:${Date.now()}:${crypto.randomBytes(4).toString("hex")}`;
|
|
508
527
|
const connectTimeout = this.normalizeConnectTimeout(timeout) ?? 30000;
|
|
509
528
|
let settled = false;
|
|
510
529
|
let closed = false;
|
|
@@ -513,7 +532,7 @@ export class SSHConnectionManager {
|
|
|
513
532
|
return;
|
|
514
533
|
}
|
|
515
534
|
debug?.(`[mcp] one-shot SSH client emitted error after ready/settle: ${err.message}`);
|
|
516
|
-
Logger.log(`One-shot SSH
|
|
535
|
+
Logger.log(`One-shot SSH ${purpose} client [${key}] emitted error after settle: ${err.message}`, "error");
|
|
517
536
|
};
|
|
518
537
|
client.on("error", onLateError);
|
|
519
538
|
const close = () => {
|
|
@@ -560,11 +579,11 @@ export class SSHConnectionManager {
|
|
|
560
579
|
}
|
|
561
580
|
this.teardownJumpChain(jumpChainKey);
|
|
562
581
|
});
|
|
563
|
-
Logger.log(`Using one-shot jump host '${config.jumpHost}' for
|
|
582
|
+
Logger.log(`Using one-shot jump host '${config.jumpHost}' for ${purpose} on [${key}]`, "info");
|
|
564
583
|
}
|
|
565
584
|
catch (err) {
|
|
566
585
|
this.teardownJumpChain(jumpChainKey);
|
|
567
|
-
throw new ToolError("SSH_CONNECTION_FAILED", `Failed to open one-shot jump tunnel for [${key}] via '${config.jumpHost}': ${err.message}`, true);
|
|
586
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `Failed to open one-shot jump tunnel for ${purpose} [${key}] via '${config.jumpHost}': ${err.message}`, true);
|
|
568
587
|
}
|
|
569
588
|
}
|
|
570
589
|
if (config.socksProxy) {
|
|
@@ -611,7 +630,7 @@ export class SSHConnectionManager {
|
|
|
611
630
|
sshConfig.password = config.password;
|
|
612
631
|
}
|
|
613
632
|
else if (agent || config.authOptional) {
|
|
614
|
-
Logger.log(`Using SSH agent/default authentication for one-shot
|
|
633
|
+
Logger.log(`Using SSH agent/default authentication for one-shot ${purpose} [${key}]`, "info");
|
|
615
634
|
}
|
|
616
635
|
else {
|
|
617
636
|
throw new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (password or private key)`, false);
|
|
@@ -631,19 +650,19 @@ export class SSHConnectionManager {
|
|
|
631
650
|
resolve();
|
|
632
651
|
};
|
|
633
652
|
const onReady = () => done();
|
|
634
|
-
const onError = (err) => done(new ToolError("SSH_CONNECTION_FAILED", `SSH
|
|
635
|
-
const onClose = () => done(new ToolError("SSH_CONNECTION_FAILED", `SSH
|
|
653
|
+
const onError = (err) => done(new ToolError("SSH_CONNECTION_FAILED", `SSH ${purpose} connection [${key}] failed: ${err.message}`, true));
|
|
654
|
+
const onClose = () => done(new ToolError("SSH_CONNECTION_FAILED", `SSH ${purpose} connection [${key}] closed before ready`, true));
|
|
636
655
|
const timeoutId = setTimeout(() => {
|
|
637
|
-
done(new ToolError("SSH_CONNECTION_FAILED", `SSH
|
|
656
|
+
done(new ToolError("SSH_CONNECTION_FAILED", `SSH ${purpose} connection [${key}] timed out after ${connectTimeout}ms`, true));
|
|
638
657
|
close();
|
|
639
658
|
}, connectTimeout);
|
|
640
|
-
debug?.(`[mcp] opening one-shot SSH connection for [${key}]`);
|
|
659
|
+
debug?.(`[mcp] opening one-shot SSH ${purpose} connection for [${key}]`);
|
|
641
660
|
client.once("ready", onReady);
|
|
642
661
|
client.once("error", onError);
|
|
643
662
|
client.once("close", onClose);
|
|
644
663
|
client.connect(sshConfig);
|
|
645
664
|
});
|
|
646
|
-
Logger.log(`Opened one-shot SSH
|
|
665
|
+
Logger.log(`Opened one-shot SSH ${purpose} connection for [${key}]`, "info");
|
|
647
666
|
return { client, close };
|
|
648
667
|
}
|
|
649
668
|
catch (err) {
|
|
@@ -700,7 +719,7 @@ export class SSHConnectionManager {
|
|
|
700
719
|
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
|
|
701
720
|
return undefined;
|
|
702
721
|
}
|
|
703
|
-
return Math.max(1,
|
|
722
|
+
return Math.max(1, timeout);
|
|
704
723
|
}
|
|
705
724
|
/**
|
|
706
725
|
* Open a TCP tunnel from the target's jump chain to `config.host:config.port`
|
|
@@ -994,6 +1013,31 @@ export class SSHConnectionManager {
|
|
|
994
1013
|
msg.includes("no response from server") ||
|
|
995
1014
|
msg.includes("timed out"));
|
|
996
1015
|
}
|
|
1016
|
+
makeSftpError(context, error) {
|
|
1017
|
+
const connectionFailure = this.isConnectionShapedMessage(error.message);
|
|
1018
|
+
return new ToolError(connectionFailure ? "SSH_CONNECTION_FAILED" : "SFTP_ERROR", `${context}: ${error.message}`, connectionFailure);
|
|
1019
|
+
}
|
|
1020
|
+
createSftpTransferOptions(options) {
|
|
1021
|
+
const transferOptions = {};
|
|
1022
|
+
const concurrency = this.optionalPositiveInteger(options?.sftpConcurrency, "sftpConcurrency");
|
|
1023
|
+
const chunkSize = this.optionalPositiveInteger(options?.chunkSize, "chunkSize");
|
|
1024
|
+
if (concurrency !== undefined) {
|
|
1025
|
+
transferOptions.concurrency = concurrency;
|
|
1026
|
+
}
|
|
1027
|
+
if (chunkSize !== undefined) {
|
|
1028
|
+
transferOptions.chunkSize = chunkSize;
|
|
1029
|
+
}
|
|
1030
|
+
return transferOptions;
|
|
1031
|
+
}
|
|
1032
|
+
optionalPositiveInteger(value, name) {
|
|
1033
|
+
if (value === undefined) {
|
|
1034
|
+
return undefined;
|
|
1035
|
+
}
|
|
1036
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
1037
|
+
throw new ToolError("INVALID_CONFIGURATION", `${name} must be a positive integer`, false);
|
|
1038
|
+
}
|
|
1039
|
+
return value;
|
|
1040
|
+
}
|
|
997
1041
|
/**
|
|
998
1042
|
* Force reconnect to SSH server
|
|
999
1043
|
* @private
|
|
@@ -1287,7 +1331,7 @@ export class SSHConnectionManager {
|
|
|
1287
1331
|
}
|
|
1288
1332
|
};
|
|
1289
1333
|
const armExecOpenTimeout = () => {
|
|
1290
|
-
const execOpenTimeout = Math.max(1,
|
|
1334
|
+
const execOpenTimeout = Math.max(1, timeout);
|
|
1291
1335
|
timeoutId = setTimeout(() => {
|
|
1292
1336
|
sinks.debug?.(`[mcp] exec channel open timed out after ${execOpenTimeout}ms`);
|
|
1293
1337
|
settle(new ToolError("SSH_CONNECTION_FAILED", `SSH exec channel timeout: no response from server within ${execOpenTimeout}ms while opening command channel`, true));
|
|
@@ -1383,24 +1427,18 @@ export class SSHConnectionManager {
|
|
|
1383
1427
|
};
|
|
1384
1428
|
}
|
|
1385
1429
|
appendDebugOutput(result, collector) {
|
|
1386
|
-
|
|
1430
|
+
const debugBlock = this.formatDebugBlock(collector);
|
|
1431
|
+
if (!debugBlock) {
|
|
1387
1432
|
return result;
|
|
1388
1433
|
}
|
|
1389
|
-
|
|
1390
|
-
const header = snapshot.truncated
|
|
1391
|
-
? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
|
|
1392
|
-
: "[SSH DEBUG]\n";
|
|
1393
|
-
return `${result}\n\n${header}${snapshot.tail.toString("utf8").trimEnd()}`;
|
|
1434
|
+
return `${result}\n\n${debugBlock}`;
|
|
1394
1435
|
}
|
|
1395
1436
|
appendDebugToError(error, collector) {
|
|
1396
|
-
|
|
1437
|
+
const debugBlock = this.formatDebugBlock(collector);
|
|
1438
|
+
if (!debugBlock) {
|
|
1397
1439
|
return error;
|
|
1398
1440
|
}
|
|
1399
|
-
const
|
|
1400
|
-
const header = snapshot.truncated
|
|
1401
|
-
? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
|
|
1402
|
-
: "[SSH DEBUG]\n";
|
|
1403
|
-
const message = `${error.message}\n\n${header}${snapshot.tail.toString("utf8").trimEnd()}`;
|
|
1441
|
+
const message = `${error.message}\n\n${debugBlock}`;
|
|
1404
1442
|
if (error instanceof ToolError) {
|
|
1405
1443
|
return new ToolError(error.code, message, error.retriable);
|
|
1406
1444
|
}
|
|
@@ -1408,6 +1446,40 @@ export class SSHConnectionManager {
|
|
|
1408
1446
|
wrapped.name = error.name;
|
|
1409
1447
|
return wrapped;
|
|
1410
1448
|
}
|
|
1449
|
+
formatDebugBlock(collector) {
|
|
1450
|
+
if (!collector || collector.getTotalBytes() === 0) {
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
const snapshot = collector.getSnapshot();
|
|
1454
|
+
const header = snapshot.truncated
|
|
1455
|
+
? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
|
|
1456
|
+
: "[SSH DEBUG]\n";
|
|
1457
|
+
return `${header}${snapshot.tail.toString("utf8").trimEnd()}`;
|
|
1458
|
+
}
|
|
1459
|
+
unpipeStream(readStream, writeStream) {
|
|
1460
|
+
const candidate = readStream;
|
|
1461
|
+
if (typeof candidate.unpipe !== "function") {
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
candidate.unpipe(writeStream);
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
// Ignore cleanup errors after the original stream failure.
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
destroyStream(stream) {
|
|
1472
|
+
const candidate = stream;
|
|
1473
|
+
if (typeof candidate.destroy !== "function") {
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
candidate.destroy();
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
// Ignore cleanup errors after the original stream failure.
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1411
1483
|
/**
|
|
1412
1484
|
* Assemble the final user-visible result string from collected tails.
|
|
1413
1485
|
* Adds a truncation header (with the on-disk log path) and the legacy
|
|
@@ -1480,13 +1552,13 @@ export class SSHConnectionManager {
|
|
|
1480
1552
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1481
1553
|
try {
|
|
1482
1554
|
debug?.(`[mcp] command attempt ${attempt + 1}/${maxRetries + 1} on [${key}], reuseConnection=${reuseConnection}`);
|
|
1483
|
-
const commandConnection =
|
|
1484
|
-
|
|
1485
|
-
|
|
1555
|
+
const commandConnection = await this.acquireSshClient(key, {
|
|
1556
|
+
reuseConnection,
|
|
1557
|
+
timeout,
|
|
1558
|
+
debug,
|
|
1559
|
+
purpose: "command",
|
|
1560
|
+
});
|
|
1486
1561
|
const client = commandConnection.client;
|
|
1487
|
-
if (reuseConnection) {
|
|
1488
|
-
debug?.(`[mcp] using cached SSH connection for [${key}]; set reuseConnection=false to capture SSH handshake debug`);
|
|
1489
|
-
}
|
|
1490
1562
|
// Per-attempt collectors + log writer. We rebuild them on each retry
|
|
1491
1563
|
// so partial output from a failed attempt is not mixed into the next.
|
|
1492
1564
|
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
@@ -1573,13 +1645,13 @@ export class SSHConnectionManager {
|
|
|
1573
1645
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1574
1646
|
try {
|
|
1575
1647
|
debug?.(`[mcp] streaming command attempt ${attempt + 1}/${maxRetries + 1} on [${key}], reuseConnection=${reuseConnection}`);
|
|
1576
|
-
const commandConnection =
|
|
1577
|
-
|
|
1578
|
-
|
|
1648
|
+
const commandConnection = await this.acquireSshClient(key, {
|
|
1649
|
+
reuseConnection,
|
|
1650
|
+
timeout,
|
|
1651
|
+
debug,
|
|
1652
|
+
purpose: "command",
|
|
1653
|
+
});
|
|
1579
1654
|
const client = commandConnection.client;
|
|
1580
|
-
if (reuseConnection) {
|
|
1581
|
-
debug?.(`[mcp] using cached SSH connection for [${key}]; set reuseConnection=false to capture SSH handshake debug`);
|
|
1582
|
-
}
|
|
1583
1655
|
// Per-attempt collectors + log writer. onProgress keeps streaming
|
|
1584
1656
|
// every byte live; only the final returned string is capped.
|
|
1585
1657
|
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
@@ -1725,6 +1797,8 @@ export class SSHConnectionManager {
|
|
|
1725
1797
|
*/
|
|
1726
1798
|
async upload(localPath, remotePath, name, options) {
|
|
1727
1799
|
const resolvedName = name || this.defaultName;
|
|
1800
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
1801
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
1728
1802
|
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1729
1803
|
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1730
1804
|
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
@@ -1740,32 +1814,67 @@ export class SSHConnectionManager {
|
|
|
1740
1814
|
throw new ToolError("LOCAL_FILE_READ_FAILED", `Local path '${validatedLocalPath}' is not a regular file (size=${stat.size}). ` +
|
|
1741
1815
|
`Use uploadDirectory / recursive=true for directories.`, false);
|
|
1742
1816
|
}
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1817
|
+
const isShellScript = SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(path.extname(validatedLocalPath).toLowerCase());
|
|
1818
|
+
const fastUpload = options?.fast === true;
|
|
1819
|
+
const mustReadPayload = skipIfIdentical || !fastUpload || isShellScript;
|
|
1820
|
+
let payload = null;
|
|
1821
|
+
let crlfFixed = {
|
|
1822
|
+
buffer: Buffer.alloc(0),
|
|
1823
|
+
fixed: false,
|
|
1824
|
+
replacedCount: 0,
|
|
1825
|
+
};
|
|
1826
|
+
if (mustReadPayload) {
|
|
1827
|
+
try {
|
|
1828
|
+
payload = fs.readFileSync(validatedLocalPath);
|
|
1829
|
+
}
|
|
1830
|
+
catch (e) {
|
|
1831
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file '${validatedLocalPath}': ${e.message}`, false);
|
|
1832
|
+
}
|
|
1833
|
+
// ---- CRLF auto-fix for shell scripts ----
|
|
1834
|
+
crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
|
|
1835
|
+
payload = crlfFixed.buffer;
|
|
1749
1836
|
}
|
|
1750
|
-
// ---- CRLF auto-fix for shell scripts ----
|
|
1751
|
-
const crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
|
|
1752
|
-
payload = crlfFixed.buffer;
|
|
1753
1837
|
const crlfNote = crlfFixed.fixed
|
|
1754
1838
|
? ` (CRLF→LF auto-fix: converted ${crlfFixed.replacedCount} line endings to LF before upload because target is a shell script).`
|
|
1755
1839
|
: "";
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1840
|
+
debug?.(`[mcp] sftp upload on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
1841
|
+
let connection = null;
|
|
1842
|
+
try {
|
|
1843
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
1844
|
+
reuseConnection,
|
|
1845
|
+
timeout: options?.timeout,
|
|
1846
|
+
debug,
|
|
1847
|
+
purpose: "sftp",
|
|
1848
|
+
});
|
|
1849
|
+
const client = connection.client;
|
|
1850
|
+
// ---- Skip-if-identical check ----
|
|
1851
|
+
if (skipIfIdentical) {
|
|
1852
|
+
const decision = await this.shouldSkipUpload(client, payload, validatedRemotePath, isShellScript, options?.timeout, debug);
|
|
1853
|
+
if (decision.skip) {
|
|
1854
|
+
return this.appendDebugOutput(`Upload skipped: remote file '${validatedRemotePath}' is already identical to local ` +
|
|
1855
|
+
`'${validatedLocalPath}' (${decision.reason}).${crlfNote}`, debugCollector);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
// ---- Actually upload ----
|
|
1859
|
+
if (fastUpload && !crlfFixed.fixed) {
|
|
1860
|
+
await this.sftpFastPut(client, validatedLocalPath, validatedRemotePath, options, options?.timeout, debug);
|
|
1861
|
+
}
|
|
1862
|
+
else {
|
|
1863
|
+
await this.sftpWriteBuffer(client, validatedRemotePath, payload, options?.timeout, debug);
|
|
1764
1864
|
}
|
|
1865
|
+
const uploadedBytes = payload?.length ?? stat.size;
|
|
1866
|
+
const modeNote = fastUpload && !crlfFixed.fixed ? " via fast SFTP" : "";
|
|
1867
|
+
return this.appendDebugOutput(`File uploaded successfully (${uploadedBytes} bytes${modeNote})${crlfNote}`, debugCollector);
|
|
1868
|
+
}
|
|
1869
|
+
catch (error) {
|
|
1870
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
1871
|
+
this.closeClient(resolvedName, true);
|
|
1872
|
+
}
|
|
1873
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
1874
|
+
}
|
|
1875
|
+
finally {
|
|
1876
|
+
connection?.close();
|
|
1765
1877
|
}
|
|
1766
|
-
// ---- Actually upload ----
|
|
1767
|
-
await this.sftpWriteBuffer(client, validatedRemotePath, payload);
|
|
1768
|
-
return `File uploaded successfully (${payload.length} bytes)${crlfNote}`;
|
|
1769
1878
|
}
|
|
1770
1879
|
/**
|
|
1771
1880
|
* Threshold above which we use MD5 hash comparison instead of byte-content
|
|
@@ -1822,10 +1931,10 @@ export class SSHConnectionManager {
|
|
|
1822
1931
|
* Any error during the check is treated as 'don't skip' (i.e. fall through
|
|
1823
1932
|
* to a normal upload), since correctness wins over an optimization.
|
|
1824
1933
|
*/
|
|
1825
|
-
async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic) {
|
|
1934
|
+
async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic, timeout, debug) {
|
|
1826
1935
|
let remoteSize;
|
|
1827
1936
|
try {
|
|
1828
|
-
const sftp = await this.openSftp(client, "dest");
|
|
1937
|
+
const sftp = await this.openSftp(client, "dest", timeout, debug);
|
|
1829
1938
|
try {
|
|
1830
1939
|
const stat = await this.sftpStat(sftp, remotePath, "dest");
|
|
1831
1940
|
remoteSize = stat.size;
|
|
@@ -1846,7 +1955,7 @@ export class SSHConnectionManager {
|
|
|
1846
1955
|
}
|
|
1847
1956
|
let remoteBuf;
|
|
1848
1957
|
try {
|
|
1849
|
-
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
1958
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
|
|
1850
1959
|
}
|
|
1851
1960
|
catch {
|
|
1852
1961
|
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
@@ -1869,7 +1978,7 @@ export class SSHConnectionManager {
|
|
|
1869
1978
|
// Byte-content compare
|
|
1870
1979
|
let remoteBuf;
|
|
1871
1980
|
try {
|
|
1872
|
-
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
1981
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
|
|
1873
1982
|
}
|
|
1874
1983
|
catch {
|
|
1875
1984
|
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
@@ -1905,88 +2014,138 @@ export class SSHConnectionManager {
|
|
|
1905
2014
|
/**
|
|
1906
2015
|
* Read an SFTP file fully into a Buffer.
|
|
1907
2016
|
*/
|
|
1908
|
-
async sftpReadBuffer(client, remotePath, expectedSize) {
|
|
2017
|
+
async sftpReadBuffer(client, remotePath, expectedSize, timeout, debug) {
|
|
2018
|
+
const sftp = await this.openSftp(client, "read", timeout, debug);
|
|
1909
2019
|
return new Promise((resolve, reject) => {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
2020
|
+
const chunks = [];
|
|
2021
|
+
let received = 0;
|
|
2022
|
+
const stream = sftp.createReadStream(remotePath);
|
|
2023
|
+
stream.on("data", (chunk) => {
|
|
2024
|
+
chunks.push(chunk);
|
|
2025
|
+
received += chunk.length;
|
|
2026
|
+
});
|
|
2027
|
+
stream.on("error", (e) => {
|
|
2028
|
+
sftp.end();
|
|
2029
|
+
reject(this.makeSftpError("Remote read failed", e));
|
|
2030
|
+
});
|
|
2031
|
+
stream.on("end", () => {
|
|
2032
|
+
sftp.end();
|
|
2033
|
+
if (received !== expectedSize) {
|
|
2034
|
+
return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
|
|
1913
2035
|
}
|
|
1914
|
-
|
|
1915
|
-
let received = 0;
|
|
1916
|
-
const stream = sftp.createReadStream(remotePath);
|
|
1917
|
-
stream.on("data", (chunk) => {
|
|
1918
|
-
chunks.push(chunk);
|
|
1919
|
-
received += chunk.length;
|
|
1920
|
-
});
|
|
1921
|
-
stream.on("error", (e) => {
|
|
1922
|
-
sftp.end();
|
|
1923
|
-
reject(new ToolError("SFTP_ERROR", `Remote read failed: ${e.message}`, false));
|
|
1924
|
-
});
|
|
1925
|
-
stream.on("end", () => {
|
|
1926
|
-
sftp.end();
|
|
1927
|
-
if (received !== expectedSize) {
|
|
1928
|
-
return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
|
|
1929
|
-
}
|
|
1930
|
-
resolve(Buffer.concat(chunks, received));
|
|
1931
|
-
});
|
|
2036
|
+
resolve(Buffer.concat(chunks, received));
|
|
1932
2037
|
});
|
|
1933
2038
|
});
|
|
1934
2039
|
}
|
|
1935
2040
|
/**
|
|
1936
2041
|
* Write a Buffer to an SFTP path (overwrites if exists).
|
|
1937
2042
|
*/
|
|
1938
|
-
async sftpWriteBuffer(client, remotePath, payload) {
|
|
2043
|
+
async sftpWriteBuffer(client, remotePath, payload, timeout, debug) {
|
|
2044
|
+
const sftp = await this.openSftp(client, "write", timeout, debug);
|
|
1939
2045
|
return new Promise((resolve, reject) => {
|
|
1940
|
-
|
|
2046
|
+
const writeStream = sftp.createWriteStream(remotePath);
|
|
2047
|
+
writeStream.on("close", () => {
|
|
2048
|
+
sftp.end();
|
|
2049
|
+
resolve();
|
|
2050
|
+
});
|
|
2051
|
+
writeStream.on("error", (e) => {
|
|
2052
|
+
sftp.end();
|
|
2053
|
+
reject(this.makeSftpError("File upload failed", e));
|
|
2054
|
+
});
|
|
2055
|
+
writeStream.end(payload);
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
/**
|
|
2059
|
+
* Upload a local file using ssh2's parallel SFTP fastPut implementation.
|
|
2060
|
+
*/
|
|
2061
|
+
async sftpFastPut(client, localPath, remotePath, options, timeout, debug) {
|
|
2062
|
+
const sftp = await this.openSftp(client, "fastPut", timeout, debug);
|
|
2063
|
+
const transferOptions = this.createSftpTransferOptions(options);
|
|
2064
|
+
debug?.(`[mcp] fastPut ${localPath} -> ${remotePath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
|
|
2065
|
+
return new Promise((resolve, reject) => {
|
|
2066
|
+
sftp.fastPut(localPath, remotePath, transferOptions, (err) => {
|
|
2067
|
+
sftp.end();
|
|
1941
2068
|
if (err) {
|
|
1942
|
-
|
|
2069
|
+
reject(this.makeSftpError("Fast upload failed", err));
|
|
2070
|
+
return;
|
|
1943
2071
|
}
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2072
|
+
resolve();
|
|
2073
|
+
});
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Download a remote file using ssh2's parallel SFTP fastGet implementation.
|
|
2078
|
+
*/
|
|
2079
|
+
async sftpFastGet(client, remotePath, localPath, options, timeout, debug) {
|
|
2080
|
+
const sftp = await this.openSftp(client, "fastGet", timeout, debug);
|
|
2081
|
+
const transferOptions = this.createSftpTransferOptions(options);
|
|
2082
|
+
debug?.(`[mcp] fastGet ${remotePath} -> ${localPath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
|
|
2083
|
+
return new Promise((resolve, reject) => {
|
|
2084
|
+
sftp.fastGet(remotePath, localPath, transferOptions, (err) => {
|
|
2085
|
+
sftp.end();
|
|
2086
|
+
if (err) {
|
|
2087
|
+
reject(this.makeSftpError("Fast download failed", err));
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
resolve();
|
|
1954
2091
|
});
|
|
1955
2092
|
});
|
|
1956
2093
|
}
|
|
1957
2094
|
/**
|
|
1958
2095
|
* Download file
|
|
1959
2096
|
*/
|
|
1960
|
-
async download(remotePath, localPath, name) {
|
|
2097
|
+
async download(remotePath, localPath, name, options) {
|
|
1961
2098
|
const resolvedName = name || this.defaultName;
|
|
2099
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2100
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
1962
2101
|
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1963
2102
|
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2103
|
+
debug?.(`[mcp] sftp download on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2104
|
+
let connection = null;
|
|
2105
|
+
try {
|
|
2106
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2107
|
+
reuseConnection,
|
|
2108
|
+
timeout: options?.timeout,
|
|
2109
|
+
debug,
|
|
2110
|
+
purpose: "sftp",
|
|
2111
|
+
});
|
|
2112
|
+
if (options?.fast === true) {
|
|
2113
|
+
await this.sftpFastGet(connection.client, validatedRemotePath, validatedLocalPath, options, options?.timeout, debug);
|
|
2114
|
+
return this.appendDebugOutput("File downloaded successfully via fast SFTP", debugCollector);
|
|
2115
|
+
}
|
|
2116
|
+
const sftp = await this.openSftp(connection.client, "download", options?.timeout, debug);
|
|
2117
|
+
await new Promise((resolve, reject) => {
|
|
2118
|
+
let settled = false;
|
|
1970
2119
|
const readStream = sftp.createReadStream(validatedRemotePath);
|
|
1971
2120
|
const writeStream = fs.createWriteStream(validatedLocalPath);
|
|
1972
|
-
const
|
|
2121
|
+
const settle = (err) => {
|
|
2122
|
+
if (settled)
|
|
2123
|
+
return;
|
|
2124
|
+
settled = true;
|
|
2125
|
+
if (err) {
|
|
2126
|
+
this.unpipeStream(readStream, writeStream);
|
|
2127
|
+
this.destroyStream(readStream);
|
|
2128
|
+
this.destroyStream(writeStream);
|
|
2129
|
+
}
|
|
1973
2130
|
sftp.end();
|
|
2131
|
+
err ? reject(err) : resolve();
|
|
1974
2132
|
};
|
|
1975
|
-
writeStream.on("finish", () =>
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
});
|
|
1979
|
-
writeStream.on("error", (err) => {
|
|
1980
|
-
cleanup();
|
|
1981
|
-
reject(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false));
|
|
1982
|
-
});
|
|
1983
|
-
readStream.on("error", (err) => {
|
|
1984
|
-
cleanup();
|
|
1985
|
-
reject(new ToolError("SFTP_ERROR", `File download failed: ${err.message}`, false));
|
|
1986
|
-
});
|
|
2133
|
+
writeStream.on("finish", () => settle());
|
|
2134
|
+
writeStream.on("error", (err) => settle(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false)));
|
|
2135
|
+
readStream.on("error", (err) => settle(this.makeSftpError("File download failed", err)));
|
|
1987
2136
|
readStream.pipe(writeStream);
|
|
1988
2137
|
});
|
|
1989
|
-
|
|
2138
|
+
return this.appendDebugOutput("File downloaded successfully", debugCollector);
|
|
2139
|
+
}
|
|
2140
|
+
catch (error) {
|
|
2141
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2142
|
+
this.closeClient(resolvedName, true);
|
|
2143
|
+
}
|
|
2144
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2145
|
+
}
|
|
2146
|
+
finally {
|
|
2147
|
+
connection?.close();
|
|
2148
|
+
}
|
|
1990
2149
|
}
|
|
1991
2150
|
/**
|
|
1992
2151
|
* Disconnect SSH connection
|
|
@@ -2080,11 +2239,30 @@ export class SSHConnectionManager {
|
|
|
2080
2239
|
const validatedSourcePath = this.validateRemotePath(sourceRemotePath, sourceName);
|
|
2081
2240
|
const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
|
|
2082
2241
|
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
2083
|
-
const
|
|
2084
|
-
const
|
|
2085
|
-
|
|
2086
|
-
|
|
2242
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2243
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2244
|
+
debug?.(`[mcp] sftp relay ${sourceName} -> ${destName}, reuseConnection=${reuseConnection}`);
|
|
2245
|
+
let srcConnection = null;
|
|
2246
|
+
let dstConnection = null;
|
|
2247
|
+
let srcSftp = null;
|
|
2248
|
+
let dstSftp = null;
|
|
2087
2249
|
try {
|
|
2250
|
+
srcConnection = await this.acquireSshClient(sourceName, {
|
|
2251
|
+
reuseConnection,
|
|
2252
|
+
timeout: options?.timeout,
|
|
2253
|
+
debug,
|
|
2254
|
+
purpose: "sftp",
|
|
2255
|
+
});
|
|
2256
|
+
dstConnection = await this.acquireSshClient(destName, {
|
|
2257
|
+
reuseConnection,
|
|
2258
|
+
timeout: options?.timeout,
|
|
2259
|
+
debug,
|
|
2260
|
+
purpose: "sftp",
|
|
2261
|
+
});
|
|
2262
|
+
const srcClient = srcConnection.client;
|
|
2263
|
+
const dstClient = dstConnection.client;
|
|
2264
|
+
srcSftp = await this.openSftp(srcClient, "source", options?.timeout, debug);
|
|
2265
|
+
dstSftp = await this.openSftp(dstClient, "dest", options?.timeout, debug);
|
|
2088
2266
|
// Get source file size before transfer
|
|
2089
2267
|
const srcStat = await this.sftpStat(srcSftp, validatedSourcePath, "source");
|
|
2090
2268
|
// Skip-if-identical: same size on both sides AND matching md5sum (when
|
|
@@ -2101,10 +2279,10 @@ export class SSHConnectionManager {
|
|
|
2101
2279
|
if (srcMd5 && dstMd5 && srcMd5 === dstMd5) {
|
|
2102
2280
|
const srcConfig = this.getConfig(sourceName);
|
|
2103
2281
|
const dstConfig = this.getConfig(destName);
|
|
2104
|
-
return (`Transfer skipped: destination already identical ` +
|
|
2282
|
+
return this.appendDebugOutput(`Transfer skipped: destination already identical ` +
|
|
2105
2283
|
`(size=${srcStat.size} bytes, md5=${srcMd5}). ` +
|
|
2106
2284
|
`${srcConfig.username}@${srcConfig.host}:${validatedSourcePath}` +
|
|
2107
|
-
` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}
|
|
2285
|
+
` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}`, debugCollector);
|
|
2108
2286
|
}
|
|
2109
2287
|
}
|
|
2110
2288
|
}
|
|
@@ -2120,11 +2298,16 @@ export class SSHConnectionManager {
|
|
|
2120
2298
|
if (settled)
|
|
2121
2299
|
return;
|
|
2122
2300
|
settled = true;
|
|
2301
|
+
if (err) {
|
|
2302
|
+
this.unpipeStream(readStream, writeStream);
|
|
2303
|
+
this.destroyStream(readStream);
|
|
2304
|
+
this.destroyStream(writeStream);
|
|
2305
|
+
}
|
|
2123
2306
|
err ? reject(err) : resolve();
|
|
2124
2307
|
};
|
|
2125
2308
|
writeStream.on("close", () => settle());
|
|
2126
|
-
writeStream.on("error", (err) => settle(
|
|
2127
|
-
readStream.on("error", (err) => settle(
|
|
2309
|
+
writeStream.on("error", (err) => settle(this.makeSftpError("Dest write error", err)));
|
|
2310
|
+
readStream.on("error", (err) => settle(this.makeSftpError("Source read error", err)));
|
|
2128
2311
|
readStream.pipe(writeStream);
|
|
2129
2312
|
});
|
|
2130
2313
|
// --- Verification ---
|
|
@@ -2148,13 +2331,22 @@ export class SSHConnectionManager {
|
|
|
2148
2331
|
}
|
|
2149
2332
|
const srcConfig = this.getConfig(sourceName);
|
|
2150
2333
|
const dstConfig = this.getConfig(destName);
|
|
2151
|
-
return (`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
|
|
2334
|
+
return this.appendDebugOutput(`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
|
|
2152
2335
|
`${srcConfig.username}@${srcConfig.host}:${sourceRemotePath}` +
|
|
2153
|
-
` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}
|
|
2336
|
+
` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}`, debugCollector);
|
|
2337
|
+
}
|
|
2338
|
+
catch (error) {
|
|
2339
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2340
|
+
this.closeClient(sourceName, true);
|
|
2341
|
+
this.closeClient(destName, true);
|
|
2342
|
+
}
|
|
2343
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2154
2344
|
}
|
|
2155
2345
|
finally {
|
|
2156
|
-
srcSftp
|
|
2157
|
-
dstSftp
|
|
2346
|
+
srcSftp?.end();
|
|
2347
|
+
dstSftp?.end();
|
|
2348
|
+
srcConnection?.close();
|
|
2349
|
+
dstConnection?.close();
|
|
2158
2350
|
}
|
|
2159
2351
|
}
|
|
2160
2352
|
/**
|
|
@@ -2164,7 +2356,7 @@ export class SSHConnectionManager {
|
|
|
2164
2356
|
return new Promise((resolve, reject) => {
|
|
2165
2357
|
sftp.stat(remotePath, (err, stats) => {
|
|
2166
2358
|
if (err) {
|
|
2167
|
-
return reject(
|
|
2359
|
+
return reject(this.makeSftpError(`Failed to stat ${label} file`, err));
|
|
2168
2360
|
}
|
|
2169
2361
|
resolve({ size: stats.size });
|
|
2170
2362
|
});
|
|
@@ -2201,40 +2393,68 @@ export class SSHConnectionManager {
|
|
|
2201
2393
|
/**
|
|
2202
2394
|
* Open an SFTP session from an existing SSH client.
|
|
2203
2395
|
*/
|
|
2204
|
-
openSftp(client, label) {
|
|
2205
|
-
|
|
2396
|
+
openSftp(client, label, timeout, debug) {
|
|
2397
|
+
const open = new Promise((resolve, reject) => {
|
|
2206
2398
|
client.sftp((err, sftp) => {
|
|
2207
2399
|
if (err) {
|
|
2208
|
-
return reject(
|
|
2400
|
+
return reject(this.makeSftpError(`SFTP connection failed (${label})`, err));
|
|
2209
2401
|
}
|
|
2402
|
+
debug?.(`[mcp] sftp channel opened (${label})`);
|
|
2210
2403
|
resolve(sftp);
|
|
2211
2404
|
});
|
|
2212
2405
|
});
|
|
2406
|
+
return this.withConnectionTimeout(open, this.normalizeConnectTimeout(timeout), `SFTP channel open (${label})`, debug, undefined, (sftp) => {
|
|
2407
|
+
try {
|
|
2408
|
+
sftp.end();
|
|
2409
|
+
}
|
|
2410
|
+
catch {
|
|
2411
|
+
// Ignore late SFTP cleanup errors.
|
|
2412
|
+
}
|
|
2413
|
+
});
|
|
2213
2414
|
}
|
|
2214
2415
|
/**
|
|
2215
2416
|
* List remote files/directories via SFTP readdir
|
|
2216
2417
|
*/
|
|
2217
|
-
async listRemoteDir(remotePath, name) {
|
|
2218
|
-
const
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
const entries = list.map((entry) => ({
|
|
2230
|
-
filename: entry.filename,
|
|
2231
|
-
isDirectory: (entry.attrs.mode & 0o40000) !== 0,
|
|
2232
|
-
size: entry.attrs.size,
|
|
2233
|
-
}));
|
|
2234
|
-
resolve(entries);
|
|
2235
|
-
});
|
|
2418
|
+
async listRemoteDir(remotePath, name, options) {
|
|
2419
|
+
const resolvedName = name || this.defaultName;
|
|
2420
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2421
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2422
|
+
debug?.(`[mcp] sftp list on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2423
|
+
let connection = null;
|
|
2424
|
+
try {
|
|
2425
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2426
|
+
reuseConnection,
|
|
2427
|
+
timeout: options?.timeout,
|
|
2428
|
+
debug,
|
|
2429
|
+
purpose: "sftp",
|
|
2236
2430
|
});
|
|
2237
|
-
|
|
2431
|
+
const entries = await new Promise((resolve, reject) => {
|
|
2432
|
+
this.openSftp(connection.client, "list", options?.timeout, debug).then((sftp) => {
|
|
2433
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
2434
|
+
sftp.end();
|
|
2435
|
+
if (err) {
|
|
2436
|
+
return reject(this.makeSftpError("Failed to list remote directory", err));
|
|
2437
|
+
}
|
|
2438
|
+
const entries = list.map((entry) => ({
|
|
2439
|
+
filename: entry.filename,
|
|
2440
|
+
isDirectory: (entry.attrs.mode & 0o40000) !== 0,
|
|
2441
|
+
size: entry.attrs.size,
|
|
2442
|
+
}));
|
|
2443
|
+
resolve(entries);
|
|
2444
|
+
});
|
|
2445
|
+
}, reject);
|
|
2446
|
+
});
|
|
2447
|
+
return entries;
|
|
2448
|
+
}
|
|
2449
|
+
catch (error) {
|
|
2450
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2451
|
+
this.closeClient(resolvedName, true);
|
|
2452
|
+
}
|
|
2453
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2454
|
+
}
|
|
2455
|
+
finally {
|
|
2456
|
+
connection?.close();
|
|
2457
|
+
}
|
|
2238
2458
|
}
|
|
2239
2459
|
/**
|
|
2240
2460
|
* Upload a local directory recursively to a remote server
|
|
@@ -2247,8 +2467,28 @@ export class SSHConnectionManager {
|
|
|
2247
2467
|
throw new ToolError("LOCAL_FILE_READ_FAILED", `Not a directory: ${localDir}`, false);
|
|
2248
2468
|
}
|
|
2249
2469
|
const results = [];
|
|
2250
|
-
const
|
|
2251
|
-
|
|
2470
|
+
const reuseConnection = options?.reuseConnection !== false;
|
|
2471
|
+
const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
|
|
2472
|
+
debug?.(`[mcp] sftp recursive upload mkdir on [${resolvedName}], reuseConnection=${reuseConnection}`);
|
|
2473
|
+
let connection = null;
|
|
2474
|
+
try {
|
|
2475
|
+
connection = await this.acquireSshClient(resolvedName, {
|
|
2476
|
+
reuseConnection,
|
|
2477
|
+
timeout: options?.timeout,
|
|
2478
|
+
debug,
|
|
2479
|
+
purpose: "sftp",
|
|
2480
|
+
});
|
|
2481
|
+
await this.sftpMkdirRecursive(connection.client, validatedRemoteDir, options?.timeout, debug);
|
|
2482
|
+
}
|
|
2483
|
+
catch (error) {
|
|
2484
|
+
if (reuseConnection && this.isConnectionError(error)) {
|
|
2485
|
+
this.closeClient(resolvedName, true);
|
|
2486
|
+
}
|
|
2487
|
+
throw this.appendDebugToError(error, debugCollector);
|
|
2488
|
+
}
|
|
2489
|
+
finally {
|
|
2490
|
+
connection?.close();
|
|
2491
|
+
}
|
|
2252
2492
|
const entries = fs.readdirSync(resolvedLocal, { withFileTypes: true });
|
|
2253
2493
|
for (const entry of entries) {
|
|
2254
2494
|
const localPath = path.join(localDir, entry.name);
|
|
@@ -2267,7 +2507,7 @@ export class SSHConnectionManager {
|
|
|
2267
2507
|
/**
|
|
2268
2508
|
* Download a remote directory recursively to a local path
|
|
2269
2509
|
*/
|
|
2270
|
-
async downloadDirectory(remoteDir, localDir, name) {
|
|
2510
|
+
async downloadDirectory(remoteDir, localDir, name, options) {
|
|
2271
2511
|
const resolvedName = name || this.defaultName;
|
|
2272
2512
|
const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
|
|
2273
2513
|
const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
|
|
@@ -2275,18 +2515,18 @@ export class SSHConnectionManager {
|
|
|
2275
2515
|
fs.mkdirSync(resolvedLocal, { recursive: true });
|
|
2276
2516
|
}
|
|
2277
2517
|
const results = [];
|
|
2278
|
-
const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName);
|
|
2518
|
+
const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName, options);
|
|
2279
2519
|
for (const entry of entries) {
|
|
2280
2520
|
if (entry.filename === "." || entry.filename === "..")
|
|
2281
2521
|
continue;
|
|
2282
2522
|
const remotePath = `${validatedRemoteDir}/${entry.filename}`;
|
|
2283
2523
|
const localPath = path.join(localDir, entry.filename);
|
|
2284
2524
|
if (entry.isDirectory) {
|
|
2285
|
-
const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName);
|
|
2525
|
+
const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName, options);
|
|
2286
2526
|
results.push(...subResults);
|
|
2287
2527
|
}
|
|
2288
2528
|
else {
|
|
2289
|
-
await this.download(remotePath, localPath, resolvedName);
|
|
2529
|
+
await this.download(remotePath, localPath, resolvedName, options);
|
|
2290
2530
|
results.push(localPath);
|
|
2291
2531
|
}
|
|
2292
2532
|
}
|
|
@@ -2295,27 +2535,23 @@ export class SSHConnectionManager {
|
|
|
2295
2535
|
/**
|
|
2296
2536
|
* Create remote directory recursively via SFTP
|
|
2297
2537
|
*/
|
|
2298
|
-
async sftpMkdirRecursive(client, remotePath) {
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2538
|
+
async sftpMkdirRecursive(client, remotePath, timeout, debug) {
|
|
2539
|
+
const sftp = await this.openSftp(client, "mkdir", timeout, debug);
|
|
2540
|
+
return new Promise((resolve) => {
|
|
2541
|
+
const parts = remotePath.split("/").filter(Boolean);
|
|
2542
|
+
let current = "";
|
|
2543
|
+
const mkdirNext = (index) => {
|
|
2544
|
+
if (index >= parts.length) {
|
|
2545
|
+
sftp.end();
|
|
2546
|
+
return resolve();
|
|
2303
2547
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
current += "/" + parts[index];
|
|
2312
|
-
sftp.mkdir(current, (err) => {
|
|
2313
|
-
// EEXIST is fine
|
|
2314
|
-
mkdirNext(index + 1);
|
|
2315
|
-
});
|
|
2316
|
-
};
|
|
2317
|
-
mkdirNext(0);
|
|
2318
|
-
});
|
|
2548
|
+
current += "/" + parts[index];
|
|
2549
|
+
sftp.mkdir(current, () => {
|
|
2550
|
+
// EEXIST is fine
|
|
2551
|
+
mkdirNext(index + 1);
|
|
2552
|
+
});
|
|
2553
|
+
};
|
|
2554
|
+
mkdirNext(0);
|
|
2319
2555
|
});
|
|
2320
2556
|
}
|
|
2321
2557
|
}
|
package/build/tools/download.js
CHANGED
|
@@ -11,10 +11,23 @@ export function registerDownloadTool(server) {
|
|
|
11
11
|
remotePath: z.string().describe("Path to the file on the remote server. Must be an absolute POSIX path (e.g. /var/log/app.log); restricted to allowedRemoteDirectories only if the server configures that list."),
|
|
12
12
|
localPath: z.string().describe("Destination path on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
|
|
13
13
|
connectionName: z.string().optional().describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
|
|
14
|
-
|
|
14
|
+
reuseConnection: z.boolean().optional().describe("Default true. Reuse the cached SSH connection for SFTP. Set false after a timeout or suspected stale cached SSH connection to force a fresh TCP/SSH connection for this transfer; the fresh connection closes afterwards."),
|
|
15
|
+
timeout: z.number().positive().optional().describe("Timeout in ms for SSH setup and SFTP channel opening. Transfer stream duration itself is not forcibly interrupted by this option."),
|
|
16
|
+
vvv: z.boolean().optional().describe("Default false. Append bounded SSH/SFTP debug output. For fresh ssh2 handshake logs, also set reuseConnection=false."),
|
|
17
|
+
fast: z.boolean().optional().describe("Default false. When true, use ssh2 fastGet for a single-file download, which performs parallel SFTP reads for better throughput."),
|
|
18
|
+
sftpConcurrency: z.number().int().positive().optional().describe("Only used when fast=true. Number of concurrent SFTP chunks for ssh2 fastGet; omitted uses ssh2's default."),
|
|
19
|
+
chunkSize: z.number().int().positive().optional().describe("Only used when fast=true. Chunk size in bytes for ssh2 fastGet; omitted uses ssh2's default."),
|
|
20
|
+
}, async ({ remotePath, localPath, connectionName, reuseConnection, timeout, vvv, fast, sftpConcurrency, chunkSize }) => {
|
|
15
21
|
try {
|
|
16
22
|
const resolvedName = sshManager.resolveServer(connectionName);
|
|
17
|
-
const result = await sshManager.download(remotePath, localPath, resolvedName
|
|
23
|
+
const result = await sshManager.download(remotePath, localPath, resolvedName, {
|
|
24
|
+
reuseConnection,
|
|
25
|
+
timeout,
|
|
26
|
+
vvv,
|
|
27
|
+
fast,
|
|
28
|
+
sftpConcurrency,
|
|
29
|
+
chunkSize,
|
|
30
|
+
});
|
|
18
31
|
return {
|
|
19
32
|
content: [{ type: "text", text: result }],
|
|
20
33
|
};
|
package/build/tools/help.js
CHANGED
|
@@ -75,29 +75,52 @@ Examples:
|
|
|
75
75
|
Parameters:
|
|
76
76
|
localPath (string, required) File path on the MCP host.
|
|
77
77
|
Must be inside the MCP working directory.
|
|
78
|
-
remotePath (string, required) Destination path on the remote server.
|
|
79
|
-
connectionName (string, see below) Target server name from list-servers.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
remotePath (string, required) Destination path on the remote server.
|
|
79
|
+
connectionName (string, see below) Target server name from list-servers.
|
|
80
|
+
skipIfIdentical (boolean, optional) Default true. Skip when remote matches.
|
|
81
|
+
reuseConnection (boolean, optional) Default true. Set false after timeout
|
|
82
|
+
or suspected stale cached SSH connection.
|
|
83
|
+
timeout (number, optional) SSH setup and SFTP channel-open timeout.
|
|
84
|
+
vvv (boolean, optional) Default false. Append bounded SSH/SFTP debug.
|
|
85
|
+
Recursive success returns structured JSON; debug is surfaced
|
|
86
|
+
for single-file results, relay results, and recursive errors.
|
|
87
|
+
fast (boolean, optional) Default false. Use ssh2 fastPut for
|
|
88
|
+
single-file upload throughput. Not multi-file concurrency.
|
|
89
|
+
sftpConcurrency (number, optional) Only with fast=true. Concurrent SFTP chunks.
|
|
90
|
+
chunkSize (number, optional) Only with fast=true. SFTP chunk bytes.
|
|
91
|
+
|
|
92
|
+
connectionName rule:
|
|
93
|
+
• If only one server is enabled → optional (auto-selected).
|
|
94
|
+
• If multiple servers are enabled → REQUIRED.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
upload { localPath: "data.csv", remotePath: "/tmp/data.csv" }
|
|
98
|
+
upload { localPath: "big.bin", remotePath: "/tmp/big.bin", fast: true,
|
|
99
|
+
sftpConcurrency: 32, chunkSize: 131072 }`,
|
|
87
100
|
"download": `download — Download a single file from a remote server over SFTP.
|
|
88
101
|
|
|
89
102
|
Parameters:
|
|
90
|
-
remotePath (string, required) File path on the remote server.
|
|
91
|
-
localPath (string, required) Destination on the MCP host.
|
|
92
|
-
Must be inside the MCP working directory.
|
|
93
|
-
connectionName (string, see below) Target server name from list-servers.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
remotePath (string, required) File path on the remote server.
|
|
104
|
+
localPath (string, required) Destination on the MCP host.
|
|
105
|
+
Must be inside the MCP working directory.
|
|
106
|
+
connectionName (string, see below) Target server name from list-servers.
|
|
107
|
+
reuseConnection (boolean, optional) Default true. Set false after timeout
|
|
108
|
+
or suspected stale cached SSH connection.
|
|
109
|
+
timeout (number, optional) SSH setup and SFTP channel-open timeout.
|
|
110
|
+
vvv (boolean, optional) Default false. Append bounded SSH/SFTP debug.
|
|
111
|
+
fast (boolean, optional) Default false. Use ssh2 fastGet for
|
|
112
|
+
single-file download throughput. Not multi-file concurrency.
|
|
113
|
+
sftpConcurrency (number, optional) Only with fast=true. Concurrent SFTP chunks.
|
|
114
|
+
chunkSize (number, optional) Only with fast=true. SFTP chunk bytes.
|
|
115
|
+
|
|
116
|
+
connectionName rule:
|
|
117
|
+
• If only one server is enabled → optional (auto-selected).
|
|
118
|
+
• If multiple servers are enabled → REQUIRED.
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
download { remotePath: "/var/log/app.log", localPath: "app.log" }
|
|
122
|
+
download { remotePath: "/tmp/big.bin", localPath: "big.bin", fast: true,
|
|
123
|
+
sftpConcurrency: 32, chunkSize: 131072 }`,
|
|
101
124
|
"transfer": `transfer — Move files between hosts (single/recursive/cross-server).
|
|
102
125
|
|
|
103
126
|
Modes:
|
|
@@ -112,15 +135,28 @@ Parameters for upload / download:
|
|
|
112
135
|
mode (string, required) "upload" or "download"
|
|
113
136
|
localPath (string, required) Path on the MCP host.
|
|
114
137
|
remotePath (string, required) Path on the remote server.
|
|
115
|
-
connectionName (string, see below) Target server name.
|
|
116
|
-
recursive (boolean, optional) True to transfer a whole directory tree.
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
connectionName (string, see below) Target server name.
|
|
139
|
+
recursive (boolean, optional) True to transfer a whole directory tree.
|
|
140
|
+
reuseConnection (boolean, optional) Default true. Set false after timeout.
|
|
141
|
+
timeout (number, optional) SSH setup and SFTP channel-open timeout.
|
|
142
|
+
vvv (boolean, optional) Default false. Append bounded SSH/SFTP debug.
|
|
143
|
+
fast (boolean, optional) Default false. upload/download only:
|
|
144
|
+
use ssh2 fastPut/fastGet for each single file. Directory
|
|
145
|
+
recursion stays sequential; no multi-file concurrency.
|
|
146
|
+
sftpConcurrency (number, optional) Only with fast=true. Concurrent SFTP chunks.
|
|
147
|
+
chunkSize (number, optional) Only with fast=true. SFTP chunk bytes.
|
|
148
|
+
|
|
149
|
+
Parameters for relay:
|
|
119
150
|
mode (string, required) "relay"
|
|
120
151
|
sourceServer (string, required) Server name to read from.
|
|
121
|
-
sourceRemotePath (string, required) File path on source server.
|
|
122
|
-
destServer (string, required) Server name to write to.
|
|
123
|
-
destRemotePath (string, required) File path on dest server.
|
|
152
|
+
sourceRemotePath (string, required) File path on source server.
|
|
153
|
+
destServer (string, required) Server name to write to.
|
|
154
|
+
destRemotePath (string, required) File path on dest server.
|
|
155
|
+
reuseConnection (boolean, optional) Default true. Set false after timeout.
|
|
156
|
+
timeout (number, optional) SSH setup and SFTP channel-open timeout.
|
|
157
|
+
vvv (boolean, optional) Default false. Append bounded SSH/SFTP debug.
|
|
158
|
+
fast (boolean, optional) Accepted but relay keeps the streaming
|
|
159
|
+
SFTP pipe path; fastGet/fastPut apply to host<->remote only.
|
|
124
160
|
|
|
125
161
|
connectionName rule (upload/download):
|
|
126
162
|
• If only one server is enabled → optional.
|
package/build/tools/transfer.js
CHANGED
|
@@ -37,22 +37,28 @@ For relay mode, specify sourceServer, sourceRemotePath, destServer, destRemotePa
|
|
|
37
37
|
"Upload: byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts (.sh / .bash / .zsh) ignore CRLF\u2194LF differences. " +
|
|
38
38
|
"Relay: size match + md5sum match on both servers (when available); falls back to transferring if md5sum is missing on either side. " +
|
|
39
39
|
"Download is never skipped. Set to false to force the transfer."),
|
|
40
|
+
reuseConnection: z.boolean().optional().describe("Default true. Reuse cached SSH connection(s) for SFTP. Set false after a timeout or suspected stale cached connection to force fresh TCP/SSH connection(s) for this transfer; fresh connections close afterwards."),
|
|
41
|
+
timeout: z.number().positive().optional().describe("Timeout in ms for SSH setup and SFTP channel opening. Transfer stream duration itself is not forcibly interrupted by this option."),
|
|
42
|
+
vvv: z.boolean().optional().describe("Default false. Append bounded SSH/SFTP debug output for single-file and relay results, and for recursive errors. For fresh ssh2 handshake logs, also set reuseConnection=false."),
|
|
43
|
+
fast: z.boolean().optional().describe("Default false. Upload/download only: use ssh2 fastPut/fastGet for single files, with parallel SFTP chunks for better throughput. Relay mode keeps the streaming pipe path."),
|
|
44
|
+
sftpConcurrency: z.number().int().positive().optional().describe("Only used when fast=true for upload/download. Number of concurrent SFTP chunks; omitted uses ssh2's default."),
|
|
45
|
+
chunkSize: z.number().int().positive().optional().describe("Only used when fast=true for upload/download. Chunk size in bytes; omitted uses ssh2's default."),
|
|
40
46
|
}, async (params) => {
|
|
41
47
|
try {
|
|
42
48
|
const { mode } = params;
|
|
43
49
|
if (mode === "relay") {
|
|
44
|
-
const { sourceServer, sourceRemotePath, destServer, destRemotePath, skipIfIdentical } = params;
|
|
50
|
+
const { sourceServer, sourceRemotePath, destServer, destRemotePath, skipIfIdentical, reuseConnection, timeout, vvv } = params;
|
|
45
51
|
if (!sourceServer || !sourceRemotePath || !destServer || !destRemotePath) {
|
|
46
52
|
return {
|
|
47
53
|
content: [{ type: "text", text: "relay mode requires: sourceServer, sourceRemotePath, destServer, destRemotePath" }],
|
|
48
54
|
isError: true,
|
|
49
55
|
};
|
|
50
56
|
}
|
|
51
|
-
const result = await sshManager.transferBetweenServers(sourceServer, sourceRemotePath, destServer, destRemotePath, { skipIfIdentical: skipIfIdentical !== false });
|
|
57
|
+
const result = await sshManager.transferBetweenServers(sourceServer, sourceRemotePath, destServer, destRemotePath, { skipIfIdentical: skipIfIdentical !== false, reuseConnection, timeout, vvv });
|
|
52
58
|
return { content: [{ type: "text", text: result }] };
|
|
53
59
|
}
|
|
54
60
|
// upload or download
|
|
55
|
-
const { localPath, remotePath, connectionName, recursive, skipIfIdentical } = params;
|
|
61
|
+
const { localPath, remotePath, connectionName, recursive, skipIfIdentical, reuseConnection, timeout, vvv, fast, sftpConcurrency, chunkSize } = params;
|
|
56
62
|
if (!localPath || !remotePath) {
|
|
57
63
|
return {
|
|
58
64
|
content: [{ type: "text", text: `${mode} mode requires: localPath, remotePath` }],
|
|
@@ -60,14 +66,15 @@ For relay mode, specify sourceServer, sourceRemotePath, destServer, destRemotePa
|
|
|
60
66
|
};
|
|
61
67
|
}
|
|
62
68
|
const resolvedName = sshManager.resolveServer(connectionName);
|
|
63
|
-
const
|
|
69
|
+
const sftpOptions = { reuseConnection, timeout, vvv, fast, sftpConcurrency, chunkSize };
|
|
70
|
+
const uploadOptions = { skipIfIdentical: skipIfIdentical !== false, ...sftpOptions };
|
|
64
71
|
if (recursive) {
|
|
65
72
|
let files;
|
|
66
73
|
if (mode === "upload") {
|
|
67
74
|
files = await sshManager.uploadDirectory(localPath, remotePath, resolvedName, uploadOptions);
|
|
68
75
|
}
|
|
69
76
|
else {
|
|
70
|
-
files = await sshManager.downloadDirectory(remotePath, localPath, resolvedName);
|
|
77
|
+
files = await sshManager.downloadDirectory(remotePath, localPath, resolvedName, sftpOptions);
|
|
71
78
|
}
|
|
72
79
|
const summary = `Recursive ${mode} complete. ${files.length} file(s) transferred.`;
|
|
73
80
|
return {
|
|
@@ -80,7 +87,7 @@ For relay mode, specify sourceServer, sourceRemotePath, destServer, destRemotePa
|
|
|
80
87
|
return { content: [{ type: "text", text: result }] };
|
|
81
88
|
}
|
|
82
89
|
else {
|
|
83
|
-
const result = await sshManager.download(remotePath, localPath, resolvedName);
|
|
90
|
+
const result = await sshManager.download(remotePath, localPath, resolvedName, sftpOptions);
|
|
84
91
|
return { content: [{ type: "text", text: result }] };
|
|
85
92
|
}
|
|
86
93
|
}
|
package/build/tools/upload.js
CHANGED
|
@@ -14,11 +14,23 @@ export function registerUploadTool(server) {
|
|
|
14
14
|
remotePath: z.string().describe("Destination path on the remote server. Must be an absolute POSIX path (e.g. /home/user/uploads/file.txt); restricted to allowedRemoteDirectories only if the server configures that list."),
|
|
15
15
|
connectionName: z.string().optional().describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
|
|
16
16
|
skipIfIdentical: z.boolean().optional().describe("When true (default), skip the upload if the remote file is already identical (byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts ignore CRLF\u2194LF differences). Set to false to force re-upload."),
|
|
17
|
-
|
|
17
|
+
reuseConnection: z.boolean().optional().describe("Default true. Reuse the cached SSH connection for SFTP. Set false after a timeout or suspected stale cached SSH connection to force a fresh TCP/SSH connection for this transfer; the fresh connection closes afterwards."),
|
|
18
|
+
timeout: z.number().positive().optional().describe("Timeout in ms for SSH setup and SFTP channel opening. Transfer stream duration itself is not forcibly interrupted by this option."),
|
|
19
|
+
vvv: z.boolean().optional().describe("Default false. Append bounded SSH/SFTP debug output. For fresh ssh2 handshake logs, also set reuseConnection=false."),
|
|
20
|
+
fast: z.boolean().optional().describe("Default false. When true, use ssh2 fastPut for a single-file upload, which performs parallel SFTP reads/writes for better throughput. If a shell script needs CRLF-to-LF conversion, the upload falls back to the normal safe path."),
|
|
21
|
+
sftpConcurrency: z.number().int().positive().optional().describe("Only used when fast=true. Number of concurrent SFTP chunks for ssh2 fastPut; omitted uses ssh2's default."),
|
|
22
|
+
chunkSize: z.number().int().positive().optional().describe("Only used when fast=true. Chunk size in bytes for ssh2 fastPut; omitted uses ssh2's default."),
|
|
23
|
+
}, async ({ localPath, remotePath, connectionName, skipIfIdentical, reuseConnection, timeout, vvv, fast, sftpConcurrency, chunkSize }) => {
|
|
18
24
|
try {
|
|
19
25
|
const resolvedName = sshManager.resolveServer(connectionName);
|
|
20
26
|
const result = await sshManager.upload(localPath, remotePath, resolvedName, {
|
|
21
27
|
skipIfIdentical: skipIfIdentical !== false,
|
|
28
|
+
reuseConnection,
|
|
29
|
+
timeout,
|
|
30
|
+
vvv,
|
|
31
|
+
fast,
|
|
32
|
+
sftpConcurrency,
|
|
33
|
+
chunkSize,
|
|
22
34
|
});
|
|
23
35
|
return {
|
|
24
36
|
content: [{ type: "text", text: result }],
|