@aaarc/handfree-ssh-mcp 1.0.6 → 1.0.9

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.
@@ -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,63 @@ 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);
723
+ }
724
+ /**
725
+ * Run a transfer that reports progress through a callback, aborting it if no
726
+ * progress arrives within `timeoutMs` (an INACTIVITY watchdog, not a total
727
+ * duration cap -- a long but actively-streaming transfer is never killed).
728
+ * When `timeoutMs` is falsy the operation runs unbounded, preserving the
729
+ * previous behavior. `onTimeout` is invoked to tear down the stalled session.
730
+ */
731
+ runWithInactivityTimeout(start, timeoutMs, description, debug, onTimeout) {
732
+ if (!timeoutMs || timeoutMs <= 0) {
733
+ return start(() => { });
734
+ }
735
+ return new Promise((resolve, reject) => {
736
+ let settled = false;
737
+ let timer = null;
738
+ const clear = () => {
739
+ if (timer) {
740
+ clearTimeout(timer);
741
+ timer = null;
742
+ }
743
+ };
744
+ const arm = () => {
745
+ clear();
746
+ timer = setTimeout(() => {
747
+ if (settled)
748
+ return;
749
+ settled = true;
750
+ debug?.(`[mcp] ${description} stalled: no progress for ${timeoutMs}ms`);
751
+ try {
752
+ onTimeout?.();
753
+ }
754
+ catch {
755
+ // Ignore cleanup failures after a stall.
756
+ }
757
+ reject(new ToolError("SSH_CONNECTION_FAILED", `${description} stalled: no progress for ${timeoutMs}ms`, true));
758
+ }, timeoutMs);
759
+ };
760
+ const onProgress = () => {
761
+ if (!settled)
762
+ arm();
763
+ };
764
+ arm();
765
+ start(onProgress).then((value) => {
766
+ if (settled)
767
+ return;
768
+ settled = true;
769
+ clear();
770
+ resolve(value);
771
+ }, (err) => {
772
+ if (settled)
773
+ return;
774
+ settled = true;
775
+ clear();
776
+ reject(err);
777
+ });
778
+ });
704
779
  }
705
780
  /**
706
781
  * Open a TCP tunnel from the target's jump chain to `config.host:config.port`
@@ -994,6 +1069,31 @@ export class SSHConnectionManager {
994
1069
  msg.includes("no response from server") ||
995
1070
  msg.includes("timed out"));
996
1071
  }
1072
+ makeSftpError(context, error) {
1073
+ const connectionFailure = this.isConnectionShapedMessage(error.message);
1074
+ return new ToolError(connectionFailure ? "SSH_CONNECTION_FAILED" : "SFTP_ERROR", `${context}: ${error.message}`, connectionFailure);
1075
+ }
1076
+ createSftpTransferOptions(options) {
1077
+ const transferOptions = {};
1078
+ const concurrency = this.optionalPositiveInteger(options?.sftpConcurrency, "sftpConcurrency");
1079
+ const chunkSize = this.optionalPositiveInteger(options?.chunkSize, "chunkSize");
1080
+ if (concurrency !== undefined) {
1081
+ transferOptions.concurrency = concurrency;
1082
+ }
1083
+ if (chunkSize !== undefined) {
1084
+ transferOptions.chunkSize = chunkSize;
1085
+ }
1086
+ return transferOptions;
1087
+ }
1088
+ optionalPositiveInteger(value, name) {
1089
+ if (value === undefined) {
1090
+ return undefined;
1091
+ }
1092
+ if (!Number.isInteger(value) || value <= 0) {
1093
+ throw new ToolError("INVALID_CONFIGURATION", `${name} must be a positive integer`, false);
1094
+ }
1095
+ return value;
1096
+ }
997
1097
  /**
998
1098
  * Force reconnect to SSH server
999
1099
  * @private
@@ -1262,12 +1362,29 @@ export class SSHConnectionManager {
1262
1362
  return new Promise((resolve, reject) => {
1263
1363
  let timeoutId = null;
1264
1364
  let settled = false;
1265
- const cleanup = () => {
1365
+ let channelOpened = false;
1366
+ let activeStream = null;
1367
+ const eventedClient = client;
1368
+ const canObserveClientLifecycle = typeof eventedClient.once === "function" &&
1369
+ typeof eventedClient.removeListener === "function";
1370
+ const clearCommandTimer = () => {
1266
1371
  if (timeoutId) {
1267
1372
  clearTimeout(timeoutId);
1268
1373
  timeoutId = null;
1269
1374
  }
1270
1375
  };
1376
+ const removeClientLifecycleListeners = () => {
1377
+ if (!canObserveClientLifecycle) {
1378
+ return;
1379
+ }
1380
+ eventedClient.removeListener?.("error", onClientError);
1381
+ eventedClient.removeListener?.("end", onClientEnd);
1382
+ eventedClient.removeListener?.("close", onClientClose);
1383
+ };
1384
+ const cleanup = () => {
1385
+ clearCommandTimer();
1386
+ removeClientLifecycleListeners();
1387
+ };
1271
1388
  const settle = (err, code) => {
1272
1389
  if (settled)
1273
1390
  return;
@@ -1286,8 +1403,26 @@ export class SSHConnectionManager {
1286
1403
  // Ignore late-stream close errors after the promise has settled.
1287
1404
  }
1288
1405
  };
1406
+ const failOnClientEvent = (event, message) => {
1407
+ const phase = channelOpened ? "running command" : "opening command channel";
1408
+ const detail = message ? `: ${message}` : "";
1409
+ sinks.debug?.(`[mcp] SSH client ${event} while ${phase}${detail}`);
1410
+ if (activeStream) {
1411
+ closeLateStream(activeStream);
1412
+ }
1413
+ settle(new ToolError("SSH_CONNECTION_FAILED", `SSH connection ${event} while ${phase}${detail}`, true));
1414
+ };
1415
+ const onClientError = (err) => {
1416
+ failOnClientEvent("failed", err.message);
1417
+ };
1418
+ const onClientEnd = () => {
1419
+ failOnClientEvent("ended");
1420
+ };
1421
+ const onClientClose = () => {
1422
+ failOnClientEvent("closed");
1423
+ };
1289
1424
  const armExecOpenTimeout = () => {
1290
- const execOpenTimeout = Math.max(1, Math.min(timeout, 30000));
1425
+ const execOpenTimeout = Math.max(1, timeout);
1291
1426
  timeoutId = setTimeout(() => {
1292
1427
  sinks.debug?.(`[mcp] exec channel open timed out after ${execOpenTimeout}ms`);
1293
1428
  settle(new ToolError("SSH_CONNECTION_FAILED", `SSH exec channel timeout: no response from server within ${execOpenTimeout}ms while opening command channel`, true));
@@ -1312,6 +1447,11 @@ export class SSHConnectionManager {
1312
1447
  }, timeout);
1313
1448
  };
1314
1449
  armExecOpenTimeout();
1450
+ if (canObserveClientLifecycle) {
1451
+ eventedClient.once?.("error", onClientError);
1452
+ eventedClient.once?.("end", onClientEnd);
1453
+ eventedClient.once?.("close", onClientClose);
1454
+ }
1315
1455
  sinks.debug?.(`[mcp] opening exec channel for command: ${cmdString}`);
1316
1456
  try {
1317
1457
  client.exec(cmdString, (err, stream) => {
@@ -1327,7 +1467,9 @@ export class SSHConnectionManager {
1327
1467
  settle(new ToolError(code, `Command execution error: ${err.message}`, isConnectionFailure));
1328
1468
  return;
1329
1469
  }
1330
- cleanup();
1470
+ clearCommandTimer();
1471
+ channelOpened = true;
1472
+ activeStream = stream;
1331
1473
  sinks.debug?.("[mcp] exec channel opened");
1332
1474
  stream.on("data", (chunk) => {
1333
1475
  sinks.stdoutCollector?.push(chunk);
@@ -1383,24 +1525,18 @@ export class SSHConnectionManager {
1383
1525
  };
1384
1526
  }
1385
1527
  appendDebugOutput(result, collector) {
1386
- if (!collector || collector.getTotalBytes() === 0) {
1528
+ const debugBlock = this.formatDebugBlock(collector);
1529
+ if (!debugBlock) {
1387
1530
  return result;
1388
1531
  }
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()}`;
1532
+ return `${result}\n\n${debugBlock}`;
1394
1533
  }
1395
1534
  appendDebugToError(error, collector) {
1396
- if (!collector || collector.getTotalBytes() === 0) {
1535
+ const debugBlock = this.formatDebugBlock(collector);
1536
+ if (!debugBlock) {
1397
1537
  return error;
1398
1538
  }
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()}`;
1539
+ const message = `${error.message}\n\n${debugBlock}`;
1404
1540
  if (error instanceof ToolError) {
1405
1541
  return new ToolError(error.code, message, error.retriable);
1406
1542
  }
@@ -1408,6 +1544,40 @@ export class SSHConnectionManager {
1408
1544
  wrapped.name = error.name;
1409
1545
  return wrapped;
1410
1546
  }
1547
+ formatDebugBlock(collector) {
1548
+ if (!collector || collector.getTotalBytes() === 0) {
1549
+ return null;
1550
+ }
1551
+ const snapshot = collector.getSnapshot();
1552
+ const header = snapshot.truncated
1553
+ ? `[SSH DEBUG TRUNCATED: dropped ${snapshot.droppedBytes} bytes]\n`
1554
+ : "[SSH DEBUG]\n";
1555
+ return `${header}${snapshot.tail.toString("utf8").trimEnd()}`;
1556
+ }
1557
+ unpipeStream(readStream, writeStream) {
1558
+ const candidate = readStream;
1559
+ if (typeof candidate.unpipe !== "function") {
1560
+ return;
1561
+ }
1562
+ try {
1563
+ candidate.unpipe(writeStream);
1564
+ }
1565
+ catch {
1566
+ // Ignore cleanup errors after the original stream failure.
1567
+ }
1568
+ }
1569
+ destroyStream(stream) {
1570
+ const candidate = stream;
1571
+ if (typeof candidate.destroy !== "function") {
1572
+ return;
1573
+ }
1574
+ try {
1575
+ candidate.destroy();
1576
+ }
1577
+ catch {
1578
+ // Ignore cleanup errors after the original stream failure.
1579
+ }
1580
+ }
1411
1581
  /**
1412
1582
  * Assemble the final user-visible result string from collected tails.
1413
1583
  * Adds a truncation header (with the on-disk log path) and the legacy
@@ -1480,13 +1650,13 @@ export class SSHConnectionManager {
1480
1650
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1481
1651
  try {
1482
1652
  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);
1653
+ const commandConnection = await this.acquireSshClient(key, {
1654
+ reuseConnection,
1655
+ timeout,
1656
+ debug,
1657
+ purpose: "command",
1658
+ });
1486
1659
  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
1660
  // Per-attempt collectors + log writer. We rebuild them on each retry
1491
1661
  // so partial output from a failed attempt is not mixed into the next.
1492
1662
  const stdoutCollector = new OutputCollector(maxOutputBytes);
@@ -1573,13 +1743,13 @@ export class SSHConnectionManager {
1573
1743
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1574
1744
  try {
1575
1745
  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);
1746
+ const commandConnection = await this.acquireSshClient(key, {
1747
+ reuseConnection,
1748
+ timeout,
1749
+ debug,
1750
+ purpose: "command",
1751
+ });
1579
1752
  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
1753
  // Per-attempt collectors + log writer. onProgress keeps streaming
1584
1754
  // every byte live; only the final returned string is capped.
1585
1755
  const stdoutCollector = new OutputCollector(maxOutputBytes);
@@ -1725,6 +1895,8 @@ export class SSHConnectionManager {
1725
1895
  */
1726
1896
  async upload(localPath, remotePath, name, options) {
1727
1897
  const resolvedName = name || this.defaultName;
1898
+ const reuseConnection = options?.reuseConnection !== false;
1899
+ const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
1728
1900
  const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
1729
1901
  const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
1730
1902
  const skipIfIdentical = options?.skipIfIdentical !== false; // default true
@@ -1740,32 +1912,67 @@ export class SSHConnectionManager {
1740
1912
  throw new ToolError("LOCAL_FILE_READ_FAILED", `Local path '${validatedLocalPath}' is not a regular file (size=${stat.size}). ` +
1741
1913
  `Use uploadDirectory / recursive=true for directories.`, false);
1742
1914
  }
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);
1915
+ const isShellScript = SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(path.extname(validatedLocalPath).toLowerCase());
1916
+ const fastUpload = options?.fast === true;
1917
+ const mustReadPayload = skipIfIdentical || !fastUpload || isShellScript;
1918
+ let payload = null;
1919
+ let crlfFixed = {
1920
+ buffer: Buffer.alloc(0),
1921
+ fixed: false,
1922
+ replacedCount: 0,
1923
+ };
1924
+ if (mustReadPayload) {
1925
+ try {
1926
+ payload = fs.readFileSync(validatedLocalPath);
1927
+ }
1928
+ catch (e) {
1929
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file '${validatedLocalPath}': ${e.message}`, false);
1930
+ }
1931
+ // ---- CRLF auto-fix for shell scripts ----
1932
+ crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
1933
+ payload = crlfFixed.buffer;
1749
1934
  }
1750
- // ---- CRLF auto-fix for shell scripts ----
1751
- const crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
1752
- payload = crlfFixed.buffer;
1753
1935
  const crlfNote = crlfFixed.fixed
1754
1936
  ? ` (CRLF→LF auto-fix: converted ${crlfFixed.replacedCount} line endings to LF before upload because target is a shell script).`
1755
1937
  : "";
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}`);
1938
+ debug?.(`[mcp] sftp upload on [${resolvedName}], reuseConnection=${reuseConnection}`);
1939
+ let connection = null;
1940
+ try {
1941
+ connection = await this.acquireSshClient(resolvedName, {
1942
+ reuseConnection,
1943
+ timeout: options?.timeout,
1944
+ debug,
1945
+ purpose: "sftp",
1946
+ });
1947
+ const client = connection.client;
1948
+ // ---- Skip-if-identical check ----
1949
+ if (skipIfIdentical) {
1950
+ const decision = await this.shouldSkipUpload(client, payload, validatedRemotePath, isShellScript, options?.timeout, debug);
1951
+ if (decision.skip) {
1952
+ return this.appendDebugOutput(`Upload skipped: remote file '${validatedRemotePath}' is already identical to local ` +
1953
+ `'${validatedLocalPath}' (${decision.reason}).${crlfNote}`, debugCollector);
1954
+ }
1955
+ }
1956
+ // ---- Actually upload ----
1957
+ if (fastUpload && !crlfFixed.fixed) {
1958
+ await this.sftpFastPut(client, validatedLocalPath, validatedRemotePath, options, options?.timeout, debug);
1959
+ }
1960
+ else {
1961
+ await this.sftpWriteBuffer(client, validatedRemotePath, payload, options?.timeout, debug);
1962
+ }
1963
+ const uploadedBytes = payload?.length ?? stat.size;
1964
+ const modeNote = fastUpload && !crlfFixed.fixed ? " via fast SFTP" : "";
1965
+ return this.appendDebugOutput(`File uploaded successfully (${uploadedBytes} bytes${modeNote})${crlfNote}`, debugCollector);
1966
+ }
1967
+ catch (error) {
1968
+ if (reuseConnection && this.isConnectionError(error)) {
1969
+ this.closeClient(resolvedName, true);
1764
1970
  }
1971
+ throw this.appendDebugToError(error, debugCollector);
1972
+ }
1973
+ finally {
1974
+ connection?.close();
1765
1975
  }
1766
- // ---- Actually upload ----
1767
- await this.sftpWriteBuffer(client, validatedRemotePath, payload);
1768
- return `File uploaded successfully (${payload.length} bytes)${crlfNote}`;
1769
1976
  }
1770
1977
  /**
1771
1978
  * Threshold above which we use MD5 hash comparison instead of byte-content
@@ -1822,10 +2029,10 @@ export class SSHConnectionManager {
1822
2029
  * Any error during the check is treated as 'don't skip' (i.e. fall through
1823
2030
  * to a normal upload), since correctness wins over an optimization.
1824
2031
  */
1825
- async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic) {
2032
+ async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic, timeout, debug) {
1826
2033
  let remoteSize;
1827
2034
  try {
1828
- const sftp = await this.openSftp(client, "dest");
2035
+ const sftp = await this.openSftp(client, "dest", timeout, debug);
1829
2036
  try {
1830
2037
  const stat = await this.sftpStat(sftp, remotePath, "dest");
1831
2038
  remoteSize = stat.size;
@@ -1846,7 +2053,7 @@ export class SSHConnectionManager {
1846
2053
  }
1847
2054
  let remoteBuf;
1848
2055
  try {
1849
- remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
2056
+ remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
1850
2057
  }
1851
2058
  catch {
1852
2059
  return { skip: false, reason: "remote-read-failed-during-content-compare" };
@@ -1869,7 +2076,7 @@ export class SSHConnectionManager {
1869
2076
  // Byte-content compare
1870
2077
  let remoteBuf;
1871
2078
  try {
1872
- remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
2079
+ remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize, timeout, debug);
1873
2080
  }
1874
2081
  catch {
1875
2082
  return { skip: false, reason: "remote-read-failed-during-content-compare" };
@@ -1905,88 +2112,166 @@ export class SSHConnectionManager {
1905
2112
  /**
1906
2113
  * Read an SFTP file fully into a Buffer.
1907
2114
  */
1908
- async sftpReadBuffer(client, remotePath, expectedSize) {
2115
+ async sftpReadBuffer(client, remotePath, expectedSize, timeout, debug) {
2116
+ const sftp = await this.openSftp(client, "read", timeout, debug);
1909
2117
  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));
2118
+ const chunks = [];
2119
+ let received = 0;
2120
+ const stream = sftp.createReadStream(remotePath);
2121
+ stream.on("data", (chunk) => {
2122
+ chunks.push(chunk);
2123
+ received += chunk.length;
2124
+ });
2125
+ stream.on("error", (e) => {
2126
+ sftp.end();
2127
+ reject(this.makeSftpError("Remote read failed", e));
2128
+ });
2129
+ stream.on("end", () => {
2130
+ sftp.end();
2131
+ if (received !== expectedSize) {
2132
+ return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
1913
2133
  }
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
- });
2134
+ resolve(Buffer.concat(chunks, received));
1932
2135
  });
1933
2136
  });
1934
2137
  }
1935
2138
  /**
1936
2139
  * Write a Buffer to an SFTP path (overwrites if exists).
1937
2140
  */
1938
- async sftpWriteBuffer(client, remotePath, payload) {
2141
+ async sftpWriteBuffer(client, remotePath, payload, timeout, debug) {
2142
+ const sftp = await this.openSftp(client, "write", timeout, debug);
1939
2143
  return new Promise((resolve, reject) => {
1940
- client.sftp((err, sftp) => {
1941
- if (err) {
1942
- return reject(new ToolError("SFTP_ERROR", `SFTP open failed: ${err.message}`, true));
1943
- }
1944
- const writeStream = sftp.createWriteStream(remotePath);
1945
- writeStream.on("close", () => {
1946
- sftp.end();
2144
+ const writeStream = sftp.createWriteStream(remotePath);
2145
+ writeStream.on("close", () => {
2146
+ sftp.end();
2147
+ resolve();
2148
+ });
2149
+ writeStream.on("error", (e) => {
2150
+ sftp.end();
2151
+ reject(this.makeSftpError("File upload failed", e));
2152
+ });
2153
+ writeStream.end(payload);
2154
+ });
2155
+ }
2156
+ /**
2157
+ * Upload a local file using ssh2's parallel SFTP fastPut implementation.
2158
+ */
2159
+ async sftpFastPut(client, localPath, remotePath, options, timeout, debug) {
2160
+ // Validate transfer options BEFORE opening the channel so invalid options
2161
+ // can never leak an SFTP channel.
2162
+ const transferOptions = this.createSftpTransferOptions(options);
2163
+ const sftp = await this.openSftp(client, "fastPut", timeout, debug);
2164
+ debug?.(`[mcp] fastPut ${localPath} -> ${remotePath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
2165
+ const endSftp = () => {
2166
+ try {
2167
+ sftp.end();
2168
+ }
2169
+ catch {
2170
+ // Ignore late SFTP cleanup errors.
2171
+ }
2172
+ };
2173
+ try {
2174
+ await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2175
+ sftp.fastPut(localPath, remotePath, { ...transferOptions, step: () => onProgress() }, (err) => {
2176
+ if (err) {
2177
+ reject(this.makeSftpError("Fast upload failed", err));
2178
+ return;
2179
+ }
1947
2180
  resolve();
1948
2181
  });
1949
- writeStream.on("error", (e) => {
1950
- sftp.end();
1951
- reject(new ToolError("SFTP_ERROR", `File upload failed: ${e.message}`, false));
2182
+ }), this.normalizeConnectTimeout(timeout), `fast upload ${localPath} -> ${remotePath}`, debug, endSftp);
2183
+ }
2184
+ finally {
2185
+ endSftp();
2186
+ }
2187
+ }
2188
+ /**
2189
+ * Download a remote file using ssh2's parallel SFTP fastGet implementation.
2190
+ */
2191
+ async sftpFastGet(client, remotePath, localPath, options, timeout, debug) {
2192
+ // Validate transfer options BEFORE opening the channel so invalid options
2193
+ // can never leak an SFTP channel.
2194
+ const transferOptions = this.createSftpTransferOptions(options);
2195
+ const sftp = await this.openSftp(client, "fastGet", timeout, debug);
2196
+ debug?.(`[mcp] fastGet ${remotePath} -> ${localPath}, concurrency=${transferOptions.concurrency ?? "ssh2-default"}, chunkSize=${transferOptions.chunkSize ?? "ssh2-default"}`);
2197
+ const endSftp = () => {
2198
+ try {
2199
+ sftp.end();
2200
+ }
2201
+ catch {
2202
+ // Ignore late SFTP cleanup errors.
2203
+ }
2204
+ };
2205
+ try {
2206
+ await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2207
+ sftp.fastGet(remotePath, localPath, { ...transferOptions, step: () => onProgress() }, (err) => {
2208
+ if (err) {
2209
+ reject(this.makeSftpError("Fast download failed", err));
2210
+ return;
2211
+ }
2212
+ resolve();
1952
2213
  });
1953
- writeStream.end(payload);
1954
- });
1955
- });
2214
+ }), this.normalizeConnectTimeout(timeout), `fast download ${remotePath} -> ${localPath}`, debug, endSftp);
2215
+ }
2216
+ finally {
2217
+ endSftp();
2218
+ }
1956
2219
  }
1957
2220
  /**
1958
2221
  * Download file
1959
2222
  */
1960
- async download(remotePath, localPath, name) {
2223
+ async download(remotePath, localPath, name, options) {
1961
2224
  const resolvedName = name || this.defaultName;
2225
+ const reuseConnection = options?.reuseConnection !== false;
2226
+ const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
1962
2227
  const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
1963
2228
  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
- }
2229
+ debug?.(`[mcp] sftp download on [${resolvedName}], reuseConnection=${reuseConnection}`);
2230
+ let connection = null;
2231
+ try {
2232
+ connection = await this.acquireSshClient(resolvedName, {
2233
+ reuseConnection,
2234
+ timeout: options?.timeout,
2235
+ debug,
2236
+ purpose: "sftp",
2237
+ });
2238
+ if (options?.fast === true) {
2239
+ await this.sftpFastGet(connection.client, validatedRemotePath, validatedLocalPath, options, options?.timeout, debug);
2240
+ return this.appendDebugOutput("File downloaded successfully via fast SFTP", debugCollector);
2241
+ }
2242
+ const sftp = await this.openSftp(connection.client, "download", options?.timeout, debug);
2243
+ await new Promise((resolve, reject) => {
2244
+ let settled = false;
1970
2245
  const readStream = sftp.createReadStream(validatedRemotePath);
1971
2246
  const writeStream = fs.createWriteStream(validatedLocalPath);
1972
- const cleanup = () => {
2247
+ const settle = (err) => {
2248
+ if (settled)
2249
+ return;
2250
+ settled = true;
2251
+ if (err) {
2252
+ this.unpipeStream(readStream, writeStream);
2253
+ this.destroyStream(readStream);
2254
+ this.destroyStream(writeStream);
2255
+ }
1973
2256
  sftp.end();
2257
+ err ? reject(err) : resolve();
1974
2258
  };
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
- });
2259
+ writeStream.on("finish", () => settle());
2260
+ writeStream.on("error", (err) => settle(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false)));
2261
+ readStream.on("error", (err) => settle(this.makeSftpError("File download failed", err)));
1987
2262
  readStream.pipe(writeStream);
1988
2263
  });
1989
- });
2264
+ return this.appendDebugOutput("File downloaded successfully", debugCollector);
2265
+ }
2266
+ catch (error) {
2267
+ if (reuseConnection && this.isConnectionError(error)) {
2268
+ this.closeClient(resolvedName, true);
2269
+ }
2270
+ throw this.appendDebugToError(error, debugCollector);
2271
+ }
2272
+ finally {
2273
+ connection?.close();
2274
+ }
1990
2275
  }
1991
2276
  /**
1992
2277
  * Disconnect SSH connection
@@ -2080,11 +2365,35 @@ export class SSHConnectionManager {
2080
2365
  const validatedSourcePath = this.validateRemotePath(sourceRemotePath, sourceName);
2081
2366
  const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
2082
2367
  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");
2368
+ const reuseConnection = options?.reuseConnection !== false;
2369
+ const selfRelay = sourceName === destName;
2370
+ const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
2371
+ debug?.(`[mcp] sftp relay ${sourceName} -> ${destName}, reuseConnection=${reuseConnection}`);
2372
+ let srcConnection = null;
2373
+ let dstConnection = null;
2374
+ let srcSftp = null;
2375
+ let dstSftp = null;
2087
2376
  try {
2377
+ srcConnection = await this.acquireSshClient(sourceName, {
2378
+ reuseConnection,
2379
+ timeout: options?.timeout,
2380
+ debug,
2381
+ purpose: "sftp",
2382
+ });
2383
+ // Same host on both ends: reuse the one SSH client (two SFTP channels are
2384
+ // still opened below) so we never open or close a second connection.
2385
+ dstConnection = selfRelay
2386
+ ? srcConnection
2387
+ : await this.acquireSshClient(destName, {
2388
+ reuseConnection,
2389
+ timeout: options?.timeout,
2390
+ debug,
2391
+ purpose: "sftp",
2392
+ });
2393
+ const srcClient = srcConnection.client;
2394
+ const dstClient = dstConnection.client;
2395
+ srcSftp = await this.openSftp(srcClient, "source", options?.timeout, debug);
2396
+ dstSftp = await this.openSftp(dstClient, "dest", options?.timeout, debug);
2088
2397
  // Get source file size before transfer
2089
2398
  const srcStat = await this.sftpStat(srcSftp, validatedSourcePath, "source");
2090
2399
  // Skip-if-identical: same size on both sides AND matching md5sum (when
@@ -2101,10 +2410,10 @@ export class SSHConnectionManager {
2101
2410
  if (srcMd5 && dstMd5 && srcMd5 === dstMd5) {
2102
2411
  const srcConfig = this.getConfig(sourceName);
2103
2412
  const dstConfig = this.getConfig(destName);
2104
- return (`Transfer skipped: destination already identical ` +
2413
+ return this.appendDebugOutput(`Transfer skipped: destination already identical ` +
2105
2414
  `(size=${srcStat.size} bytes, md5=${srcMd5}). ` +
2106
2415
  `${srcConfig.username}@${srcConfig.host}:${validatedSourcePath}` +
2107
- ` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}`);
2416
+ ` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}`, debugCollector);
2108
2417
  }
2109
2418
  }
2110
2419
  }
@@ -2120,11 +2429,16 @@ export class SSHConnectionManager {
2120
2429
  if (settled)
2121
2430
  return;
2122
2431
  settled = true;
2432
+ if (err) {
2433
+ this.unpipeStream(readStream, writeStream);
2434
+ this.destroyStream(readStream);
2435
+ this.destroyStream(writeStream);
2436
+ }
2123
2437
  err ? reject(err) : resolve();
2124
2438
  };
2125
2439
  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)));
2440
+ writeStream.on("error", (err) => settle(this.makeSftpError("Dest write error", err)));
2441
+ readStream.on("error", (err) => settle(this.makeSftpError("Source read error", err)));
2128
2442
  readStream.pipe(writeStream);
2129
2443
  });
2130
2444
  // --- Verification ---
@@ -2148,13 +2462,24 @@ export class SSHConnectionManager {
2148
2462
  }
2149
2463
  const srcConfig = this.getConfig(sourceName);
2150
2464
  const dstConfig = this.getConfig(destName);
2151
- return (`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
2465
+ return this.appendDebugOutput(`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
2152
2466
  `${srcConfig.username}@${srcConfig.host}:${sourceRemotePath}` +
2153
- ` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}`);
2467
+ ` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}`, debugCollector);
2468
+ }
2469
+ catch (error) {
2470
+ if (reuseConnection && this.isConnectionError(error)) {
2471
+ this.closeClient(sourceName, true);
2472
+ if (!selfRelay)
2473
+ this.closeClient(destName, true);
2474
+ }
2475
+ throw this.appendDebugToError(error, debugCollector);
2154
2476
  }
2155
2477
  finally {
2156
- srcSftp.end();
2157
- dstSftp.end();
2478
+ srcSftp?.end();
2479
+ dstSftp?.end();
2480
+ srcConnection?.close();
2481
+ if (!selfRelay)
2482
+ dstConnection?.close();
2158
2483
  }
2159
2484
  }
2160
2485
  /**
@@ -2164,7 +2489,7 @@ export class SSHConnectionManager {
2164
2489
  return new Promise((resolve, reject) => {
2165
2490
  sftp.stat(remotePath, (err, stats) => {
2166
2491
  if (err) {
2167
- return reject(new ToolError("SFTP_ERROR", `Failed to stat ${label} file: ${err.message}`, false));
2492
+ return reject(this.makeSftpError(`Failed to stat ${label} file`, err));
2168
2493
  }
2169
2494
  resolve({ size: stats.size });
2170
2495
  });
@@ -2201,40 +2526,71 @@ export class SSHConnectionManager {
2201
2526
  /**
2202
2527
  * Open an SFTP session from an existing SSH client.
2203
2528
  */
2204
- openSftp(client, label) {
2205
- return new Promise((resolve, reject) => {
2529
+ openSftp(client, label, timeout, debug) {
2530
+ const open = new Promise((resolve, reject) => {
2206
2531
  client.sftp((err, sftp) => {
2207
2532
  if (err) {
2208
- return reject(new ToolError("SFTP_ERROR", `SFTP connection failed (${label}): ${err.message}`, true));
2533
+ // A client that cannot open an SFTP channel is unusable, so treat the
2534
+ // failure as connection-shaped regardless of wording. This lets the
2535
+ // caller force-drop the stale cached client and self-heal on retry.
2536
+ return reject(new ToolError("SSH_CONNECTION_FAILED", `SFTP connection failed (${label}): ${err.message}`, true));
2209
2537
  }
2538
+ debug?.(`[mcp] sftp channel opened (${label})`);
2210
2539
  resolve(sftp);
2211
2540
  });
2212
2541
  });
2542
+ return this.withConnectionTimeout(open, this.normalizeConnectTimeout(timeout), `SFTP channel open (${label})`, debug, undefined, (sftp) => {
2543
+ try {
2544
+ sftp.end();
2545
+ }
2546
+ catch {
2547
+ // Ignore late SFTP cleanup errors.
2548
+ }
2549
+ });
2213
2550
  }
2214
2551
  /**
2215
2552
  * List remote files/directories via SFTP readdir
2216
2553
  */
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
- });
2554
+ async listRemoteDir(remotePath, name, options) {
2555
+ const resolvedName = name || this.defaultName;
2556
+ const reuseConnection = options?.reuseConnection !== false;
2557
+ const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
2558
+ debug?.(`[mcp] sftp list on [${resolvedName}], reuseConnection=${reuseConnection}`);
2559
+ let connection = null;
2560
+ try {
2561
+ connection = await this.acquireSshClient(resolvedName, {
2562
+ reuseConnection,
2563
+ timeout: options?.timeout,
2564
+ debug,
2565
+ purpose: "sftp",
2236
2566
  });
2237
- });
2567
+ const entries = await new Promise((resolve, reject) => {
2568
+ this.openSftp(connection.client, "list", options?.timeout, debug).then((sftp) => {
2569
+ sftp.readdir(remotePath, (err, list) => {
2570
+ sftp.end();
2571
+ if (err) {
2572
+ return reject(this.makeSftpError("Failed to list remote directory", err));
2573
+ }
2574
+ const entries = list.map((entry) => ({
2575
+ filename: entry.filename,
2576
+ isDirectory: (entry.attrs.mode & 0o40000) !== 0,
2577
+ size: entry.attrs.size,
2578
+ }));
2579
+ resolve(entries);
2580
+ });
2581
+ }, reject);
2582
+ });
2583
+ return entries;
2584
+ }
2585
+ catch (error) {
2586
+ if (reuseConnection && this.isConnectionError(error)) {
2587
+ this.closeClient(resolvedName, true);
2588
+ }
2589
+ throw this.appendDebugToError(error, debugCollector);
2590
+ }
2591
+ finally {
2592
+ connection?.close();
2593
+ }
2238
2594
  }
2239
2595
  /**
2240
2596
  * Upload a local directory recursively to a remote server
@@ -2247,8 +2603,28 @@ export class SSHConnectionManager {
2247
2603
  throw new ToolError("LOCAL_FILE_READ_FAILED", `Not a directory: ${localDir}`, false);
2248
2604
  }
2249
2605
  const results = [];
2250
- const client = await this.ensureConnected(resolvedName);
2251
- await this.sftpMkdirRecursive(client, validatedRemoteDir);
2606
+ const reuseConnection = options?.reuseConnection !== false;
2607
+ const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
2608
+ debug?.(`[mcp] sftp recursive upload mkdir on [${resolvedName}], reuseConnection=${reuseConnection}`);
2609
+ let connection = null;
2610
+ try {
2611
+ connection = await this.acquireSshClient(resolvedName, {
2612
+ reuseConnection,
2613
+ timeout: options?.timeout,
2614
+ debug,
2615
+ purpose: "sftp",
2616
+ });
2617
+ await this.sftpMkdirRecursive(connection.client, validatedRemoteDir, options?.timeout, debug);
2618
+ }
2619
+ catch (error) {
2620
+ if (reuseConnection && this.isConnectionError(error)) {
2621
+ this.closeClient(resolvedName, true);
2622
+ }
2623
+ throw this.appendDebugToError(error, debugCollector);
2624
+ }
2625
+ finally {
2626
+ connection?.close();
2627
+ }
2252
2628
  const entries = fs.readdirSync(resolvedLocal, { withFileTypes: true });
2253
2629
  for (const entry of entries) {
2254
2630
  const localPath = path.join(localDir, entry.name);
@@ -2267,7 +2643,7 @@ export class SSHConnectionManager {
2267
2643
  /**
2268
2644
  * Download a remote directory recursively to a local path
2269
2645
  */
2270
- async downloadDirectory(remoteDir, localDir, name) {
2646
+ async downloadDirectory(remoteDir, localDir, name, options) {
2271
2647
  const resolvedName = name || this.defaultName;
2272
2648
  const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
2273
2649
  const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
@@ -2275,18 +2651,18 @@ export class SSHConnectionManager {
2275
2651
  fs.mkdirSync(resolvedLocal, { recursive: true });
2276
2652
  }
2277
2653
  const results = [];
2278
- const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName);
2654
+ const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName, options);
2279
2655
  for (const entry of entries) {
2280
2656
  if (entry.filename === "." || entry.filename === "..")
2281
2657
  continue;
2282
2658
  const remotePath = `${validatedRemoteDir}/${entry.filename}`;
2283
2659
  const localPath = path.join(localDir, entry.filename);
2284
2660
  if (entry.isDirectory) {
2285
- const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName);
2661
+ const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName, options);
2286
2662
  results.push(...subResults);
2287
2663
  }
2288
2664
  else {
2289
- await this.download(remotePath, localPath, resolvedName);
2665
+ await this.download(remotePath, localPath, resolvedName, options);
2290
2666
  results.push(localPath);
2291
2667
  }
2292
2668
  }
@@ -2295,27 +2671,39 @@ export class SSHConnectionManager {
2295
2671
  /**
2296
2672
  * Create remote directory recursively via SFTP
2297
2673
  */
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));
2674
+ async sftpMkdirRecursive(client, remotePath, timeout, debug) {
2675
+ const sftp = await this.openSftp(client, "mkdir", timeout, debug);
2676
+ const walk = new Promise((resolve, reject) => {
2677
+ const parts = remotePath.split("/").filter(Boolean);
2678
+ let current = "";
2679
+ const mkdirNext = (index) => {
2680
+ if (index >= parts.length) {
2681
+ return resolve();
2303
2682
  }
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();
2683
+ current += "/" + parts[index];
2684
+ sftp.mkdir(current, (err) => {
2685
+ // An existing directory (or other non-connection failure) is fine and
2686
+ // just means the path component already exists. A connection-shaped
2687
+ // error means the channel died mid-walk -- surface it instead of
2688
+ // silently marching on (and eventually hanging the per-file uploads).
2689
+ if (err && this.isConnectionShapedMessage(err.message)) {
2690
+ return reject(this.makeSftpError("Remote mkdir failed", err));
2310
2691
  }
2311
- current += "/" + parts[index];
2312
- sftp.mkdir(current, (err) => {
2313
- // EEXIST is fine
2314
- mkdirNext(index + 1);
2315
- });
2316
- };
2317
- mkdirNext(0);
2318
- });
2692
+ mkdirNext(index + 1);
2693
+ });
2694
+ };
2695
+ mkdirNext(0);
2319
2696
  });
2697
+ try {
2698
+ await this.withConnectionTimeout(walk, this.normalizeConnectTimeout(timeout), `SFTP mkdir ${remotePath}`, debug);
2699
+ }
2700
+ finally {
2701
+ try {
2702
+ sftp.end();
2703
+ }
2704
+ catch {
2705
+ // Ignore late SFTP cleanup errors.
2706
+ }
2707
+ }
2320
2708
  }
2321
2709
  }