@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.
package/build/config/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const SERVER_CONFIG = {
|
|
2
2
|
name: "ssh-mcp-server",
|
|
3
|
-
version: "1.0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2020
|
-
|
|
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
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
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
|
-
|
|
2046
|
-
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2066
|
-
|
|
2272
|
+
const endSftp = () => {
|
|
2273
|
+
try {
|
|
2067
2274
|
sftp.end();
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2084
|
-
|
|
2304
|
+
const endSftp = () => {
|
|
2305
|
+
try {
|
|
2085
2306
|
sftp.end();
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
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
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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",
|