@aaarc/handfree-ssh-mcp 1.0.7 → 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.
@@ -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.9",
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,62 @@ 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
+ }
724
780
  /**
725
781
  * Open a TCP tunnel from the target's jump chain to `config.host:config.port`
726
782
  * and return the duplex stream to be used as `sock` for the target SSH client.
@@ -1306,12 +1362,29 @@ export class SSHConnectionManager {
1306
1362
  return new Promise((resolve, reject) => {
1307
1363
  let timeoutId = null;
1308
1364
  let settled = false;
1309
- 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 = () => {
1310
1371
  if (timeoutId) {
1311
1372
  clearTimeout(timeoutId);
1312
1373
  timeoutId = null;
1313
1374
  }
1314
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
+ };
1315
1388
  const settle = (err, code) => {
1316
1389
  if (settled)
1317
1390
  return;
@@ -1330,6 +1403,24 @@ export class SSHConnectionManager {
1330
1403
  // Ignore late-stream close errors after the promise has settled.
1331
1404
  }
1332
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
+ };
1333
1424
  const armExecOpenTimeout = () => {
1334
1425
  const execOpenTimeout = Math.max(1, timeout);
1335
1426
  timeoutId = setTimeout(() => {
@@ -1356,6 +1447,11 @@ export class SSHConnectionManager {
1356
1447
  }, timeout);
1357
1448
  };
1358
1449
  armExecOpenTimeout();
1450
+ if (canObserveClientLifecycle) {
1451
+ eventedClient.once?.("error", onClientError);
1452
+ eventedClient.once?.("end", onClientEnd);
1453
+ eventedClient.once?.("close", onClientClose);
1454
+ }
1359
1455
  sinks.debug?.(`[mcp] opening exec channel for command: ${cmdString}`);
1360
1456
  try {
1361
1457
  client.exec(cmdString, (err, stream) => {
@@ -1371,7 +1467,9 @@ export class SSHConnectionManager {
1371
1467
  settle(new ToolError(code, `Command execution error: ${err.message}`, isConnectionFailure));
1372
1468
  return;
1373
1469
  }
1374
- cleanup();
1470
+ clearCommandTimer();
1471
+ channelOpened = true;
1472
+ activeStream = stream;
1375
1473
  sinks.debug?.("[mcp] exec channel opened");
1376
1474
  stream.on("data", (chunk) => {
1377
1475
  sinks.stdoutCollector?.push(chunk);
@@ -2059,37 +2157,65 @@ export class SSHConnectionManager {
2059
2157
  * Upload a local file using ssh2's parallel SFTP fastPut implementation.
2060
2158
  */
2061
2159
  async sftpFastPut(client, localPath, remotePath, options, timeout, debug) {
2062
- const sftp = await this.openSftp(client, "fastPut", timeout, debug);
2160
+ // Validate transfer options BEFORE opening the channel so invalid options
2161
+ // can never leak an SFTP channel.
2063
2162
  const transferOptions = this.createSftpTransferOptions(options);
2163
+ const sftp = await this.openSftp(client, "fastPut", timeout, debug);
2064
2164
  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) => {
2165
+ const endSftp = () => {
2166
+ try {
2067
2167
  sftp.end();
2068
- if (err) {
2069
- reject(this.makeSftpError("Fast upload failed", err));
2070
- return;
2071
- }
2072
- resolve();
2073
- });
2074
- });
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
+ }
2180
+ resolve();
2181
+ });
2182
+ }), this.normalizeConnectTimeout(timeout), `fast upload ${localPath} -> ${remotePath}`, debug, endSftp);
2183
+ }
2184
+ finally {
2185
+ endSftp();
2186
+ }
2075
2187
  }
2076
2188
  /**
2077
2189
  * Download a remote file using ssh2's parallel SFTP fastGet implementation.
2078
2190
  */
2079
2191
  async sftpFastGet(client, remotePath, localPath, options, timeout, debug) {
2080
- const sftp = await this.openSftp(client, "fastGet", timeout, debug);
2192
+ // Validate transfer options BEFORE opening the channel so invalid options
2193
+ // can never leak an SFTP channel.
2081
2194
  const transferOptions = this.createSftpTransferOptions(options);
2195
+ const sftp = await this.openSftp(client, "fastGet", timeout, debug);
2082
2196
  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) => {
2197
+ const endSftp = () => {
2198
+ try {
2085
2199
  sftp.end();
2086
- if (err) {
2087
- reject(this.makeSftpError("Fast download failed", err));
2088
- return;
2089
- }
2090
- resolve();
2091
- });
2092
- });
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();
2213
+ });
2214
+ }), this.normalizeConnectTimeout(timeout), `fast download ${remotePath} -> ${localPath}`, debug, endSftp);
2215
+ }
2216
+ finally {
2217
+ endSftp();
2218
+ }
2093
2219
  }
2094
2220
  /**
2095
2221
  * Download file
@@ -2240,6 +2366,7 @@ export class SSHConnectionManager {
2240
2366
  const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
2241
2367
  const skipIfIdentical = options?.skipIfIdentical !== false; // default true
2242
2368
  const reuseConnection = options?.reuseConnection !== false;
2369
+ const selfRelay = sourceName === destName;
2243
2370
  const { collector: debugCollector, debug } = this.createDebugCollector(options?.vvv === true);
2244
2371
  debug?.(`[mcp] sftp relay ${sourceName} -> ${destName}, reuseConnection=${reuseConnection}`);
2245
2372
  let srcConnection = null;
@@ -2253,12 +2380,16 @@ export class SSHConnectionManager {
2253
2380
  debug,
2254
2381
  purpose: "sftp",
2255
2382
  });
2256
- dstConnection = await this.acquireSshClient(destName, {
2257
- reuseConnection,
2258
- timeout: options?.timeout,
2259
- debug,
2260
- purpose: "sftp",
2261
- });
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
+ });
2262
2393
  const srcClient = srcConnection.client;
2263
2394
  const dstClient = dstConnection.client;
2264
2395
  srcSftp = await this.openSftp(srcClient, "source", options?.timeout, debug);
@@ -2338,7 +2469,8 @@ export class SSHConnectionManager {
2338
2469
  catch (error) {
2339
2470
  if (reuseConnection && this.isConnectionError(error)) {
2340
2471
  this.closeClient(sourceName, true);
2341
- this.closeClient(destName, true);
2472
+ if (!selfRelay)
2473
+ this.closeClient(destName, true);
2342
2474
  }
2343
2475
  throw this.appendDebugToError(error, debugCollector);
2344
2476
  }
@@ -2346,7 +2478,8 @@ export class SSHConnectionManager {
2346
2478
  srcSftp?.end();
2347
2479
  dstSftp?.end();
2348
2480
  srcConnection?.close();
2349
- dstConnection?.close();
2481
+ if (!selfRelay)
2482
+ dstConnection?.close();
2350
2483
  }
2351
2484
  }
2352
2485
  /**
@@ -2397,7 +2530,10 @@ export class SSHConnectionManager {
2397
2530
  const open = new Promise((resolve, reject) => {
2398
2531
  client.sftp((err, sftp) => {
2399
2532
  if (err) {
2400
- return reject(this.makeSftpError(`SFTP connection failed (${label})`, err));
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));
2401
2537
  }
2402
2538
  debug?.(`[mcp] sftp channel opened (${label})`);
2403
2539
  resolve(sftp);
@@ -2537,21 +2673,37 @@ export class SSHConnectionManager {
2537
2673
  */
2538
2674
  async sftpMkdirRecursive(client, remotePath, timeout, debug) {
2539
2675
  const sftp = await this.openSftp(client, "mkdir", timeout, debug);
2540
- return new Promise((resolve) => {
2676
+ const walk = new Promise((resolve, reject) => {
2541
2677
  const parts = remotePath.split("/").filter(Boolean);
2542
2678
  let current = "";
2543
2679
  const mkdirNext = (index) => {
2544
2680
  if (index >= parts.length) {
2545
- sftp.end();
2546
2681
  return resolve();
2547
2682
  }
2548
2683
  current += "/" + parts[index];
2549
- sftp.mkdir(current, () => {
2550
- // EEXIST is fine
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));
2691
+ }
2551
2692
  mkdirNext(index + 1);
2552
2693
  });
2553
2694
  };
2554
2695
  mkdirNext(0);
2555
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
+ }
2556
2708
  }
2557
2709
  }
@@ -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.9",
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",