@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 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`, optional `skipIfIdentical`) |
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` reuses the cached SSH client by default (`reuseConnection: true`). If a command times out or you suspect the cached `ssh2.Client` is stale while native `ssh` works, retry with `reuseConnection: false`; that command opens a fresh SSH connection and closes it afterwards.
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 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.
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
- - **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.
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
- - [ ] **SFTP retry parity**: extend the retry-with-reconnect loop to `upload` / `download` / `transfer`
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
 
@@ -1,6 +1,6 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.0.5",
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. 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.
15
- 8. For SSH/channel diagnostics, add vvv=true. Use it with reuseConnection=false when you need fresh handshake/debug output.
16
- 9. Use upload and download for single-file SFTP transfers. Use transfer for recursive directory transfers or cross-server relay.
17
- 10. Call help or help { tool: "<name>" } for detailed per-tool parameter docs and examples.
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
- for (const name of toReset) {
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
- for (const target of affected) {
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
- * Open a one-shot SSH client for a single execute-command call.
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 = `__command__:${key}:${Date.now()}:${crypto.randomBytes(4).toString("hex")}`;
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 command client [${key}] emitted error after settle: ${err.message}`, "error");
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 command on [${key}]`, "info");
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 command [${key}]`, "info");
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 command connection [${key}] failed: ${err.message}`, true));
635
- const onClose = () => done(new ToolError("SSH_CONNECTION_FAILED", `SSH command connection [${key}] closed before ready`, true));
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 command connection [${key}] timed out after ${connectTimeout}ms`, true));
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 command connection for [${key}]`, "info");
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, Math.min(timeout, 30000));
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, Math.min(timeout, 30000));
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
- if (!collector || collector.getTotalBytes() === 0) {
1430
+ const debugBlock = this.formatDebugBlock(collector);
1431
+ if (!debugBlock) {
1387
1432
  return result;
1388
1433
  }
1389
- const snapshot = collector.getSnapshot();
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
- if (!collector || collector.getTotalBytes() === 0) {
1437
+ const debugBlock = this.formatDebugBlock(collector);
1438
+ if (!debugBlock) {
1397
1439
  return error;
1398
1440
  }
1399
- const snapshot = collector.getSnapshot();
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 = reuseConnection
1484
- ? { client: await this.ensureConnected(name, timeout), close: () => { } }
1485
- : await this.connectCommandClient(key, timeout, debug);
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 = reuseConnection
1577
- ? { client: await this.ensureConnected(name, timeout), close: () => { } }
1578
- : await this.connectCommandClient(key, timeout, debug);
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
- let payload;
1744
- try {
1745
- payload = fs.readFileSync(validatedLocalPath);
1746
- }
1747
- catch (e) {
1748
- throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file '${validatedLocalPath}': ${e.message}`, false);
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
- const client = await this.ensureConnected(resolvedName);
1757
- // ---- Skip-if-identical check ----
1758
- const isShellScript = SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(path.extname(validatedLocalPath).toLowerCase());
1759
- if (skipIfIdentical) {
1760
- const decision = await this.shouldSkipUpload(client, payload, validatedRemotePath, isShellScript);
1761
- if (decision.skip) {
1762
- return (`Upload skipped: remote file '${validatedRemotePath}' is already identical to local ` +
1763
- `'${validatedLocalPath}' (${decision.reason}).${crlfNote}`);
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
- client.sftp((err, sftp) => {
1911
- if (err) {
1912
- return reject(new ToolError("SFTP_ERROR", `SFTP open failed: ${err.message}`, true));
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
- const chunks = [];
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
- client.sftp((err, sftp) => {
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
- return reject(new ToolError("SFTP_ERROR", `SFTP open failed: ${err.message}`, true));
2069
+ reject(this.makeSftpError("Fast upload failed", err));
2070
+ return;
1943
2071
  }
1944
- const writeStream = sftp.createWriteStream(remotePath);
1945
- writeStream.on("close", () => {
1946
- sftp.end();
1947
- resolve();
1948
- });
1949
- writeStream.on("error", (e) => {
1950
- sftp.end();
1951
- reject(new ToolError("SFTP_ERROR", `File upload failed: ${e.message}`, false));
1952
- });
1953
- writeStream.end(payload);
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
- const client = await this.ensureConnected(resolvedName);
1965
- return new Promise((resolve, reject) => {
1966
- client.sftp((err, sftp) => {
1967
- if (err) {
1968
- return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
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 cleanup = () => {
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
- cleanup();
1977
- resolve("File downloaded successfully");
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 srcClient = await this.ensureConnected(sourceName);
2084
- const dstClient = await this.ensureConnected(destName);
2085
- const srcSftp = await this.openSftp(srcClient, "source");
2086
- const dstSftp = await this.openSftp(dstClient, "dest");
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(new ToolError("SFTP_ERROR", `Dest write error: ${err.message}`, false)));
2127
- readStream.on("error", (err) => settle(new ToolError("SFTP_ERROR", `Source read error: ${err.message}`, false)));
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.end();
2157
- dstSftp.end();
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(new ToolError("SFTP_ERROR", `Failed to stat ${label} file: ${err.message}`, false));
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
- return new Promise((resolve, reject) => {
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(new ToolError("SFTP_ERROR", `SFTP connection failed (${label}): ${err.message}`, true));
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 client = await this.ensureConnected(name);
2219
- return new Promise((resolve, reject) => {
2220
- client.sftp((err, sftp) => {
2221
- if (err) {
2222
- return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
2223
- }
2224
- sftp.readdir(remotePath, (err, list) => {
2225
- sftp.end();
2226
- if (err) {
2227
- return reject(new ToolError("SFTP_ERROR", `Failed to list remote directory: ${err.message}`, false));
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 client = await this.ensureConnected(resolvedName);
2251
- await this.sftpMkdirRecursive(client, validatedRemoteDir);
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
- return new Promise((resolve, reject) => {
2300
- client.sftp((err, sftp) => {
2301
- if (err) {
2302
- return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
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
- const parts = remotePath.split("/").filter(Boolean);
2305
- let current = "";
2306
- const mkdirNext = (index) => {
2307
- if (index >= parts.length) {
2308
- sftp.end();
2309
- return resolve();
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
  }
@@ -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
- }, async ({ remotePath, localPath, connectionName }) => {
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
  };
@@ -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
- connectionName rule:
82
- If only one server is enabled → optional (auto-selected).
83
- If multiple servers are enabled → REQUIRED.
84
-
85
- Example:
86
- upload { localPath: "data.csv", remotePath: "/tmp/data.csv" }`,
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
- connectionName rule:
96
- If only one server is enabled → optional (auto-selected).
97
- If multiple servers are enabled → REQUIRED.
98
-
99
- Example:
100
- download { remotePath: "/var/log/app.log", localPath: "app.log" }`,
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
- Parameters for relay:
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.
@@ -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 uploadOptions = { skipIfIdentical: skipIfIdentical !== false };
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
  }
@@ -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
- }, async ({ localPath, remotePath, connectionName, skipIfIdentical }) => {
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 }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaarc/handfree-ssh-mcp",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
5
5
  "main": "build/index.js",
6
6
  "type": "module",