@aaarc/handfree-ssh-mcp 1.0.9 → 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
|
|
|
@@ -777,6 +777,60 @@ export class SSHConnectionManager {
|
|
|
777
777
|
});
|
|
778
778
|
});
|
|
779
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
|
+
}
|
|
780
834
|
/**
|
|
781
835
|
* Open a TCP tunnel from the target's jump chain to `config.host:config.port`
|
|
782
836
|
* and return the duplex stream to be used as `sock` for the target SSH client.
|
|
@@ -2111,47 +2165,100 @@ export class SSHConnectionManager {
|
|
|
2111
2165
|
}
|
|
2112
2166
|
/**
|
|
2113
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.
|
|
2114
2173
|
*/
|
|
2115
2174
|
async sftpReadBuffer(client, remotePath, expectedSize, timeout, debug) {
|
|
2116
2175
|
const sftp = await this.openSftp(client, "read", timeout, debug);
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
let received = 0;
|
|
2120
|
-
const stream = sftp.createReadStream(remotePath);
|
|
2121
|
-
stream.on("data", (chunk) => {
|
|
2122
|
-
chunks.push(chunk);
|
|
2123
|
-
received += chunk.length;
|
|
2124
|
-
});
|
|
2125
|
-
stream.on("error", (e) => {
|
|
2126
|
-
sftp.end();
|
|
2127
|
-
reject(this.makeSftpError("Remote read failed", e));
|
|
2128
|
-
});
|
|
2129
|
-
stream.on("end", () => {
|
|
2176
|
+
const endSftp = () => {
|
|
2177
|
+
try {
|
|
2130
2178
|
sftp.end();
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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
|
+
}
|
|
2137
2208
|
}
|
|
2138
2209
|
/**
|
|
2139
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.
|
|
2140
2216
|
*/
|
|
2141
2217
|
async sftpWriteBuffer(client, remotePath, payload, timeout, debug) {
|
|
2142
2218
|
const sftp = await this.openSftp(client, "write", timeout, debug);
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
writeStream.on("close", () => {
|
|
2146
|
-
sftp.end();
|
|
2147
|
-
resolve();
|
|
2148
|
-
});
|
|
2149
|
-
writeStream.on("error", (e) => {
|
|
2219
|
+
const endSftp = () => {
|
|
2220
|
+
try {
|
|
2150
2221
|
sftp.end();
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
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
|
+
}
|
|
2155
2262
|
}
|
|
2156
2263
|
/**
|
|
2157
2264
|
* Upload a local file using ssh2's parallel SFTP fastPut implementation.
|
|
@@ -2179,7 +2286,7 @@ export class SSHConnectionManager {
|
|
|
2179
2286
|
}
|
|
2180
2287
|
resolve();
|
|
2181
2288
|
});
|
|
2182
|
-
}), this.
|
|
2289
|
+
}), this.transferStallTimeout(timeout), `fast upload ${localPath} -> ${remotePath}`, debug, endSftp);
|
|
2183
2290
|
}
|
|
2184
2291
|
finally {
|
|
2185
2292
|
endSftp();
|
|
@@ -2211,7 +2318,7 @@ export class SSHConnectionManager {
|
|
|
2211
2318
|
}
|
|
2212
2319
|
resolve();
|
|
2213
2320
|
});
|
|
2214
|
-
}), this.
|
|
2321
|
+
}), this.transferStallTimeout(timeout), `fast download ${remotePath} -> ${localPath}`, debug, endSftp);
|
|
2215
2322
|
}
|
|
2216
2323
|
finally {
|
|
2217
2324
|
endSftp();
|
|
@@ -2240,27 +2347,17 @@ export class SSHConnectionManager {
|
|
|
2240
2347
|
return this.appendDebugOutput("File downloaded successfully via fast SFTP", debugCollector);
|
|
2241
2348
|
}
|
|
2242
2349
|
const sftp = await this.openSftp(connection.client, "download", options?.timeout, debug);
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
if (settled)
|
|
2249
|
-
return;
|
|
2250
|
-
settled = true;
|
|
2251
|
-
if (err) {
|
|
2252
|
-
this.unpipeStream(readStream, writeStream);
|
|
2253
|
-
this.destroyStream(readStream);
|
|
2254
|
-
this.destroyStream(writeStream);
|
|
2255
|
-
}
|
|
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 {
|
|
2256
2355
|
sftp.end();
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
readStream.pipe(writeStream);
|
|
2263
|
-
});
|
|
2356
|
+
}
|
|
2357
|
+
catch {
|
|
2358
|
+
// Ignore late SFTP cleanup errors.
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2264
2361
|
return this.appendDebugOutput("File downloaded successfully", debugCollector);
|
|
2265
2362
|
}
|
|
2266
2363
|
catch (error) {
|
|
@@ -2417,30 +2514,11 @@ export class SSHConnectionManager {
|
|
|
2417
2514
|
}
|
|
2418
2515
|
}
|
|
2419
2516
|
}
|
|
2420
|
-
const readStream = srcSftp.createReadStream(validatedSourcePath);
|
|
2421
|
-
const writeStream = dstSftp.createWriteStream(validatedDestPath);
|
|
2422
2517
|
// Bind the validated paths into the rest of the verification flow so
|
|
2423
2518
|
// we never accidentally fall back to the un-validated originals.
|
|
2424
2519
|
sourceRemotePath = validatedSourcePath;
|
|
2425
2520
|
destRemotePath = validatedDestPath;
|
|
2426
|
-
await
|
|
2427
|
-
let settled = false;
|
|
2428
|
-
const settle = (err) => {
|
|
2429
|
-
if (settled)
|
|
2430
|
-
return;
|
|
2431
|
-
settled = true;
|
|
2432
|
-
if (err) {
|
|
2433
|
-
this.unpipeStream(readStream, writeStream);
|
|
2434
|
-
this.destroyStream(readStream);
|
|
2435
|
-
this.destroyStream(writeStream);
|
|
2436
|
-
}
|
|
2437
|
-
err ? reject(err) : resolve();
|
|
2438
|
-
};
|
|
2439
|
-
writeStream.on("close", () => settle());
|
|
2440
|
-
writeStream.on("error", (err) => settle(this.makeSftpError("Dest write error", err)));
|
|
2441
|
-
readStream.on("error", (err) => settle(this.makeSftpError("Source read error", err)));
|
|
2442
|
-
readStream.pipe(writeStream);
|
|
2443
|
-
});
|
|
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));
|
|
2444
2522
|
// --- Verification ---
|
|
2445
2523
|
const dstStat = await this.sftpStat(dstSftp, destRemotePath, "dest");
|
|
2446
2524
|
const verification = [];
|
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",
|