@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.
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.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2066
|
-
|
|
2165
|
+
const endSftp = () => {
|
|
2166
|
+
try {
|
|
2067
2167
|
sftp.end();
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2084
|
-
|
|
2197
|
+
const endSftp = () => {
|
|
2198
|
+
try {
|
|
2085
2199
|
sftp.end();
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|