@aaarc/handfree-ssh-mcp 1.0.5 → 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.
- package/README.md +43 -21
- package/build/config/server.js +6 -3
- package/build/services/ssh-connection-manager.js +537 -81
- package/build/tools/close-connection.js +36 -0
- package/build/tools/execute-command.js +15 -3
- package/build/tools/help.js +45 -14
- package/build/tools/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -113,9 +113,10 @@ The AI can now execute commands on your servers. All within your defined securit
|
|
|
113
113
|
|
|
114
114
|
| Tool | Description |
|
|
115
115
|
|------|-------------|
|
|
116
|
-
| `execute-command` | Run SSH command (with optional `stream` for real-time output) |
|
|
117
|
-
| `show-whitelist` | Show active command policy + SFTP policy + output-log path for a server |
|
|
118
|
-
| `
|
|
116
|
+
| `execute-command` | Run SSH command (with optional `stream` for real-time output) |
|
|
117
|
+
| `show-whitelist` | Show active command policy + SFTP policy + output-log path for a server |
|
|
118
|
+
| `close-connection` | Close a cached SSH connection for a server |
|
|
119
|
+
| `upload` | Upload local file to a remote server (CRLF-fix for shell scripts, skip-if-identical) |
|
|
119
120
|
| `download` | Download remote file to local disk |
|
|
120
121
|
| `transfer` | Unified upload / download / server-to-server relay (`mode`: `upload` / `download` / `relay`, optional `recursive`, optional `skipIfIdentical`) |
|
|
121
122
|
| `list-servers` | List configured (enabled) servers. Lean by default; `verbose:true` adds cached system status, `refresh:true` re-collects it (implies verbose). |
|
|
@@ -142,24 +143,41 @@ Returns command mode, built-in command guards, configured whitelist/blacklist pa
|
|
|
142
143
|
{
|
|
143
144
|
"tool": "execute-command",
|
|
144
145
|
"params": {
|
|
145
|
-
"cmdString": "docker ps",
|
|
146
|
-
"connectionName": "dev",
|
|
147
|
-
"timeout": 300000,
|
|
148
|
-
"stream": true
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
"cmdString": "docker ps",
|
|
147
|
+
"connectionName": "dev",
|
|
148
|
+
"timeout": 300000,
|
|
149
|
+
"stream": true,
|
|
150
|
+
"reuseConnection": true,
|
|
151
|
+
"vvv": false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
152
155
|
|
|
153
156
|
| Param | Required | Default | Description |
|
|
154
157
|
|-------|----------|---------|-------------|
|
|
155
158
|
| `cmdString` | ✅ | - | Command to execute |
|
|
156
|
-
| `connectionName` | ❌ | First in `--enable-servers` | Which server to run on |
|
|
157
|
-
| `timeout` | ❌ | 300000ms (stream) / 30000ms (no stream) |
|
|
158
|
-
| `stream` | ❌ | `true` | Real-time streaming output |
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
| `connectionName` | ❌ | First in `--enable-servers` | Which server to run on |
|
|
160
|
+
| `timeout` | ❌ | 300000ms (stream) / 30000ms (no stream) | Per-attempt phase timeout in ms. SSH setup, exec-channel opening, and remote command execution each use this cap. |
|
|
161
|
+
| `stream` | ❌ | `true` | Real-time streaming output |
|
|
162
|
+
| `reuseConnection` | ❌ | `true` | Reuse the cached SSH connection. Set `false` after a timeout or suspected stale cached connection to force a fresh TCP/SSH connection for this command. |
|
|
163
|
+
| `vvv` | ❌ | `false` | Append bounded SSH/channel debug output. Use with `reuseConnection: false` when you need fresh ssh2 handshake logs. |
|
|
164
|
+
|
|
165
|
+
**When to use `stream: false`:**
|
|
166
|
+
- Simple, fast commands (ls, pwd, cat)
|
|
167
|
+
- When you don't need real-time feedback
|
|
168
|
+
|
|
169
|
+
### close-connection
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"tool": "close-connection",
|
|
174
|
+
"params": {
|
|
175
|
+
"connectionName": "dev"
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Closes the cached SSH client for a configured server. This is useful after a timeout or suspected stale reused connection when you want the next default `reuseConnection: true` command to reconnect cleanly. Closing a jump host also closes cached targets whose jump chain uses that host. `reuseConnection: false` commands do not need this because their one-shot SSH clients close after each command.
|
|
163
181
|
|
|
164
182
|
## 📄 YAML Config Reference
|
|
165
183
|
|
|
@@ -276,10 +294,14 @@ Rules (enforced at config load — bad configs fail fast):
|
|
|
276
294
|
|
|
277
295
|
### Connection lifecycle (connect / reconnect)
|
|
278
296
|
|
|
279
|
-
- **Lazy by default.** A server's SSH client is created on its first tool call via `ensureConnected()`. Set `preConnect: true` (or pass `--pre-connect`) to open all enabled servers at startup in parallel; failures are logged but don't block startup.
|
|
280
|
-
- **
|
|
281
|
-
- **
|
|
282
|
-
- **
|
|
297
|
+
- **Lazy by default.** A server's SSH client is created on its first tool call via `ensureConnected()`. Set `preConnect: true` (or pass `--pre-connect`) to open all enabled servers at startup in parallel; failures are logged but don't block startup.
|
|
298
|
+
- **Cached clients stay open while reused.** With the default `reuseConnection: true`, `execute-command` does not close the SSH client after each command. If a cached client must be opened first, SSH setup, jump, SOCKS, and channel-open waits are bounded by the call's `timeout`. The cached client is closed on explicit disconnect/server shutdown, config hot-reload for changed connection fields, underlying SSH close/error cleanup, or reconnect after a connection-shaped command failure.
|
|
299
|
+
- **Manual cached-client close.** Use `close-connection { connectionName: "name" }` to drop a cached SSH client on demand. If the named server is a jump host, cached targets that jump through it are closed too.
|
|
300
|
+
- **Auto-reconnect on `execute-command`.** Every command runs inside a retry loop (default 3 attempts: 1 initial + 2 retries) with exponential backoff. If the underlying error matches a connection-shaped pattern (`econnreset`, `epipe`, `socket`, `closed`, `channel`, `end of stream`, or a `SSH_CONNECTION_FAILED` ToolError), the manager closes the dead client, reconnects, and retries the command. Non-connection errors (permission denied, validation, command-not-found) are returned immediately without retry.
|
|
301
|
+
- **Optional fresh command connections.** `execute-command` reuses the cached SSH client by default (`reuseConnection: true`). If a command times out or you suspect the cached `ssh2.Client` is stale while native `ssh` works, retry with `reuseConnection: false`; that command opens a fresh SSH connection and closes it afterwards.
|
|
302
|
+
- **Optional SSH debug output.** Set `vvv: true` on `execute-command` to append bounded SSH/channel debug output to the result or error. For full ssh2 handshake logs, combine it with `reuseConnection: false`; an already-open cached client cannot retroactively emit handshake debug.
|
|
303
|
+
- **SFTP transfers do NOT auto-retry.** `upload` / `download` / `transfer` lazy-connect via the same path, but a mid-transfer disconnect surfaces as a single failure — re-issue the call manually. This is a known gap.
|
|
304
|
+
- **No background keepalive or health probe.** Dead connections are only discovered on the next tool call. If you idle for hours through a NATed network, expect the first call after the gap to fail-then-reconnect on its own (you'll see one retry in the logs).
|
|
283
305
|
|
|
284
306
|
### Upload behaviors
|
|
285
307
|
|
package/build/config/server.js
CHANGED
|
@@ -10,12 +10,15 @@ Recommended workflow:
|
|
|
10
10
|
3. Use execute-command for remote shell commands. Prefer a single command per call. Compound commands may be rejected by command policy even if each subcommand is individually safe.
|
|
11
11
|
4. Use stream=false for short commands that should finish quickly, such as pwd, ls, cat, head, tail, git status, or docker ps.
|
|
12
12
|
5. Use stream=true for commands that may take longer or where incremental output is useful.
|
|
13
|
-
6.
|
|
14
|
-
7.
|
|
13
|
+
6. execute-command reuses SSH connections by default. If an execute-command call times out, retry the next command with reuseConnection=false to bypass a potentially stale cached SSH connection.
|
|
14
|
+
7. Use close-connection to close a cached SSH connection for a server before retrying with a clean reused connection. Closing a jump host also closes cached targets that jump through it.
|
|
15
|
+
8. For SSH/channel diagnostics, add vvv=true. Use it with reuseConnection=false when you need fresh handshake/debug output.
|
|
16
|
+
9. Use upload and download for single-file SFTP transfers. Use transfer for recursive directory transfers or cross-server relay.
|
|
17
|
+
10. Call help or help { tool: "<name>" } for detailed per-tool parameter docs and examples.
|
|
15
18
|
|
|
16
19
|
Server targeting:
|
|
17
20
|
- When only one server is enabled, connectionName can be omitted and the server is auto-selected.
|
|
18
|
-
- When multiple servers are enabled, connectionName is REQUIRED on execute-command, upload, download, show-whitelist, and transfer (upload/download mode). Omitting it returns an error listing the available names.
|
|
21
|
+
- When multiple servers are enabled, connectionName is REQUIRED on execute-command, close-connection, upload, download, show-whitelist, and transfer (upload/download mode). Omitting it returns an error listing the available names.
|
|
19
22
|
|
|
20
23
|
Behavior notes:
|
|
21
24
|
- A tool may automatically establish the SSH connection on first use.
|
|
@@ -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
|
-
|
|
291
|
-
|
|
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,
|
|
294
|
-
return
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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 (
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
927
|
-
if (
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1047
|
-
const
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
1130
|
-
const
|
|
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
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
|
3
|
+
import { Logger } from "../utils/logger.js";
|
|
4
|
+
import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
|
|
5
|
+
/**
|
|
6
|
+
* Register close-connection tool
|
|
7
|
+
*/
|
|
8
|
+
export function registerCloseConnectionTool(server) {
|
|
9
|
+
const sshManager = SSHConnectionManager.getInstance();
|
|
10
|
+
server.tool("close-connection", "Close the cached SSH connection for a configured server. Use this after a timeout, stale connection suspicion, or before retrying a host with a clean cached connection. This affects only cached/reused SSH clients; reuseConnection=false command clients already close after each command. Closing a jump host also closes cached target connections whose jump chain uses that host.", {
|
|
11
|
+
connectionName: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
|
|
15
|
+
}, async ({ connectionName }) => {
|
|
16
|
+
try {
|
|
17
|
+
const result = sshManager.closeConnection(connectionName);
|
|
18
|
+
return {
|
|
19
|
+
content: [
|
|
20
|
+
{
|
|
21
|
+
type: "text",
|
|
22
|
+
text: JSON.stringify(result),
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const toolError = toToolError(error, "INVALID_CONFIGURATION");
|
|
29
|
+
Logger.handleError(toolError, "Failed to close SSH connection");
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
|
|
32
|
+
isError: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -11,7 +11,7 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
|
|
|
11
11
|
*/
|
|
12
12
|
export function registerExecuteCommandTool(server) {
|
|
13
13
|
const sshManager = SSHConnectionManager.getInstance();
|
|
14
|
-
server.tool("execute-command", "Execute a shell command on a remote server over SSH. Use this for command-line actions on the selected host. Streaming mode is enabled by default so long-running commands can emit progress; set stream=false for short commands where you only want the final output. The returned text is tail-only-capped at maxOutputBytes PER STREAM (default 65536 bytes for stdout and 65536 bytes for stderr, so the response can be up to ~2x that); the FULL stdout/stderr is always persisted to a local log file under <cwd>/.handfree-output/<server>/<user>/, and the response includes that path whenever output was truncated.", {
|
|
14
|
+
server.tool("execute-command", "Execute a shell command on a remote server over SSH. Use this for command-line actions on the selected host. Streaming mode is enabled by default so long-running commands can emit progress; set stream=false for short commands where you only want the final output. SSH connections are reused by default for speed; if an execute-command call times out or you suspect a stale cached SSH connection, retry with reuseConnection=false to force a fresh TCP/SSH connection for that command. Set vvv=true only when debugging SSH/channel issues; with reuseConnection=false it includes ssh2 handshake/debug lines in the result or error. The returned text is tail-only-capped at maxOutputBytes PER STREAM (default 65536 bytes for stdout and 65536 bytes for stderr, so the response can be up to ~2x that); the FULL stdout/stderr is always persisted to a local log file under <cwd>/.handfree-output/<server>/<user>/, and the response includes that path whenever output was truncated.", {
|
|
15
15
|
cmdString: z.string().describe("Exact remote shell command to run. Prefer a single command per call, for example 'pwd', 'ls -la', 'cat /etc/hostname', or 'git status'. Compound commands may be blocked by command policy even if each subcommand is safe."),
|
|
16
16
|
connectionName: z
|
|
17
17
|
.string()
|
|
@@ -20,18 +20,26 @@ export function registerExecuteCommandTool(server) {
|
|
|
20
20
|
timeout: z
|
|
21
21
|
.number()
|
|
22
22
|
.optional()
|
|
23
|
-
.describe("
|
|
23
|
+
.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
24
|
stream: z
|
|
25
25
|
.boolean()
|
|
26
26
|
.optional()
|
|
27
27
|
.describe("Whether to stream progress output. Default is true. Use false for short commands like pwd, ls, cat, head, tail, or git status when you only need the final result."),
|
|
28
|
+
reuseConnection: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Whether to reuse the cached SSH connection for this server. Default is true for speed. Set false after a timeout or suspected stale/bad cached connection; false opens a fresh SSH connection for this command and closes it afterwards."),
|
|
32
|
+
vvv: z
|
|
33
|
+
.boolean()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Default false. When true, append bounded SSH/channel debug output to the result or error. For full ssh2 handshake debug, also set reuseConnection=false; already-reused cached clients cannot retroactively emit handshake logs."),
|
|
28
36
|
maxOutputBytes: z
|
|
29
37
|
.number()
|
|
30
38
|
.int()
|
|
31
39
|
.nonnegative()
|
|
32
40
|
.optional()
|
|
33
41
|
.describe("Per-stream cap on bytes returned to the caller (tail-only). Applied independently to stdout and stderr, so the combined response can be up to ~2x this value. Defaults to 65536 (64 KiB). The full output is always saved to disk regardless; only the returned text is trimmed. Set higher when you need more context, lower to save tokens."),
|
|
34
|
-
}, async ({ cmdString, connectionName, timeout, stream, maxOutputBytes }, extra) => {
|
|
42
|
+
}, async ({ cmdString, connectionName, timeout, stream, reuseConnection, vvv, maxOutputBytes }, extra) => {
|
|
35
43
|
try {
|
|
36
44
|
const resolvedName = sshManager.resolveServer(connectionName);
|
|
37
45
|
const useStream = stream !== false;
|
|
@@ -54,6 +62,8 @@ export function registerExecuteCommandTool(server) {
|
|
|
54
62
|
const result = await sshManager.executeCommandWithProgress(cmdString, resolvedName, {
|
|
55
63
|
timeout: timeout || 300000,
|
|
56
64
|
maxOutputBytes,
|
|
65
|
+
reuseConnection,
|
|
66
|
+
vvv,
|
|
57
67
|
onProgress,
|
|
58
68
|
});
|
|
59
69
|
return {
|
|
@@ -63,6 +73,8 @@ export function registerExecuteCommandTool(server) {
|
|
|
63
73
|
const result = await sshManager.executeCommand(cmdString, resolvedName, {
|
|
64
74
|
timeout: timeout || 30000,
|
|
65
75
|
maxOutputBytes,
|
|
76
|
+
reuseConnection,
|
|
77
|
+
vvv,
|
|
66
78
|
});
|
|
67
79
|
return {
|
|
68
80
|
content: [{ type: "text", text: result }],
|
package/build/tools/help.js
CHANGED
|
@@ -18,19 +18,30 @@ Example:
|
|
|
18
18
|
Parameters:
|
|
19
19
|
cmdString (string, required) The shell command to execute.
|
|
20
20
|
connectionName (string, see below) Target server name from list-servers.
|
|
21
|
-
stream (boolean, optional) Default true. Set false for short
|
|
22
|
-
commands when you only need the final output.
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
stream (boolean, optional) Default true. Set false for short
|
|
22
|
+
commands when you only need the final output.
|
|
23
|
+
reuseConnection (boolean, optional) Default true. Set false after a timeout
|
|
24
|
+
or suspected stale cached SSH connection to force a fresh
|
|
25
|
+
TCP/SSH connection for this command.
|
|
26
|
+
vvv (boolean, optional) Default false. Append bounded
|
|
27
|
+
SSH/channel debug output. Use with reuseConnection=false
|
|
28
|
+
when you need fresh handshake logs.
|
|
29
|
+
timeout (number, optional) Per-attempt phase timeout in ms:
|
|
30
|
+
SSH setup, exec-channel open, and
|
|
31
|
+
remote command execution each use this
|
|
32
|
+
cap.
|
|
33
|
+
Defaults: 300000 (stream) / 30000 (non-stream).
|
|
25
34
|
|
|
26
35
|
connectionName rule:
|
|
27
36
|
• If only one server is enabled → optional (auto-selected).
|
|
28
37
|
• If multiple servers are enabled → REQUIRED.
|
|
29
38
|
|
|
30
39
|
Examples:
|
|
31
|
-
execute-command { cmdString: "pwd" }
|
|
32
|
-
execute-command { cmdString: "docker ps -a", connectionName: "prod", stream: false }
|
|
33
|
-
execute-command { cmdString: "tail -f /var/log/syslog", stream: true, timeout: 600000 }
|
|
40
|
+
execute-command { cmdString: "pwd" }
|
|
41
|
+
execute-command { cmdString: "docker ps -a", connectionName: "prod", stream: false }
|
|
42
|
+
execute-command { cmdString: "tail -f /var/log/syslog", stream: true, timeout: 600000 }
|
|
43
|
+
execute-command { cmdString: "hostname", connectionName: "scnet", stream: false, reuseConnection: false }
|
|
44
|
+
execute-command { cmdString: "hostname", connectionName: "scnet", stream: false, reuseConnection: false, vvv: true }`,
|
|
34
45
|
"show-whitelist": `show-whitelist — Show the active command policy for a server.
|
|
35
46
|
|
|
36
47
|
Parameters:
|
|
@@ -41,7 +52,25 @@ connectionName rule:
|
|
|
41
52
|
• If multiple servers are enabled → REQUIRED.
|
|
42
53
|
|
|
43
54
|
Returns: Command mode, built-in blacklist, configured whitelist/blacklist patterns, and example commands when whitelist mode is active.`,
|
|
44
|
-
"
|
|
55
|
+
"close-connection": `close-connection — Close a cached SSH connection.
|
|
56
|
+
|
|
57
|
+
Parameters:
|
|
58
|
+
connectionName (string, see below) Target server name from list-servers.
|
|
59
|
+
|
|
60
|
+
connectionName rule:
|
|
61
|
+
• If only one server is enabled → optional (auto-selected).
|
|
62
|
+
• If multiple servers are enabled → REQUIRED.
|
|
63
|
+
|
|
64
|
+
Behavior:
|
|
65
|
+
• Closes the cached/reused SSH client for the target server.
|
|
66
|
+
• Closing a jump host also closes cached targets whose jump chain uses it.
|
|
67
|
+
• Does not affect reuseConnection=false commands, because those one-shot
|
|
68
|
+
connections already close after each command.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
close-connection { connectionName: "scnet" }
|
|
72
|
+
close-connection { connectionName: "dcu" }`,
|
|
73
|
+
"upload": `upload — Upload a single local file to a remote server over SFTP.
|
|
45
74
|
|
|
46
75
|
Parameters:
|
|
47
76
|
localPath (string, required) File path on the MCP host.
|
|
@@ -113,18 +142,20 @@ Example:
|
|
|
113
142
|
};
|
|
114
143
|
const TOOL_OVERVIEW = `Available tools (use help { tool: "<name>" } for details):
|
|
115
144
|
|
|
116
|
-
list-servers Discover available SSH servers and their status.
|
|
117
|
-
execute-command Run a shell command on a remote server.
|
|
145
|
+
list-servers Discover available SSH servers and their status.
|
|
146
|
+
execute-command Run a shell command on a remote server.
|
|
118
147
|
show-whitelist Show the active command policy.
|
|
119
|
-
|
|
120
|
-
|
|
148
|
+
close-connection Close a cached SSH connection for a server.
|
|
149
|
+
upload Upload a single file to a remote server.
|
|
150
|
+
download Download a single file from a remote server.
|
|
121
151
|
transfer Move files: single, recursive, or cross-server relay.
|
|
122
152
|
help Show this help or detailed per-tool usage.
|
|
123
153
|
|
|
124
154
|
Quick start:
|
|
125
|
-
1. list-servers → discover server names
|
|
155
|
+
1. list-servers → discover server names
|
|
126
156
|
2. show-whitelist { connectionName: "<name>" } → inspect command policy
|
|
127
|
-
3. execute-command { cmdString: "pwd", connectionName: "<name>" }
|
|
157
|
+
3. execute-command { cmdString: "pwd", connectionName: "<name>" }
|
|
158
|
+
4. close-connection { connectionName: "<name>" } → drop a stale cached SSH client`;
|
|
128
159
|
/**
|
|
129
160
|
* Register help tool
|
|
130
161
|
*/
|
package/build/tools/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { registerUploadTool } from "./upload.js";
|
|
|
3
3
|
import { registerDownloadTool } from "./download.js";
|
|
4
4
|
import { registerListServersTool } from "./list-servers.js";
|
|
5
5
|
import { registerShowWhitelistTool } from "./show-whitelist.js";
|
|
6
|
+
import { registerCloseConnectionTool } from "./close-connection.js";
|
|
6
7
|
import { registerTransferTool } from "./transfer.js";
|
|
7
8
|
import { registerHelpTool } from "./help.js";
|
|
8
9
|
/**
|
|
@@ -15,6 +16,7 @@ export function registerAllTools(server) {
|
|
|
15
16
|
registerDownloadTool(server);
|
|
16
17
|
registerListServersTool(server);
|
|
17
18
|
registerShowWhitelistTool(server);
|
|
19
|
+
registerCloseConnectionTool(server);
|
|
18
20
|
registerTransferTool(server);
|
|
19
21
|
registerHelpTool(server);
|
|
20
22
|
}
|