@aaarc/handfree-ssh-mcp 1.0.4 → 1.0.6

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.
@@ -41,6 +41,7 @@ export const BUILT_IN_DESTRUCTIVE_GUARDS = [
41
41
  */
42
42
  export class SSHConnectionManager {
43
43
  static instance;
44
+ static CLOSED_CLIENT_ERROR_SINK = () => { };
44
45
  clients = new Map();
45
46
  configs = {};
46
47
  connected = new Map();
@@ -196,6 +197,27 @@ export class SSHConnectionManager {
196
197
  this.connected.set(name, false);
197
198
  this.connecting.delete(name);
198
199
  }
200
+ closeConnection(name) {
201
+ const key = this.resolveServer(name);
202
+ this.getConfig(key);
203
+ const affected = new Set([key]);
204
+ const changed = new Set([key]);
205
+ for (const target of Object.keys(this.configs)) {
206
+ if (target === key)
207
+ continue;
208
+ if (this.jumpChainTouchesChanged(target, this.configs, changed)) {
209
+ affected.add(target);
210
+ }
211
+ }
212
+ for (const target of affected) {
213
+ this.closeClient(target, true);
214
+ this.statusCache.delete(target);
215
+ }
216
+ return {
217
+ requested: key,
218
+ closed: Array.from(affected),
219
+ };
220
+ }
199
221
  bumpConnectionGeneration(name) {
200
222
  this.connectionGenerations.set(name, (this.connectionGenerations.get(name) ?? 0) + 1);
201
223
  }
@@ -278,7 +300,7 @@ export class SSHConnectionManager {
278
300
  * Concurrent callers for the same server share a single in-flight promise,
279
301
  * so we never create two SSH clients and leak the loser.
280
302
  */
281
- async connect(name) {
303
+ async connect(name, timeout) {
282
304
  const key = name || this.defaultName;
283
305
  if (this.connected.get(key) && this.clients.get(key)) {
284
306
  return;
@@ -287,21 +309,25 @@ export class SSHConnectionManager {
287
309
  if (inFlight) {
288
310
  return inFlight;
289
311
  }
290
- const promise = this.doConnect(key).finally(() => {
291
- this.connecting.delete(key);
312
+ let trackedPromise;
313
+ trackedPromise = this.doConnect(key, timeout).finally(() => {
314
+ if (this.connecting.get(key) === trackedPromise) {
315
+ this.connecting.delete(key);
316
+ }
292
317
  });
293
- this.connecting.set(key, promise);
294
- return promise;
318
+ this.connecting.set(key, trackedPromise);
319
+ return trackedPromise;
295
320
  }
296
321
  /**
297
322
  * Actual SSH connect implementation. Callers must go through `connect()`
298
323
  * so concurrent requests are deduped.
299
324
  * @private
300
325
  */
301
- async doConnect(key) {
326
+ async doConnect(key, timeout) {
302
327
  const config = this.getConfig(key);
303
328
  const client = new Client();
304
329
  const generation = this.connectionGenerations.get(key) ?? 0;
330
+ const connectTimeout = this.normalizeConnectTimeout(timeout);
305
331
  this.pendingClients.set(key, client);
306
332
  try {
307
333
  await new Promise(async (resolve, reject) => {
@@ -357,6 +383,9 @@ export class SSHConnectionManager {
357
383
  port: config.port,
358
384
  username: config.username,
359
385
  };
386
+ if (connectTimeout) {
387
+ sshConfig.readyTimeout = connectTimeout;
388
+ }
360
389
  const agent = config.agent === false
361
390
  ? undefined
362
391
  : config.agent || (config.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
@@ -367,7 +396,15 @@ export class SSHConnectionManager {
367
396
  // (enforced at config load time).
368
397
  if (config.jumpHost) {
369
398
  try {
370
- const sock = await this.openJumpTunnel(key, config);
399
+ const sock = await this.withConnectionTimeout(this.openJumpTunnel(key, config, undefined, connectTimeout), connectTimeout, `jump tunnel for [${key}] via '${config.jumpHost}'`, undefined, () => this.teardownJumpChain(key), (stream) => {
400
+ try {
401
+ stream.destroy?.();
402
+ }
403
+ catch {
404
+ // Ignore late stream cleanup errors.
405
+ }
406
+ this.teardownJumpChain(key);
407
+ });
371
408
  sshConfig.sock = sock;
372
409
  Logger.log(`Using jump host '${config.jumpHost}' for [${key}]`, "info");
373
410
  }
@@ -390,7 +427,7 @@ export class SSHConnectionManager {
390
427
  const proxyPort = parseInt(proxyUrl.port, 10);
391
428
  Logger.log(`Using SOCKS proxy for [${key}]: ${config.socksProxy}`, "info");
392
429
  // Create SOCKS connection
393
- const { socket } = await SocksClient.createConnection({
430
+ const { socket } = await this.withConnectionTimeout(SocksClient.createConnection({
394
431
  proxy: {
395
432
  host: proxyHost,
396
433
  port: proxyPort,
@@ -401,6 +438,14 @@ export class SSHConnectionManager {
401
438
  host: config.host,
402
439
  port: config.port,
403
440
  },
441
+ timeout: connectTimeout,
442
+ }), connectTimeout, `SOCKS proxy connection for [${key}]`, undefined, undefined, (event) => {
443
+ try {
444
+ event.socket.destroy();
445
+ }
446
+ catch {
447
+ // Ignore late socket cleanup errors.
448
+ }
404
449
  });
405
450
  // Set the socket as the sock for SSH connection
406
451
  sshConfig.sock = socket;
@@ -451,6 +496,212 @@ export class SSHConnectionManager {
451
496
  }
452
497
  this.clients.set(key, client);
453
498
  }
499
+ /**
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.
503
+ */
504
+ async connectCommandClient(key, timeout, debug) {
505
+ const config = this.getConfig(key);
506
+ const client = new Client();
507
+ const jumpChainKey = `__command__:${key}:${Date.now()}:${crypto.randomBytes(4).toString("hex")}`;
508
+ const connectTimeout = this.normalizeConnectTimeout(timeout) ?? 30000;
509
+ let settled = false;
510
+ let closed = false;
511
+ const onLateError = (err) => {
512
+ if (!settled) {
513
+ return;
514
+ }
515
+ 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");
517
+ };
518
+ client.on("error", onLateError);
519
+ const close = () => {
520
+ if (closed) {
521
+ return;
522
+ }
523
+ closed = true;
524
+ client.on("error", SSHConnectionManager.CLOSED_CLIENT_ERROR_SINK);
525
+ client.removeListener("error", onLateError);
526
+ try {
527
+ client.end();
528
+ }
529
+ catch {
530
+ // Ignore close errors for per-command clients.
531
+ }
532
+ this.connected.delete(jumpChainKey);
533
+ this.connecting.delete(jumpChainKey);
534
+ this.teardownJumpChain(jumpChainKey);
535
+ };
536
+ try {
537
+ const sshConfig = {
538
+ host: config.host,
539
+ port: config.port,
540
+ username: config.username,
541
+ readyTimeout: connectTimeout,
542
+ };
543
+ if (debug) {
544
+ sshConfig.debug = (line) => debug(`[ssh2] ${line}`);
545
+ }
546
+ const agent = config.agent === false
547
+ ? undefined
548
+ : config.agent || (config.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
549
+ if (agent) {
550
+ sshConfig.agent = agent;
551
+ }
552
+ if (config.jumpHost) {
553
+ try {
554
+ sshConfig.sock = await this.withConnectionTimeout(this.openJumpTunnel(jumpChainKey, config, debug, connectTimeout), connectTimeout, `one-shot jump tunnel for [${key}] via '${config.jumpHost}'`, debug, () => this.teardownJumpChain(jumpChainKey), (stream) => {
555
+ try {
556
+ stream.destroy?.();
557
+ }
558
+ catch {
559
+ // Ignore late stream cleanup errors.
560
+ }
561
+ this.teardownJumpChain(jumpChainKey);
562
+ });
563
+ Logger.log(`Using one-shot jump host '${config.jumpHost}' for command on [${key}]`, "info");
564
+ }
565
+ catch (err) {
566
+ 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);
568
+ }
569
+ }
570
+ if (config.socksProxy) {
571
+ try {
572
+ const proxyUrl = new URL(config.socksProxy);
573
+ const { socket } = await this.withConnectionTimeout(SocksClient.createConnection({
574
+ proxy: {
575
+ host: proxyUrl.hostname,
576
+ port: parseInt(proxyUrl.port, 10),
577
+ type: 5,
578
+ },
579
+ command: "connect",
580
+ destination: {
581
+ host: config.host,
582
+ port: config.port,
583
+ },
584
+ timeout: connectTimeout,
585
+ }), connectTimeout, `SOCKS proxy connection for one-shot command [${key}]`, debug, undefined, (event) => {
586
+ try {
587
+ event.socket.destroy();
588
+ }
589
+ catch {
590
+ // Ignore late socket cleanup errors.
591
+ }
592
+ });
593
+ sshConfig.sock = socket;
594
+ }
595
+ catch (err) {
596
+ throw new ToolError("SSH_CONNECTION_FAILED", `Failed to create SOCKS proxy connection for one-shot command [${key}]: ${err.message}`, true);
597
+ }
598
+ }
599
+ if (config.privateKey) {
600
+ try {
601
+ sshConfig.privateKey = fs.readFileSync(config.privateKey, "utf8");
602
+ if (config.passphrase) {
603
+ sshConfig.passphrase = config.passphrase;
604
+ }
605
+ }
606
+ catch (err) {
607
+ throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read private key file for [${key}]: ${err.message}`, false);
608
+ }
609
+ }
610
+ else if (config.password) {
611
+ sshConfig.password = config.password;
612
+ }
613
+ else if (agent || config.authOptional) {
614
+ Logger.log(`Using SSH agent/default authentication for one-shot command [${key}]`, "info");
615
+ }
616
+ else {
617
+ throw new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (password or private key)`, false);
618
+ }
619
+ await new Promise((resolve, reject) => {
620
+ const done = (err) => {
621
+ if (settled)
622
+ return;
623
+ settled = true;
624
+ clearTimeout(timeoutId);
625
+ client.removeListener("ready", onReady);
626
+ client.removeListener("error", onError);
627
+ client.removeListener("close", onClose);
628
+ if (err)
629
+ reject(err);
630
+ else
631
+ resolve();
632
+ };
633
+ 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));
636
+ const timeoutId = setTimeout(() => {
637
+ done(new ToolError("SSH_CONNECTION_FAILED", `SSH command connection [${key}] timed out after ${connectTimeout}ms`, true));
638
+ close();
639
+ }, connectTimeout);
640
+ debug?.(`[mcp] opening one-shot SSH connection for [${key}]`);
641
+ client.once("ready", onReady);
642
+ client.once("error", onError);
643
+ client.once("close", onClose);
644
+ client.connect(sshConfig);
645
+ });
646
+ Logger.log(`Opened one-shot SSH command connection for [${key}]`, "info");
647
+ return { client, close };
648
+ }
649
+ catch (err) {
650
+ close();
651
+ throw err;
652
+ }
653
+ }
654
+ withConnectionTimeout(operation, timeoutMs, description, debug, onTimeout, onLateResolve) {
655
+ if (!timeoutMs || timeoutMs <= 0) {
656
+ return operation;
657
+ }
658
+ return new Promise((resolve, reject) => {
659
+ let settled = false;
660
+ let timedOut = false;
661
+ const timeoutId = setTimeout(() => {
662
+ if (settled)
663
+ return;
664
+ settled = true;
665
+ timedOut = true;
666
+ debug?.(`[mcp] ${description} timed out after ${timeoutMs}ms`);
667
+ try {
668
+ onTimeout?.();
669
+ }
670
+ catch {
671
+ // Ignore cleanup failures after timeout.
672
+ }
673
+ reject(new ToolError("SSH_CONNECTION_FAILED", `${description} timed out after ${timeoutMs}ms`, true));
674
+ }, timeoutMs);
675
+ operation.then((value) => {
676
+ if (timedOut) {
677
+ try {
678
+ onLateResolve?.(value);
679
+ }
680
+ catch {
681
+ // Ignore cleanup failures for a late result.
682
+ }
683
+ return;
684
+ }
685
+ if (settled)
686
+ return;
687
+ settled = true;
688
+ clearTimeout(timeoutId);
689
+ resolve(value);
690
+ }, (err) => {
691
+ if (settled)
692
+ return;
693
+ settled = true;
694
+ clearTimeout(timeoutId);
695
+ reject(err);
696
+ });
697
+ });
698
+ }
699
+ normalizeConnectTimeout(timeout) {
700
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) {
701
+ return undefined;
702
+ }
703
+ return Math.max(1, Math.min(timeout, 30000));
704
+ }
454
705
  /**
455
706
  * Open a TCP tunnel from the target's jump chain to `config.host:config.port`
456
707
  * and return the duplex stream to be used as `sock` for the target SSH client.
@@ -466,12 +717,12 @@ export class SSHConnectionManager {
466
717
  * calls against a bastion stay isolated.
467
718
  * @private
468
719
  */
469
- async openJumpTunnel(targetKey, config) {
720
+ async openJumpTunnel(targetKey, config, debug, timeout) {
470
721
  // Tear down any stale jump chain for this target before opening a new one.
471
722
  this.teardownJumpChain(targetKey);
472
723
  this.jumpClients.set(targetKey, []);
473
- const jumpClient = await this.connectJumpChain(targetKey, config.jumpHost);
474
- return this.forwardOutStream(jumpClient, config.host, config.port);
724
+ const jumpClient = await this.connectJumpChain(targetKey, config.jumpHost, debug, timeout);
725
+ return this.forwardOutStream(jumpClient, config.host, config.port, timeout);
475
726
  }
476
727
  /**
477
728
  * Recursively connect the SSH client for `jumpName`, tunneling through its own
@@ -479,7 +730,7 @@ export class SSHConnectionManager {
479
730
  * to the target's jump-client chain. Returns the connected client for this hop.
480
731
  * @private
481
732
  */
482
- async connectJumpChain(targetKey, jumpName) {
733
+ async connectJumpChain(targetKey, jumpName, debug, timeout) {
483
734
  const jumpConfig = this.configs[jumpName];
484
735
  if (!jumpConfig) {
485
736
  // Should be caught at config load, but guard at runtime too.
@@ -489,10 +740,10 @@ export class SSHConnectionManager {
489
740
  // tunnel first and hand its stream to this hop as `sock`.
490
741
  let sock;
491
742
  if (jumpConfig.jumpHost) {
492
- const innerClient = await this.connectJumpChain(targetKey, jumpConfig.jumpHost);
493
- sock = await this.forwardOutStream(innerClient, jumpConfig.host, jumpConfig.port);
743
+ const innerClient = await this.connectJumpChain(targetKey, jumpConfig.jumpHost, debug, timeout);
744
+ sock = await this.forwardOutStream(innerClient, jumpConfig.host, jumpConfig.port, timeout);
494
745
  }
495
- const jumpClient = await this.connectJumpClient(targetKey, jumpName, jumpConfig, sock);
746
+ const jumpClient = await this.connectJumpClient(targetKey, jumpName, jumpConfig, sock, debug, timeout);
496
747
  const chain = this.jumpClients.get(targetKey);
497
748
  if (chain) {
498
749
  chain.push(jumpClient);
@@ -508,14 +759,57 @@ export class SSHConnectionManager {
508
759
  * chain down so a dead hop surfaces as a clear failure on the next op.
509
760
  * @private
510
761
  */
511
- connectJumpClient(targetKey, jumpName, jumpConfig, sock) {
762
+ connectJumpClient(targetKey, jumpName, jumpConfig, sock, debug, timeout) {
512
763
  return new Promise((resolve, reject) => {
513
764
  const jumpClient = new Client();
514
- jumpClient.on("ready", () => resolve(jumpClient));
515
- jumpClient.on("error", (err) => {
516
- reject(new Error(`jump SSH connect failed for '${jumpName}': ${err.message}`));
517
- });
765
+ let settled = false;
766
+ let timeoutId = null;
767
+ let closedErrorSinkInstalled = false;
768
+ const installClosedErrorSink = () => {
769
+ if (!closedErrorSinkInstalled) {
770
+ closedErrorSinkInstalled = true;
771
+ jumpClient.on("error", SSHConnectionManager.CLOSED_CLIENT_ERROR_SINK);
772
+ }
773
+ };
774
+ const onLateError = (err) => {
775
+ Logger.log(`Jump SSH client '${jumpName}' for [${targetKey}] emitted error after ready: ${err.message}`, "error");
776
+ this.teardownTargetViaJump(targetKey);
777
+ };
778
+ const cleanup = () => {
779
+ if (timeoutId) {
780
+ clearTimeout(timeoutId);
781
+ timeoutId = null;
782
+ }
783
+ jumpClient.removeListener("ready", onReady);
784
+ jumpClient.removeListener("error", onError);
785
+ };
786
+ const done = (err) => {
787
+ if (settled)
788
+ return;
789
+ settled = true;
790
+ cleanup();
791
+ if (err) {
792
+ installClosedErrorSink();
793
+ reject(err);
794
+ return;
795
+ }
796
+ jumpClient.on("error", onLateError);
797
+ resolve(jumpClient);
798
+ };
799
+ const onReady = () => done();
800
+ const onError = (err) => {
801
+ done(new Error(`jump SSH connect failed for '${jumpName}': ${err.message}`));
802
+ };
803
+ jumpClient.on("ready", onReady);
804
+ jumpClient.on("error", onError);
518
805
  jumpClient.on("close", () => {
806
+ if (!settled) {
807
+ done(new Error(`jump SSH connect closed for '${jumpName}' before ready`));
808
+ return;
809
+ }
810
+ jumpClient.removeListener("error", onError);
811
+ jumpClient.removeListener("error", onLateError);
812
+ installClosedErrorSink();
519
813
  // If any hop dies, kill the target too so callers get a clear failure on
520
814
  // the next op and reconnect through a fresh chain.
521
815
  this.teardownTargetViaJump(targetKey);
@@ -528,6 +822,21 @@ export class SSHConnectionManager {
528
822
  if (sock) {
529
823
  jumpSsh.sock = sock;
530
824
  }
825
+ if (timeout) {
826
+ jumpSsh.readyTimeout = timeout;
827
+ timeoutId = setTimeout(() => {
828
+ done(new Error(`jump SSH connect timed out for '${jumpName}' after ${timeout}ms`));
829
+ try {
830
+ jumpClient.end();
831
+ }
832
+ catch {
833
+ // Ignore close errors after timeout.
834
+ }
835
+ }, timeout);
836
+ }
837
+ if (debug) {
838
+ jumpSsh.debug = (line) => debug(`[ssh2:${jumpName}] ${line}`);
839
+ }
531
840
  const jumpAgent = jumpConfig.agent === false
532
841
  ? undefined
533
842
  : jumpConfig.agent || (jumpConfig.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
@@ -560,12 +869,39 @@ export class SSHConnectionManager {
560
869
  * Open a forwarded TCP stream from `client` to `host:port`.
561
870
  * @private
562
871
  */
563
- forwardOutStream(client, host, port) {
872
+ forwardOutStream(client, host, port, timeout) {
564
873
  return new Promise((resolve, reject) => {
874
+ let settled = false;
875
+ let timeoutId = null;
876
+ const done = (err, stream) => {
877
+ if (settled)
878
+ return;
879
+ settled = true;
880
+ if (timeoutId) {
881
+ clearTimeout(timeoutId);
882
+ }
883
+ if (err) {
884
+ reject(err);
885
+ return;
886
+ }
887
+ resolve(stream);
888
+ };
889
+ if (timeout) {
890
+ timeoutId = setTimeout(() => {
891
+ done(new Error(`forwardOut to ${host}:${port} timed out after ${timeout}ms`));
892
+ }, timeout);
893
+ }
565
894
  client.forwardOut("127.0.0.1", 0, host, port, (err, ch) => {
566
- if (err)
567
- return reject(err);
568
- resolve(ch);
895
+ if (settled) {
896
+ try {
897
+ ch?.close();
898
+ }
899
+ catch {
900
+ // Ignore cleanup failures for a late forwarded channel.
901
+ }
902
+ return;
903
+ }
904
+ done(err, ch);
569
905
  });
570
906
  });
571
907
  }
@@ -622,10 +958,11 @@ export class SSHConnectionManager {
622
958
  * Ensure SSH client is connected
623
959
  * @private
624
960
  */
625
- async ensureConnected(name) {
961
+ async ensureConnected(name, timeout) {
626
962
  const key = name || this.defaultName;
627
963
  if (!this.connected.get(key) || !this.clients.get(key)) {
628
- await this.connect(key);
964
+ const connectTimeout = this.normalizeConnectTimeout(timeout);
965
+ await this.withConnectionTimeout(this.connect(key, timeout), connectTimeout, `cached SSH connection [${key}]`, undefined, () => this.closeClient(key, true));
629
966
  }
630
967
  const client = this.clients.get(key);
631
968
  if (!client) {
@@ -641,7 +978,10 @@ export class SSHConnectionManager {
641
978
  if (error instanceof ToolError) {
642
979
  return error.code === "SSH_CONNECTION_FAILED";
643
980
  }
644
- const msg = error.message.toLowerCase();
981
+ return this.isConnectionShapedMessage(error.message);
982
+ }
983
+ isConnectionShapedMessage(message) {
984
+ const msg = message.toLowerCase();
645
985
  return (msg.includes("not connected") ||
646
986
  msg.includes("connection") ||
647
987
  msg.includes("socket") ||
@@ -650,7 +990,9 @@ export class SSHConnectionManager {
650
990
  msg.includes("epipe") ||
651
991
  msg.includes("closed") ||
652
992
  msg.includes("end of stream") ||
653
- msg.includes("channel"));
993
+ msg.includes("channel") ||
994
+ msg.includes("no response from server") ||
995
+ msg.includes("timed out"));
654
996
  }
655
997
  /**
656
998
  * Force reconnect to SSH server
@@ -918,54 +1260,107 @@ export class SSHConnectionManager {
918
1260
  */
919
1261
  async runCommandStream(cmdString, client, timeout, sinks) {
920
1262
  return new Promise((resolve, reject) => {
921
- let timeoutId;
1263
+ let timeoutId = null;
1264
+ let settled = false;
922
1265
  const cleanup = () => {
923
- if (timeoutId)
1266
+ if (timeoutId) {
924
1267
  clearTimeout(timeoutId);
1268
+ timeoutId = null;
1269
+ }
925
1270
  };
926
- client.exec(cmdString, (err, stream) => {
927
- if (err) {
928
- cleanup();
929
- reject(new ToolError("COMMAND_EXECUTION_ERROR", `Command execution error: ${err.message}`, false));
1271
+ const settle = (err, code) => {
1272
+ if (settled)
930
1273
  return;
931
- }
932
- stream.on("data", (chunk) => {
933
- sinks.stdoutCollector?.push(chunk);
934
- sinks.logWriter?.appendStdout(chunk);
935
- if (sinks.onProgress)
936
- sinks.onProgress(chunk.toString());
937
- });
938
- stream.stderr.on("data", (chunk) => {
939
- sinks.stderrCollector?.push(chunk);
940
- sinks.logWriter?.appendStderr(chunk);
941
- if (sinks.onProgress)
942
- sinks.onProgress(`[STDERR] ${chunk.toString()}`);
943
- });
944
- stream.on("close", (code) => {
945
- cleanup();
1274
+ settled = true;
1275
+ cleanup();
1276
+ if (err)
1277
+ reject(err);
1278
+ else
946
1279
  resolve(code ?? null);
947
- });
948
- stream.on("error", (err) => {
949
- cleanup();
950
- reject(new ToolError("COMMAND_EXECUTION_ERROR", `Stream error: ${err.message}`, false));
951
- });
1280
+ };
1281
+ const closeLateStream = (stream) => {
1282
+ try {
1283
+ stream.close();
1284
+ }
1285
+ catch {
1286
+ // Ignore late-stream close errors after the promise has settled.
1287
+ }
1288
+ };
1289
+ const armExecOpenTimeout = () => {
1290
+ const execOpenTimeout = Math.max(1, Math.min(timeout, 30000));
952
1291
  timeoutId = setTimeout(() => {
953
- cleanup();
1292
+ sinks.debug?.(`[mcp] exec channel open timed out after ${execOpenTimeout}ms`);
1293
+ settle(new ToolError("SSH_CONNECTION_FAILED", `SSH exec channel timeout: no response from server within ${execOpenTimeout}ms while opening command channel`, true));
1294
+ }, execOpenTimeout);
1295
+ };
1296
+ const armCommandTimeout = (stream) => {
1297
+ timeoutId = setTimeout(() => {
1298
+ sinks.debug?.(`[mcp] remote command timed out after ${timeout}ms; closing command channel`);
1299
+ settle(new ToolError("COMMAND_TIMEOUT", `Command timeout: execution exceeded ${timeout}ms limit. Remote process killed.`, false));
954
1300
  try {
955
1301
  stream.signal("KILL");
956
1302
  }
957
- catch (e) {
958
- // Ignore errors when sending signal
1303
+ catch {
1304
+ // Ignore errors when sending signal.
959
1305
  }
960
1306
  try {
961
1307
  stream.close();
962
1308
  }
963
- catch (e) {
964
- // Ignore errors when closing streams during timeout
1309
+ catch {
1310
+ // Ignore errors when closing streams during timeout.
965
1311
  }
966
- reject(new ToolError("COMMAND_TIMEOUT", `Command timeout: execution exceeded ${timeout}ms limit. Remote process killed.`, false));
967
1312
  }, timeout);
968
- });
1313
+ };
1314
+ armExecOpenTimeout();
1315
+ sinks.debug?.(`[mcp] opening exec channel for command: ${cmdString}`);
1316
+ try {
1317
+ client.exec(cmdString, (err, stream) => {
1318
+ if (settled) {
1319
+ if (stream)
1320
+ closeLateStream(stream);
1321
+ return;
1322
+ }
1323
+ if (err) {
1324
+ const isConnectionFailure = this.isConnectionShapedMessage(err.message);
1325
+ const code = isConnectionFailure ? "SSH_CONNECTION_FAILED" : "COMMAND_EXECUTION_ERROR";
1326
+ sinks.debug?.(`[mcp] exec callback failed: ${err.message}`);
1327
+ settle(new ToolError(code, `Command execution error: ${err.message}`, isConnectionFailure));
1328
+ return;
1329
+ }
1330
+ cleanup();
1331
+ sinks.debug?.("[mcp] exec channel opened");
1332
+ stream.on("data", (chunk) => {
1333
+ sinks.stdoutCollector?.push(chunk);
1334
+ sinks.logWriter?.appendStdout(chunk);
1335
+ if (sinks.onProgress)
1336
+ sinks.onProgress(chunk.toString());
1337
+ });
1338
+ stream.stderr.on("data", (chunk) => {
1339
+ sinks.stderrCollector?.push(chunk);
1340
+ sinks.logWriter?.appendStderr(chunk);
1341
+ if (sinks.onProgress)
1342
+ sinks.onProgress(`[STDERR] ${chunk.toString()}`);
1343
+ });
1344
+ stream.on("close", (code) => {
1345
+ sinks.debug?.(`[mcp] exec channel closed with code ${code ?? "null"}`);
1346
+ settle(null, code ?? null);
1347
+ });
1348
+ stream.on("error", (err) => {
1349
+ const isConnectionFailure = this.isConnectionShapedMessage(err.message);
1350
+ const code = isConnectionFailure ? "SSH_CONNECTION_FAILED" : "COMMAND_EXECUTION_ERROR";
1351
+ sinks.debug?.(`[mcp] exec stream error: ${err.message}`);
1352
+ settle(new ToolError(code, `Stream error: ${err.message}`, isConnectionFailure));
1353
+ });
1354
+ armCommandTimeout(stream);
1355
+ });
1356
+ }
1357
+ catch (err) {
1358
+ const error = err;
1359
+ const isConnectionFailure = this.isConnectionShapedMessage(error.message);
1360
+ const code = isConnectionFailure ? "SSH_CONNECTION_FAILED" : "COMMAND_EXECUTION_ERROR";
1361
+ sinks.debug?.(`[mcp] client.exec threw before callback: ${error.message}`);
1362
+ settle(new ToolError(code, `Command execution error: ${error.message}`, isConnectionFailure));
1363
+ }
969
1364
  });
970
1365
  }
971
1366
  /**
@@ -974,6 +1369,45 @@ export class SSHConnectionManager {
974
1369
  * full output is always persisted to disk regardless of this cap.
975
1370
  */
976
1371
  static DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
1372
+ static DEFAULT_DEBUG_BYTES = 64 * 1024;
1373
+ createDebugCollector(enabled) {
1374
+ if (!enabled) {
1375
+ return { collector: null };
1376
+ }
1377
+ const collector = new OutputCollector(SSHConnectionManager.DEFAULT_DEBUG_BYTES);
1378
+ return {
1379
+ collector,
1380
+ debug: (line) => {
1381
+ collector.push(`${line}\n`);
1382
+ },
1383
+ };
1384
+ }
1385
+ appendDebugOutput(result, collector) {
1386
+ if (!collector || collector.getTotalBytes() === 0) {
1387
+ return result;
1388
+ }
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()}`;
1394
+ }
1395
+ appendDebugToError(error, collector) {
1396
+ if (!collector || collector.getTotalBytes() === 0) {
1397
+ return error;
1398
+ }
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()}`;
1404
+ if (error instanceof ToolError) {
1405
+ return new ToolError(error.code, message, error.retriable);
1406
+ }
1407
+ const wrapped = new Error(message);
1408
+ wrapped.name = error.name;
1409
+ return wrapped;
1410
+ }
977
1411
  /**
978
1412
  * Assemble the final user-visible result string from collected tails.
979
1413
  * Adds a truncation header (with the on-disk log path) and the legacy
@@ -1039,12 +1473,20 @@ export class SSHConnectionManager {
1039
1473
  const timeout = options.timeout || 30000; // Default 30 seconds timeout
1040
1474
  const maxRetries = options.maxRetries ?? 2; // Default 2 retries
1041
1475
  const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
1476
+ const reuseConnection = options.reuseConnection !== false;
1042
1477
  const key = name || this.defaultName;
1478
+ const { collector: debugCollector, debug } = this.createDebugCollector(options.vvv === true);
1043
1479
  let lastError = null;
1044
1480
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1045
1481
  try {
1046
- // Ensure SSH connection is established
1047
- const client = await this.ensureConnected(name);
1482
+ 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);
1486
+ 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
+ }
1048
1490
  // Per-attempt collectors + log writer. We rebuild them on each retry
1049
1491
  // so partial output from a failed attempt is not mixed into the next.
1050
1492
  const stdoutCollector = new OutputCollector(maxOutputBytes);
@@ -1057,10 +1499,12 @@ export class SSHConnectionManager {
1057
1499
  stdoutCollector,
1058
1500
  stderrCollector,
1059
1501
  logWriter: logWriter ?? undefined,
1502
+ debug,
1060
1503
  });
1061
1504
  }
1062
1505
  finally {
1063
1506
  logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
1507
+ commandConnection.close();
1064
1508
  }
1065
1509
  const result = this.buildExecuteResult({
1066
1510
  stdoutCollector,
@@ -1073,7 +1517,7 @@ export class SSHConnectionManager {
1073
1517
  if (attempt > 0) {
1074
1518
  Logger.log(`Command succeeded on retry attempt ${attempt} for [${key}]`, "info");
1075
1519
  }
1076
- return result;
1520
+ return this.appendDebugOutput(result, debugCollector);
1077
1521
  }
1078
1522
  catch (error) {
1079
1523
  lastError = error;
@@ -1083,13 +1527,13 @@ export class SSHConnectionManager {
1083
1527
  Logger.log(`Connection error on attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
1084
1528
  // Wait with exponential backoff
1085
1529
  await this.sleep(backoffMs);
1086
- // Force reconnect before retry
1087
- try {
1088
- await this.reconnect(name);
1089
- }
1090
- catch (reconnectError) {
1091
- Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
1092
- // Continue to next retry attempt anyway
1530
+ if (reuseConnection) {
1531
+ try {
1532
+ await this.reconnect(name);
1533
+ }
1534
+ catch (reconnectError) {
1535
+ Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
1536
+ }
1093
1537
  }
1094
1538
  continue;
1095
1539
  }
@@ -1098,7 +1542,7 @@ export class SSHConnectionManager {
1098
1542
  }
1099
1543
  }
1100
1544
  // All retries exhausted
1101
- throw lastError || new Error("Command execution failed after all retries");
1545
+ throw this.appendDebugToError(lastError || new Error("Command execution failed after all retries"), debugCollector);
1102
1546
  }
1103
1547
  /**
1104
1548
  * Execute SSH command with real-time streaming output via progress callback
@@ -1122,12 +1566,20 @@ export class SSHConnectionManager {
1122
1566
  const timeout = options.timeout || 300000; // Default 5 minutes for streaming
1123
1567
  const maxRetries = options.maxRetries ?? 2; // Default 2 retries
1124
1568
  const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
1569
+ const reuseConnection = options.reuseConnection !== false;
1125
1570
  const key = name || this.defaultName;
1571
+ const { collector: debugCollector, debug } = this.createDebugCollector(options.vvv === true);
1126
1572
  let lastError = null;
1127
1573
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1128
1574
  try {
1129
- // Ensure SSH connection is established
1130
- const client = await this.ensureConnected(name);
1575
+ 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);
1579
+ 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
+ }
1131
1583
  // Per-attempt collectors + log writer. onProgress keeps streaming
1132
1584
  // every byte live; only the final returned string is capped.
1133
1585
  const stdoutCollector = new OutputCollector(maxOutputBytes);
@@ -1141,10 +1593,12 @@ export class SSHConnectionManager {
1141
1593
  stderrCollector,
1142
1594
  logWriter: logWriter ?? undefined,
1143
1595
  onProgress: options.onProgress,
1596
+ debug,
1144
1597
  });
1145
1598
  }
1146
1599
  finally {
1147
1600
  logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
1601
+ commandConnection.close();
1148
1602
  }
1149
1603
  const result = this.buildExecuteResult({
1150
1604
  stdoutCollector,
@@ -1157,7 +1611,7 @@ export class SSHConnectionManager {
1157
1611
  if (attempt > 0) {
1158
1612
  Logger.log(`Streaming command succeeded on retry attempt ${attempt} for [${key}]`, "info");
1159
1613
  }
1160
- return result;
1614
+ return this.appendDebugOutput(result, debugCollector);
1161
1615
  }
1162
1616
  catch (error) {
1163
1617
  lastError = error;
@@ -1166,18 +1620,20 @@ export class SSHConnectionManager {
1166
1620
  const backoffMs = 500 * Math.pow(2, attempt);
1167
1621
  Logger.log(`Connection error on streaming attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
1168
1622
  await this.sleep(backoffMs);
1169
- try {
1170
- await this.reconnect(name);
1171
- }
1172
- catch (reconnectError) {
1173
- Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
1623
+ if (reuseConnection) {
1624
+ try {
1625
+ await this.reconnect(name);
1626
+ }
1627
+ catch (reconnectError) {
1628
+ Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
1629
+ }
1174
1630
  }
1175
1631
  continue;
1176
1632
  }
1177
1633
  break;
1178
1634
  }
1179
1635
  }
1180
- throw lastError || new Error("Streaming command execution failed after all retries");
1636
+ throw this.appendDebugToError(lastError || new Error("Streaming command execution failed after all retries"), debugCollector);
1181
1637
  }
1182
1638
  /**
1183
1639
  * Build an OutputLogWriter for a given server. Returns null if we cannot
@@ -1204,14 +1660,16 @@ export class SSHConnectionManager {
1204
1660
  */
1205
1661
  validateLocalPath(localPath, name) {
1206
1662
  const resolvedPath = path.resolve(localPath);
1663
+ const config = name ? this.getServerConfig(name) : undefined;
1664
+ // disableSftpPathPolicy fully opens the local side too (any path allowed).
1665
+ if (config?.disableSftpPathPolicy) {
1666
+ return resolvedPath;
1667
+ }
1207
1668
  const allowedRoots = new Set([process.cwd()]);
1208
1669
  // Add per-server allowedLocalDirectories if a server is targeted
1209
- if (name) {
1210
- const config = this.getServerConfig(name);
1211
- if (config?.allowedLocalDirectories) {
1212
- for (const dir of config.allowedLocalDirectories) {
1213
- allowedRoots.add(dir);
1214
- }
1670
+ if (config?.allowedLocalDirectories) {
1671
+ for (const dir of config.allowedLocalDirectories) {
1672
+ allowedRoots.add(dir);
1215
1673
  }
1216
1674
  }
1217
1675
  const isAllowed = Array.from(allowedRoots).some((root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep));
@@ -1249,9 +1707,11 @@ export class SSHConnectionManager {
1249
1707
  const normalized = path.posix.normalize(remotePath);
1250
1708
  const config = this.getConfig(name);
1251
1709
  const allowedRoots = config.allowedRemoteDirectories ?? [];
1252
- if (allowedRoots.length === 0) {
1253
- throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `SFTP is disabled for server '${name}': no 'allowedRemoteDirectories' configured. ` +
1254
- `Add at least one absolute POSIX directory to 'allowedRemoteDirectories' in the YAML config to permit upload/download.`, false);
1710
+ // Default is OPEN: with no 'allowedRemoteDirectories' configured (or
1711
+ // disableSftpPathPolicy set), any absolute POSIX path is allowed. Configure
1712
+ // 'allowedRemoteDirectories' to opt into an allowlist for this server.
1713
+ if (config.disableSftpPathPolicy || allowedRoots.length === 0) {
1714
+ return normalized;
1255
1715
  }
1256
1716
  const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(root === "/" ? "/" : root + "/"));
1257
1717
  if (!isAllowed) {