@aaarc/handfree-ssh-mcp 1.0.7 → 1.0.10

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.
@@ -1,6 +1,6 @@
1
1
  export const SERVER_CONFIG = {
2
2
  name: "ssh-mcp-server",
3
- version: "1.0.7",
3
+ version: "1.0.10",
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
 
@@ -721,6 +721,116 @@ export class SSHConnectionManager {
721
721
  }
722
722
  return Math.max(1, timeout);
723
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
+ });
779
+ }
780
+ /**
781
+ * Default inactivity window (ms) for SFTP data transfers when the caller does
782
+ * not pass an explicit timeout. Deliberately generous: a healthy transfer
783
+ * moves bytes far more often than this, so tripping it means the connection
784
+ * is effectively dead, not merely slow.
785
+ */
786
+ static DEFAULT_TRANSFER_STALL_TIMEOUT_MS = 60_000;
787
+ /** Chunk size for buffered SFTP writes, sized to yield per-ack progress. */
788
+ static SFTP_WRITE_CHUNK_BYTES = 256 * 1024;
789
+ /**
790
+ * Resolve the inactivity window for a data transfer: the caller's timeout if
791
+ * valid, otherwise the generous default so a dead connection never hangs.
792
+ */
793
+ transferStallTimeout(timeout) {
794
+ return (this.normalizeConnectTimeout(timeout) ??
795
+ SSHConnectionManager.DEFAULT_TRANSFER_STALL_TIMEOUT_MS);
796
+ }
797
+ /**
798
+ * Pipe a readable into a writable under the inactivity watchdog, aborting if
799
+ * no bytes flow for the stall window. Used by the non-fast download and relay
800
+ * paths, which otherwise settle only on close/finish/error and so hang on a
801
+ * dead reused connection. Read-side `data` events drive progress; the caller
802
+ * supplies error mappers so each path keeps its own error code/message.
803
+ */
804
+ pipeWithInactivityTimeout(readStream, writeStream, timeoutMs, description, debug, mapReadError, mapWriteError) {
805
+ const teardown = () => {
806
+ this.unpipeStream(readStream, writeStream);
807
+ this.destroyStream(readStream);
808
+ this.destroyStream(writeStream);
809
+ };
810
+ return this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
811
+ let settled = false;
812
+ const settle = (err) => {
813
+ if (settled)
814
+ return;
815
+ settled = true;
816
+ if (err) {
817
+ teardown();
818
+ reject(err);
819
+ }
820
+ else {
821
+ resolve();
822
+ }
823
+ };
824
+ readStream.on("data", () => onProgress());
825
+ // Resolve on whichever completion event the write side emits: local fs
826
+ // writables emit "finish", SFTP writables emit "close".
827
+ writeStream.on("finish", () => settle());
828
+ writeStream.on("close", () => settle());
829
+ writeStream.on("error", (err) => settle(mapWriteError(err)));
830
+ readStream.on("error", (err) => settle(mapReadError(err)));
831
+ readStream.pipe(writeStream);
832
+ }), timeoutMs, description, debug, teardown);
833
+ }
724
834
  /**
725
835
  * Open a TCP tunnel from the target's jump chain to `config.host:config.port`
726
836
  * and return the duplex stream to be used as `sock` for the target SSH client.
@@ -1306,12 +1416,29 @@ export class SSHConnectionManager {
1306
1416
  return new Promise((resolve, reject) => {
1307
1417
  let timeoutId = null;
1308
1418
  let settled = false;
1309
- const cleanup = () => {
1419
+ let channelOpened = false;
1420
+ let activeStream = null;
1421
+ const eventedClient = client;
1422
+ const canObserveClientLifecycle = typeof eventedClient.once === "function" &&
1423
+ typeof eventedClient.removeListener === "function";
1424
+ const clearCommandTimer = () => {
1310
1425
  if (timeoutId) {
1311
1426
  clearTimeout(timeoutId);
1312
1427
  timeoutId = null;
1313
1428
  }
1314
1429
  };
1430
+ const removeClientLifecycleListeners = () => {
1431
+ if (!canObserveClientLifecycle) {
1432
+ return;
1433
+ }
1434
+ eventedClient.removeListener?.("error", onClientError);
1435
+ eventedClient.removeListener?.("end", onClientEnd);
1436
+ eventedClient.removeListener?.("close", onClientClose);
1437
+ };
1438
+ const cleanup = () => {
1439
+ clearCommandTimer();
1440
+ removeClientLifecycleListeners();
1441
+ };
1315
1442
  const settle = (err, code) => {
1316
1443
  if (settled)
1317
1444
  return;
@@ -1330,6 +1457,24 @@ export class SSHConnectionManager {
1330
1457
  // Ignore late-stream close errors after the promise has settled.
1331
1458
  }
1332
1459
  };
1460
+ const failOnClientEvent = (event, message) => {
1461
+ const phase = channelOpened ? "running command" : "opening command channel";
1462
+ const detail = message ? `: ${message}` : "";
1463
+ sinks.debug?.(`[mcp] SSH client ${event} while ${phase}${detail}`);
1464
+ if (activeStream) {
1465
+ closeLateStream(activeStream);
1466
+ }
1467
+ settle(new ToolError("SSH_CONNECTION_FAILED", `SSH connection ${event} while ${phase}${detail}`, true));
1468
+ };
1469
+ const onClientError = (err) => {
1470
+ failOnClientEvent("failed", err.message);
1471
+ };
1472
+ const onClientEnd = () => {
1473
+ failOnClientEvent("ended");
1474
+ };
1475
+ const onClientClose = () => {
1476
+ failOnClientEvent("closed");
1477
+ };
1333
1478
  const armExecOpenTimeout = () => {
1334
1479
  const execOpenTimeout = Math.max(1, timeout);
1335
1480
  timeoutId = setTimeout(() => {
@@ -1356,6 +1501,11 @@ export class SSHConnectionManager {
1356
1501
  }, timeout);
1357
1502
  };
1358
1503
  armExecOpenTimeout();
1504
+ if (canObserveClientLifecycle) {
1505
+ eventedClient.once?.("error", onClientError);
1506
+ eventedClient.once?.("end", onClientEnd);
1507
+ eventedClient.once?.("close", onClientClose);
1508
+ }
1359
1509
  sinks.debug?.(`[mcp] opening exec channel for command: ${cmdString}`);
1360
1510
  try {
1361
1511
  client.exec(cmdString, (err, stream) => {
@@ -1371,7 +1521,9 @@ export class SSHConnectionManager {
1371
1521
  settle(new ToolError(code, `Command execution error: ${err.message}`, isConnectionFailure));
1372
1522
  return;
1373
1523
  }
1374
- cleanup();
1524
+ clearCommandTimer();
1525
+ channelOpened = true;
1526
+ activeStream = stream;
1375
1527
  sinks.debug?.("[mcp] exec channel opened");
1376
1528
  stream.on("data", (chunk) => {
1377
1529
  sinks.stdoutCollector?.push(chunk);
@@ -2013,83 +2165,164 @@ export class SSHConnectionManager {
2013
2165
  }
2014
2166
  /**
2015
2167
  * Read an SFTP file fully into a Buffer.
2168
+ *
2169
+ * Guarded by an inactivity watchdog: if no bytes arrive for the stall window
2170
+ * (a dead reused connection can open the channel but never stream) the read
2171
+ * aborts with a retriable error instead of hanging. An actively-streaming
2172
+ * read is never killed because each chunk resets the watchdog.
2016
2173
  */
2017
2174
  async sftpReadBuffer(client, remotePath, expectedSize, timeout, debug) {
2018
2175
  const sftp = await this.openSftp(client, "read", timeout, debug);
2019
- return new Promise((resolve, reject) => {
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", () => {
2176
+ const endSftp = () => {
2177
+ try {
2032
2178
  sftp.end();
2033
- if (received !== expectedSize) {
2034
- return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
2035
- }
2036
- resolve(Buffer.concat(chunks, received));
2037
- });
2038
- });
2179
+ }
2180
+ catch {
2181
+ // Ignore late SFTP cleanup errors.
2182
+ }
2183
+ };
2184
+ try {
2185
+ return await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2186
+ const chunks = [];
2187
+ let received = 0;
2188
+ const stream = sftp.createReadStream(remotePath);
2189
+ stream.on("data", (chunk) => {
2190
+ chunks.push(chunk);
2191
+ received += chunk.length;
2192
+ onProgress();
2193
+ });
2194
+ stream.on("error", (e) => {
2195
+ reject(this.makeSftpError("Remote read failed", e));
2196
+ });
2197
+ stream.on("end", () => {
2198
+ if (received !== expectedSize) {
2199
+ return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
2200
+ }
2201
+ resolve(Buffer.concat(chunks, received));
2202
+ });
2203
+ }), this.transferStallTimeout(timeout), `remote read ${remotePath}`, debug, endSftp);
2204
+ }
2205
+ finally {
2206
+ endSftp();
2207
+ }
2039
2208
  }
2040
2209
  /**
2041
2210
  * Write a Buffer to an SFTP path (overwrites if exists).
2211
+ *
2212
+ * The payload is written in chunks so the inactivity watchdog gets a real
2213
+ * progress signal per acknowledged chunk: a dead reused connection that opens
2214
+ * the channel but never acks a write aborts with a retriable error instead of
2215
+ * hanging, while an actively-flushing write is never killed.
2042
2216
  */
2043
2217
  async sftpWriteBuffer(client, remotePath, payload, timeout, debug) {
2044
2218
  const sftp = await this.openSftp(client, "write", timeout, debug);
2045
- return new Promise((resolve, reject) => {
2046
- const writeStream = sftp.createWriteStream(remotePath);
2047
- writeStream.on("close", () => {
2048
- sftp.end();
2049
- resolve();
2050
- });
2051
- writeStream.on("error", (e) => {
2219
+ const endSftp = () => {
2220
+ try {
2052
2221
  sftp.end();
2053
- reject(this.makeSftpError("File upload failed", e));
2054
- });
2055
- writeStream.end(payload);
2056
- });
2222
+ }
2223
+ catch {
2224
+ // Ignore late SFTP cleanup errors.
2225
+ }
2226
+ };
2227
+ try {
2228
+ await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2229
+ const writeStream = sftp.createWriteStream(remotePath);
2230
+ let offset = 0;
2231
+ let ended = false;
2232
+ writeStream.on("close", () => resolve());
2233
+ writeStream.on("error", (e) => {
2234
+ reject(this.makeSftpError("File upload failed", e));
2235
+ });
2236
+ const writeNext = () => {
2237
+ if (offset >= payload.length) {
2238
+ if (!ended) {
2239
+ ended = true;
2240
+ writeStream.end();
2241
+ }
2242
+ return;
2243
+ }
2244
+ const end = Math.min(offset + SSHConnectionManager.SFTP_WRITE_CHUNK_BYTES, payload.length);
2245
+ const chunk = payload.subarray(offset, end);
2246
+ offset = end;
2247
+ writeStream.write(chunk, (err) => {
2248
+ if (err) {
2249
+ // The "error" event will reject; nothing else to do here.
2250
+ return;
2251
+ }
2252
+ onProgress();
2253
+ writeNext();
2254
+ });
2255
+ };
2256
+ writeNext();
2257
+ }), this.transferStallTimeout(timeout), `upload ${remotePath}`, debug, endSftp);
2258
+ }
2259
+ finally {
2260
+ endSftp();
2261
+ }
2057
2262
  }
2058
2263
  /**
2059
2264
  * Upload a local file using ssh2's parallel SFTP fastPut implementation.
2060
2265
  */
2061
2266
  async sftpFastPut(client, localPath, remotePath, options, timeout, debug) {
2062
- const sftp = await this.openSftp(client, "fastPut", timeout, debug);
2267
+ // Validate transfer options BEFORE opening the channel so invalid options
2268
+ // can never leak an SFTP channel.
2063
2269
  const transferOptions = this.createSftpTransferOptions(options);
2270
+ const sftp = await this.openSftp(client, "fastPut", timeout, debug);
2064
2271
  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) => {
2272
+ const endSftp = () => {
2273
+ try {
2067
2274
  sftp.end();
2068
- if (err) {
2069
- reject(this.makeSftpError("Fast upload failed", err));
2070
- return;
2071
- }
2072
- resolve();
2073
- });
2074
- });
2275
+ }
2276
+ catch {
2277
+ // Ignore late SFTP cleanup errors.
2278
+ }
2279
+ };
2280
+ try {
2281
+ await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2282
+ sftp.fastPut(localPath, remotePath, { ...transferOptions, step: () => onProgress() }, (err) => {
2283
+ if (err) {
2284
+ reject(this.makeSftpError("Fast upload failed", err));
2285
+ return;
2286
+ }
2287
+ resolve();
2288
+ });
2289
+ }), this.transferStallTimeout(timeout), `fast upload ${localPath} -> ${remotePath}`, debug, endSftp);
2290
+ }
2291
+ finally {
2292
+ endSftp();
2293
+ }
2075
2294
  }
2076
2295
  /**
2077
2296
  * Download a remote file using ssh2's parallel SFTP fastGet implementation.
2078
2297
  */
2079
2298
  async sftpFastGet(client, remotePath, localPath, options, timeout, debug) {
2080
- const sftp = await this.openSftp(client, "fastGet", timeout, debug);
2299
+ // Validate transfer options BEFORE opening the channel so invalid options
2300
+ // can never leak an SFTP channel.
2081
2301
  const transferOptions = this.createSftpTransferOptions(options);
2302
+ const sftp = await this.openSftp(client, "fastGet", timeout, debug);
2082
2303
  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) => {
2304
+ const endSftp = () => {
2305
+ try {
2085
2306
  sftp.end();
2086
- if (err) {
2087
- reject(this.makeSftpError("Fast download failed", err));
2088
- return;
2089
- }
2090
- resolve();
2091
- });
2092
- });
2307
+ }
2308
+ catch {
2309
+ // Ignore late SFTP cleanup errors.
2310
+ }
2311
+ };
2312
+ try {
2313
+ await this.runWithInactivityTimeout((onProgress) => new Promise((resolve, reject) => {
2314
+ sftp.fastGet(remotePath, localPath, { ...transferOptions, step: () => onProgress() }, (err) => {
2315
+ if (err) {
2316
+ reject(this.makeSftpError("Fast download failed", err));
2317
+ return;
2318
+ }
2319
+ resolve();
2320
+ });
2321
+ }), this.transferStallTimeout(timeout), `fast download ${remotePath} -> ${localPath}`, debug, endSftp);
2322
+ }
2323
+ finally {
2324
+ endSftp();
2325
+ }
2093
2326
  }
2094
2327
  /**
2095
2328
  * Download file
@@ -2114,27 +2347,17 @@ export class SSHConnectionManager {
2114
2347
  return this.appendDebugOutput("File downloaded successfully via fast SFTP", debugCollector);
2115
2348
  }
2116
2349
  const sftp = await this.openSftp(connection.client, "download", options?.timeout, debug);
2117
- await new Promise((resolve, reject) => {
2118
- let settled = false;
2119
- const readStream = sftp.createReadStream(validatedRemotePath);
2120
- const writeStream = fs.createWriteStream(validatedLocalPath);
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
- }
2350
+ try {
2351
+ await this.pipeWithInactivityTimeout(sftp.createReadStream(validatedRemotePath), fs.createWriteStream(validatedLocalPath), this.transferStallTimeout(options?.timeout), `download ${validatedRemotePath} -> ${validatedLocalPath}`, debug, (err) => this.makeSftpError("File download failed", err), (err) => new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false));
2352
+ }
2353
+ finally {
2354
+ try {
2130
2355
  sftp.end();
2131
- err ? reject(err) : resolve();
2132
- };
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)));
2136
- readStream.pipe(writeStream);
2137
- });
2356
+ }
2357
+ catch {
2358
+ // Ignore late SFTP cleanup errors.
2359
+ }
2360
+ }
2138
2361
  return this.appendDebugOutput("File downloaded successfully", debugCollector);
2139
2362
  }
2140
2363
  catch (error) {
@@ -2240,6 +2463,7 @@ export class SSHConnectionManager {
2240
2463
  const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
2241
2464
  const skipIfIdentical = options?.skipIfIdentical !== false; // default true
2242
2465
  const reuseConnection = options?.reuseConnection !== false;
2466
+ const selfRelay = sourceName === destName;
2243
2467
  const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
2244
2468
  debug?.(`[mcp] sftp relay ${sourceName} -> ${destName}, reuseConnection=${reuseConnection}`);
2245
2469
  let srcConnection = null;
@@ -2253,12 +2477,16 @@ export class SSHConnectionManager {
2253
2477
  debug,
2254
2478
  purpose: "sftp",
2255
2479
  });
2256
- dstConnection = await this.acquireSshClient(destName, {
2257
- reuseConnection,
2258
- timeout: options?.timeout,
2259
- debug,
2260
- purpose: "sftp",
2261
- });
2480
+ // Same host on both ends: reuse the one SSH client (two SFTP channels are
2481
+ // still opened below) so we never open or close a second connection.
2482
+ dstConnection = selfRelay
2483
+ ? srcConnection
2484
+ : await this.acquireSshClient(destName, {
2485
+ reuseConnection,
2486
+ timeout: options?.timeout,
2487
+ debug,
2488
+ purpose: "sftp",
2489
+ });
2262
2490
  const srcClient = srcConnection.client;
2263
2491
  const dstClient = dstConnection.client;
2264
2492
  srcSftp = await this.openSftp(srcClient, "source", options?.timeout, debug);
@@ -2286,30 +2514,11 @@ export class SSHConnectionManager {
2286
2514
  }
2287
2515
  }
2288
2516
  }
2289
- const readStream = srcSftp.createReadStream(validatedSourcePath);
2290
- const writeStream = dstSftp.createWriteStream(validatedDestPath);
2291
2517
  // Bind the validated paths into the rest of the verification flow so
2292
2518
  // we never accidentally fall back to the un-validated originals.
2293
2519
  sourceRemotePath = validatedSourcePath;
2294
2520
  destRemotePath = validatedDestPath;
2295
- await new Promise((resolve, reject) => {
2296
- let settled = false;
2297
- const settle = (err) => {
2298
- if (settled)
2299
- return;
2300
- settled = true;
2301
- if (err) {
2302
- this.unpipeStream(readStream, writeStream);
2303
- this.destroyStream(readStream);
2304
- this.destroyStream(writeStream);
2305
- }
2306
- err ? reject(err) : resolve();
2307
- };
2308
- writeStream.on("close", () => settle());
2309
- writeStream.on("error", (err) => settle(this.makeSftpError("Dest write error", err)));
2310
- readStream.on("error", (err) => settle(this.makeSftpError("Source read error", err)));
2311
- readStream.pipe(writeStream);
2312
- });
2521
+ await this.pipeWithInactivityTimeout(srcSftp.createReadStream(validatedSourcePath), dstSftp.createWriteStream(validatedDestPath), this.transferStallTimeout(options?.timeout), `relay ${sourceName}:${validatedSourcePath} -> ${destName}:${validatedDestPath}`, debug, (err) => this.makeSftpError("Source read error", err), (err) => this.makeSftpError("Dest write error", err));
2313
2522
  // --- Verification ---
2314
2523
  const dstStat = await this.sftpStat(dstSftp, destRemotePath, "dest");
2315
2524
  const verification = [];
@@ -2338,7 +2547,8 @@ export class SSHConnectionManager {
2338
2547
  catch (error) {
2339
2548
  if (reuseConnection && this.isConnectionError(error)) {
2340
2549
  this.closeClient(sourceName, true);
2341
- this.closeClient(destName, true);
2550
+ if (!selfRelay)
2551
+ this.closeClient(destName, true);
2342
2552
  }
2343
2553
  throw this.appendDebugToError(error, debugCollector);
2344
2554
  }
@@ -2346,7 +2556,8 @@ export class SSHConnectionManager {
2346
2556
  srcSftp?.end();
2347
2557
  dstSftp?.end();
2348
2558
  srcConnection?.close();
2349
- dstConnection?.close();
2559
+ if (!selfRelay)
2560
+ dstConnection?.close();
2350
2561
  }
2351
2562
  }
2352
2563
  /**
@@ -2397,7 +2608,10 @@ export class SSHConnectionManager {
2397
2608
  const open = new Promise((resolve, reject) => {
2398
2609
  client.sftp((err, sftp) => {
2399
2610
  if (err) {
2400
- return reject(this.makeSftpError(`SFTP connection failed (${label})`, err));
2611
+ // A client that cannot open an SFTP channel is unusable, so treat the
2612
+ // failure as connection-shaped regardless of wording. This lets the
2613
+ // caller force-drop the stale cached client and self-heal on retry.
2614
+ return reject(new ToolError("SSH_CONNECTION_FAILED", `SFTP connection failed (${label}): ${err.message}`, true));
2401
2615
  }
2402
2616
  debug?.(`[mcp] sftp channel opened (${label})`);
2403
2617
  resolve(sftp);
@@ -2537,21 +2751,37 @@ export class SSHConnectionManager {
2537
2751
  */
2538
2752
  async sftpMkdirRecursive(client, remotePath, timeout, debug) {
2539
2753
  const sftp = await this.openSftp(client, "mkdir", timeout, debug);
2540
- return new Promise((resolve) => {
2754
+ const walk = new Promise((resolve, reject) => {
2541
2755
  const parts = remotePath.split("/").filter(Boolean);
2542
2756
  let current = "";
2543
2757
  const mkdirNext = (index) => {
2544
2758
  if (index >= parts.length) {
2545
- sftp.end();
2546
2759
  return resolve();
2547
2760
  }
2548
2761
  current += "/" + parts[index];
2549
- sftp.mkdir(current, () => {
2550
- // EEXIST is fine
2762
+ sftp.mkdir(current, (err) => {
2763
+ // An existing directory (or other non-connection failure) is fine and
2764
+ // just means the path component already exists. A connection-shaped
2765
+ // error means the channel died mid-walk -- surface it instead of
2766
+ // silently marching on (and eventually hanging the per-file uploads).
2767
+ if (err && this.isConnectionShapedMessage(err.message)) {
2768
+ return reject(this.makeSftpError("Remote mkdir failed", err));
2769
+ }
2551
2770
  mkdirNext(index + 1);
2552
2771
  });
2553
2772
  };
2554
2773
  mkdirNext(0);
2555
2774
  });
2775
+ try {
2776
+ await this.withConnectionTimeout(walk, this.normalizeConnectTimeout(timeout), `SFTP mkdir ${remotePath}`, debug);
2777
+ }
2778
+ finally {
2779
+ try {
2780
+ sftp.end();
2781
+ }
2782
+ catch {
2783
+ // Ignore late SFTP cleanup errors.
2784
+ }
2785
+ }
2556
2786
  }
2557
2787
  }
@@ -19,6 +19,7 @@ export function registerExecuteCommandTool(server) {
19
19
  .describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
20
20
  timeout: z
21
21
  .number()
22
+ .positive()
22
23
  .optional()
23
24
  .describe("Timeout in milliseconds applied separately to SSH connection setup, exec-channel opening, and remote command execution for each attempt. Defaults to 300000 when stream=true and 30000 when stream=false. Increase this for long-running commands; reduce it for fast probes."),
24
25
  stream: z
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaarc/handfree-ssh-mcp",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
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",
@@ -19,7 +19,7 @@
19
19
  "access": "public"
20
20
  },
21
21
  "scripts": {
22
- "test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
22
+ "test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js build/tests/sftp-stall-timeout.test.js",
23
23
  "test:config": "npm run build && node --test build/tests/config.test.js",
24
24
  "test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
25
25
  "test:tools": "npm run build && node --test build/tests/tools.test.js",