@aaarc/handfree-ssh-mcp 1.0.0

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.
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Tools Unit Tests
3
+ *
4
+ * Tests for list-servers, upload, download, show-whitelist tools
5
+ * Uses mock SSHConnectionManager to avoid actual SSH connections.
6
+ */
7
+ import { describe, it, beforeEach } from "node:test";
8
+ import assert from "node:assert";
9
+ /**
10
+ * Mock SSHConnectionManager for testing tools without real SSH connections
11
+ */
12
+ class MockSSHConnectionManager {
13
+ configs = {};
14
+ enabledServers = [];
15
+ defaultName = "default";
16
+ setConfig(configs, enabledServers) {
17
+ this.configs = configs;
18
+ this.enabledServers = enabledServers || Object.keys(configs);
19
+ if (this.enabledServers.length > 0) {
20
+ this.defaultName = this.enabledServers[0];
21
+ }
22
+ }
23
+ getServerConfig(name) {
24
+ const serverName = name || this.defaultName;
25
+ if (!this.enabledServers.includes(serverName)) {
26
+ return null;
27
+ }
28
+ return this.configs[serverName] || null;
29
+ }
30
+ getAllServerInfos() {
31
+ return this.enabledServers.map(name => {
32
+ const config = this.configs[name];
33
+ return {
34
+ name,
35
+ host: config?.host || "unknown",
36
+ port: config?.port || 22,
37
+ username: config?.username || "unknown",
38
+ connected: false,
39
+ };
40
+ });
41
+ }
42
+ async upload(localPath, remotePath, connectionName) {
43
+ const serverName = connectionName || this.defaultName;
44
+ if (!this.enabledServers.includes(serverName)) {
45
+ throw new Error(`Server '${serverName}' not enabled`);
46
+ }
47
+ // Mock implementation - just return success message
48
+ return `Uploaded ${localPath} to ${remotePath} on ${serverName}`;
49
+ }
50
+ async download(remotePath, localPath, connectionName) {
51
+ const serverName = connectionName || this.defaultName;
52
+ if (!this.enabledServers.includes(serverName)) {
53
+ throw new Error(`Server '${serverName}' not enabled`);
54
+ }
55
+ // Mock implementation - just return success message
56
+ return `Downloaded ${remotePath} to ${localPath} from ${serverName}`;
57
+ }
58
+ async executeCommand(command, connectionName, options) {
59
+ const serverName = connectionName || this.defaultName;
60
+ if (!this.enabledServers.includes(serverName)) {
61
+ throw new Error(`Server '${serverName}' not enabled`);
62
+ }
63
+ // Mock implementation - simulate command execution
64
+ if (command === "pwd")
65
+ return "/home/testuser";
66
+ if (command.startsWith("ls"))
67
+ return "file1.txt\nfile2.txt\ndir1";
68
+ if (command === "hostname")
69
+ return "test-server";
70
+ if (command === "whoami")
71
+ return "testuser";
72
+ if (command === "date")
73
+ return "Sat Jan 24 12:00:00 UTC 2026";
74
+ return `Executed: ${command}`;
75
+ }
76
+ async executeCommandWithProgress(command, connectionName, options) {
77
+ const serverName = connectionName || this.defaultName;
78
+ if (!this.enabledServers.includes(serverName)) {
79
+ throw new Error(`Server '${serverName}' not enabled`);
80
+ }
81
+ // Simulate streaming output
82
+ if (options?.onProgress) {
83
+ options.onProgress("line 1\n");
84
+ options.onProgress("line 2\n");
85
+ options.onProgress("line 3\n");
86
+ }
87
+ return "line 1\nline 2\nline 3\n";
88
+ }
89
+ disconnect() {
90
+ // No-op for mock
91
+ }
92
+ }
93
+ // ============================================
94
+ // list-servers Tool Tests
95
+ // ============================================
96
+ describe("list-servers Tool", () => {
97
+ let mockManager;
98
+ beforeEach(() => {
99
+ mockManager = new MockSSHConnectionManager();
100
+ });
101
+ it("should return empty array when no servers configured", () => {
102
+ mockManager.setConfig({}, []);
103
+ const result = mockManager.getAllServerInfos();
104
+ assert.deepStrictEqual(result, []);
105
+ });
106
+ it("should return single server info", () => {
107
+ mockManager.setConfig({
108
+ dev: {
109
+ host: "192.168.1.1",
110
+ port: 22,
111
+ username: "testuser",
112
+ password: "testpass",
113
+ }
114
+ }, ["dev"]);
115
+ const result = mockManager.getAllServerInfos();
116
+ assert.strictEqual(result.length, 1);
117
+ assert.strictEqual(result[0].name, "dev");
118
+ assert.strictEqual(result[0].host, "192.168.1.1");
119
+ assert.strictEqual(result[0].port, 22);
120
+ assert.strictEqual(result[0].username, "testuser");
121
+ assert.strictEqual(result[0].connected, false);
122
+ });
123
+ it("should return multiple servers info", () => {
124
+ mockManager.setConfig({
125
+ dev: { host: "10.0.0.1", port: 22, username: "dev" },
126
+ prod: { host: "10.0.0.2", port: 2222, username: "prod" },
127
+ staging: { host: "10.0.0.3", port: 22, username: "staging" },
128
+ }, ["dev", "prod", "staging"]);
129
+ const result = mockManager.getAllServerInfos();
130
+ assert.strictEqual(result.length, 3);
131
+ assert.strictEqual(result[0].name, "dev");
132
+ assert.strictEqual(result[1].name, "prod");
133
+ assert.strictEqual(result[2].name, "staging");
134
+ assert.strictEqual(result[1].port, 2222);
135
+ });
136
+ it("should only return enabled servers", () => {
137
+ mockManager.setConfig({
138
+ dev: { host: "10.0.0.1", port: 22, username: "dev" },
139
+ prod: { host: "10.0.0.2", port: 22, username: "prod" },
140
+ staging: { host: "10.0.0.3", port: 22, username: "staging" },
141
+ }, ["dev", "staging"]); // prod not enabled
142
+ const result = mockManager.getAllServerInfos();
143
+ assert.strictEqual(result.length, 2);
144
+ const names = result.map(s => s.name);
145
+ assert.ok(names.includes("dev"));
146
+ assert.ok(names.includes("staging"));
147
+ assert.ok(!names.includes("prod"));
148
+ });
149
+ });
150
+ // ============================================
151
+ // show-whitelist Tool Tests
152
+ // ============================================
153
+ describe("show-whitelist Tool", () => {
154
+ let mockManager;
155
+ beforeEach(() => {
156
+ mockManager = new MockSSHConnectionManager();
157
+ });
158
+ it("should return null for non-existent server", () => {
159
+ mockManager.setConfig({
160
+ dev: { host: "192.168.1.1", username: "test", password: "test" }
161
+ }, ["dev"]);
162
+ const result = mockManager.getServerConfig("nonexistent");
163
+ assert.strictEqual(result, null);
164
+ });
165
+ it("should return config for valid server", () => {
166
+ const devConfig = {
167
+ host: "192.168.1.1",
168
+ port: 22,
169
+ username: "testuser",
170
+ password: "testpass",
171
+ commandWhitelist: ["^ls.*$", "^pwd$"],
172
+ };
173
+ mockManager.setConfig({ dev: devConfig }, ["dev"]);
174
+ const result = mockManager.getServerConfig("dev");
175
+ assert.ok(result);
176
+ assert.strictEqual(result.host, "192.168.1.1");
177
+ assert.deepStrictEqual(result.commandWhitelist, ["^ls.*$", "^pwd$"]);
178
+ });
179
+ it("should return default server config when name not specified", () => {
180
+ const devConfig = {
181
+ host: "192.168.1.1",
182
+ username: "dev",
183
+ password: "test",
184
+ };
185
+ mockManager.setConfig({ dev: devConfig }, ["dev"]);
186
+ const result = mockManager.getServerConfig(); // No name - uses default
187
+ assert.ok(result);
188
+ assert.strictEqual(result.host, "192.168.1.1");
189
+ });
190
+ it("should return null for disabled server", () => {
191
+ mockManager.setConfig({
192
+ dev: { host: "10.0.0.1", username: "dev", password: "test" },
193
+ prod: { host: "10.0.0.2", username: "prod", password: "test" },
194
+ }, ["dev"]); // Only dev enabled
195
+ const result = mockManager.getServerConfig("prod");
196
+ assert.strictEqual(result, null);
197
+ });
198
+ it("should return config with blacklist", () => {
199
+ const devConfig = {
200
+ host: "192.168.1.1",
201
+ username: "test",
202
+ password: "test",
203
+ commandWhitelist: ["^.*$"],
204
+ commandBlacklist: ["^rm.*$", "^shutdown.*$"],
205
+ };
206
+ mockManager.setConfig({ dev: devConfig }, ["dev"]);
207
+ const result = mockManager.getServerConfig("dev");
208
+ assert.ok(result);
209
+ assert.deepStrictEqual(result.commandBlacklist, ["^rm.*$", "^shutdown.*$"]);
210
+ });
211
+ });
212
+ // ============================================
213
+ // upload Tool Tests
214
+ // ============================================
215
+ describe("upload Tool", () => {
216
+ let mockManager;
217
+ beforeEach(() => {
218
+ mockManager = new MockSSHConnectionManager();
219
+ mockManager.setConfig({
220
+ dev: { host: "192.168.1.1", username: "test", password: "test" }
221
+ }, ["dev"]);
222
+ });
223
+ it("should upload file to default server", async () => {
224
+ const result = await mockManager.upload("/local/file.txt", "/remote/file.txt");
225
+ assert.ok(result.includes("Uploaded"));
226
+ assert.ok(result.includes("/local/file.txt"));
227
+ assert.ok(result.includes("/remote/file.txt"));
228
+ });
229
+ it("should upload file to specified server", async () => {
230
+ const result = await mockManager.upload("/local/file.txt", "/remote/file.txt", "dev");
231
+ assert.ok(result.includes("dev"));
232
+ });
233
+ it("should throw error for disabled server", async () => {
234
+ mockManager.setConfig({
235
+ dev: { host: "192.168.1.1", username: "test", password: "test" },
236
+ prod: { host: "192.168.1.2", username: "test", password: "test" },
237
+ }, ["dev"]); // Only dev enabled
238
+ await assert.rejects(() => mockManager.upload("/local/file.txt", "/remote/file.txt", "prod"), /not enabled/);
239
+ });
240
+ });
241
+ // ============================================
242
+ // download Tool Tests
243
+ // ============================================
244
+ describe("download Tool", () => {
245
+ let mockManager;
246
+ beforeEach(() => {
247
+ mockManager = new MockSSHConnectionManager();
248
+ mockManager.setConfig({
249
+ dev: { host: "192.168.1.1", username: "test", password: "test" }
250
+ }, ["dev"]);
251
+ });
252
+ it("should download file from default server", async () => {
253
+ const result = await mockManager.download("/remote/file.txt", "/local/file.txt");
254
+ assert.ok(result.includes("Downloaded"));
255
+ assert.ok(result.includes("/remote/file.txt"));
256
+ assert.ok(result.includes("/local/file.txt"));
257
+ });
258
+ it("should download file from specified server", async () => {
259
+ const result = await mockManager.download("/remote/file.txt", "/local/file.txt", "dev");
260
+ assert.ok(result.includes("dev"));
261
+ });
262
+ it("should throw error for disabled server", async () => {
263
+ mockManager.setConfig({
264
+ dev: { host: "192.168.1.1", username: "test", password: "test" },
265
+ prod: { host: "192.168.1.2", username: "test", password: "test" },
266
+ }, ["dev"]); // Only dev enabled
267
+ await assert.rejects(() => mockManager.download("/remote/file.txt", "/local/file.txt", "prod"), /not enabled/);
268
+ });
269
+ });
270
+ // ============================================
271
+ // execute-command Streaming Mode Tests
272
+ // ============================================
273
+ describe("execute-command Streaming Mode", () => {
274
+ let mockManager;
275
+ beforeEach(() => {
276
+ mockManager = new MockSSHConnectionManager();
277
+ mockManager.setConfig({
278
+ dev: { host: "192.168.1.1", username: "test", password: "test" }
279
+ }, ["dev"]);
280
+ });
281
+ it("should execute command without streaming", async () => {
282
+ const result = await mockManager.executeCommand("pwd", "dev");
283
+ assert.strictEqual(result, "/home/testuser");
284
+ });
285
+ it("should execute command with streaming and receive chunks", async () => {
286
+ const chunks = [];
287
+ const onProgress = (chunk) => chunks.push(chunk);
288
+ const result = await mockManager.executeCommandWithProgress("ls -la", "dev", {
289
+ onProgress,
290
+ });
291
+ assert.ok(chunks.length > 0);
292
+ assert.ok(result.includes("line 1"));
293
+ assert.ok(result.includes("line 2"));
294
+ assert.ok(result.includes("line 3"));
295
+ });
296
+ it("should handle custom timeout", async () => {
297
+ const result = await mockManager.executeCommandWithProgress("long-command", "dev", {
298
+ timeout: 60000,
299
+ });
300
+ assert.ok(result);
301
+ });
302
+ it("should throw error for disabled server in streaming mode", async () => {
303
+ mockManager.setConfig({
304
+ dev: { host: "192.168.1.1", username: "test", password: "test" },
305
+ prod: { host: "192.168.1.2", username: "test", password: "test" },
306
+ }, ["dev"]);
307
+ await assert.rejects(() => mockManager.executeCommandWithProgress("pwd", "prod"), /not enabled/);
308
+ });
309
+ });
310
+ // ============================================
311
+ // Timeout Behavior Tests (Logic Simulation)
312
+ // ============================================
313
+ /**
314
+ * Helper to calculate effective timeout based on stream mode
315
+ * Matches the logic in execute-command.ts
316
+ */
317
+ function calculateTimeout(stream, timeout) {
318
+ const useStream = stream !== false;
319
+ if (useStream) {
320
+ return timeout || 300000; // 5 min default for streaming
321
+ }
322
+ else {
323
+ return timeout || 30000; // 30s default for non-streaming
324
+ }
325
+ }
326
+ describe("Timeout Behavior", () => {
327
+ it("should use default 30s timeout for non-streaming mode", () => {
328
+ const effectiveTimeout = calculateTimeout(false, undefined);
329
+ assert.strictEqual(effectiveTimeout, 30000);
330
+ });
331
+ it("should use default 300s timeout for streaming mode", () => {
332
+ const effectiveTimeout = calculateTimeout(true, undefined);
333
+ assert.strictEqual(effectiveTimeout, 300000);
334
+ });
335
+ it("should use custom timeout when specified in streaming mode", () => {
336
+ const effectiveTimeout = calculateTimeout(true, 60000);
337
+ assert.strictEqual(effectiveTimeout, 60000);
338
+ });
339
+ it("should use custom timeout when specified in non-streaming mode", () => {
340
+ const effectiveTimeout = calculateTimeout(false, 5000);
341
+ assert.strictEqual(effectiveTimeout, 5000);
342
+ });
343
+ it("should default stream to true when not specified (undefined)", () => {
344
+ const effectiveTimeout = calculateTimeout(undefined, undefined);
345
+ // undefined !== false is true, so streaming mode is used
346
+ assert.strictEqual(effectiveTimeout, 300000);
347
+ });
348
+ });
349
+ // ============================================
350
+ // Pattern Helper Function Tests (show-whitelist)
351
+ // ============================================
352
+ describe("patternToReadable (show-whitelist helper)", () => {
353
+ function patternToReadable(pattern) {
354
+ let readable = pattern;
355
+ readable = readable.replace(/^\^/, "").replace(/\$$/, "");
356
+ if (readable === "ls( .*)?")
357
+ return "ls, ls -la, ls /path, etc.";
358
+ if (readable === "cat .*")
359
+ return "cat <file>";
360
+ if (readable === "pwd")
361
+ return "pwd (current directory)";
362
+ if (readable === "whoami")
363
+ return "whoami (current user)";
364
+ if (readable === "hostname")
365
+ return "hostname";
366
+ if (readable === "date")
367
+ return "date (current time)";
368
+ if (readable === "docker ps.*")
369
+ return "docker ps, docker ps -a, etc.";
370
+ if (readable === "docker logs.*")
371
+ return "docker logs <container>";
372
+ if (readable === "git .*")
373
+ return "git <any subcommand>";
374
+ if (readable.includes(".*"))
375
+ return readable.replace(/\.\*/g, "<any>");
376
+ if (readable.includes(".+"))
377
+ return readable.replace(/\.\+/g, "<required>");
378
+ return readable;
379
+ }
380
+ it("should convert ls pattern to readable", () => {
381
+ assert.strictEqual(patternToReadable("^ls( .*)?$"), "ls, ls -la, ls /path, etc.");
382
+ });
383
+ it("should convert cat pattern to readable", () => {
384
+ assert.strictEqual(patternToReadable("^cat .*$"), "cat <file>");
385
+ });
386
+ it("should convert pwd pattern to readable", () => {
387
+ assert.strictEqual(patternToReadable("^pwd$"), "pwd (current directory)");
388
+ });
389
+ it("should convert docker ps pattern to readable", () => {
390
+ assert.strictEqual(patternToReadable("^docker ps.*$"), "docker ps, docker ps -a, etc.");
391
+ });
392
+ it("should convert git pattern to readable", () => {
393
+ assert.strictEqual(patternToReadable("^git .*$"), "git <any subcommand>");
394
+ });
395
+ it("should replace .* with <any> for unknown patterns", () => {
396
+ assert.strictEqual(patternToReadable("^custom .*$"), "custom <any>");
397
+ });
398
+ it("should replace .+ with <required> for unknown patterns", () => {
399
+ assert.strictEqual(patternToReadable("^custom .+$"), "custom <required>");
400
+ });
401
+ });
402
+ // ============================================
403
+ // Example Generation Tests (show-whitelist)
404
+ // ============================================
405
+ describe("generateExamples (show-whitelist helper)", () => {
406
+ function generateExamples(whitelist) {
407
+ const examples = [];
408
+ for (const pattern of whitelist) {
409
+ if (pattern.includes("^ls"))
410
+ examples.push("ls -la");
411
+ if (pattern.includes("^cat"))
412
+ examples.push("cat /etc/hostname");
413
+ if (pattern.includes("^pwd"))
414
+ examples.push("pwd");
415
+ if (pattern.includes("^whoami"))
416
+ examples.push("whoami");
417
+ if (pattern.includes("^hostname"))
418
+ examples.push("hostname");
419
+ if (pattern.includes("^date"))
420
+ examples.push("date");
421
+ if (pattern.includes("^echo"))
422
+ examples.push("echo 'hello world'");
423
+ if (pattern.includes("^docker ps"))
424
+ examples.push("docker ps -a");
425
+ if (pattern.includes("^docker logs"))
426
+ examples.push("docker logs --tail 50 <container>");
427
+ if (pattern.includes("^git"))
428
+ examples.push("git status");
429
+ }
430
+ return [...new Set(examples)];
431
+ }
432
+ it("should generate ls example", () => {
433
+ const examples = generateExamples(["^ls( .*)?$"]);
434
+ assert.ok(examples.includes("ls -la"));
435
+ });
436
+ it("should generate docker examples", () => {
437
+ const examples = generateExamples(["^docker ps.*$", "^docker logs.*$"]);
438
+ assert.ok(examples.includes("docker ps -a"));
439
+ assert.ok(examples.includes("docker logs --tail 50 <container>"));
440
+ });
441
+ it("should generate multiple examples for mixed whitelist", () => {
442
+ const examples = generateExamples([
443
+ "^ls( .*)?$",
444
+ "^cat .*$",
445
+ "^pwd$",
446
+ "^git .*$",
447
+ ]);
448
+ assert.ok(examples.includes("ls -la"));
449
+ assert.ok(examples.includes("cat /etc/hostname"));
450
+ assert.ok(examples.includes("pwd"));
451
+ assert.ok(examples.includes("git status"));
452
+ });
453
+ it("should remove duplicates", () => {
454
+ const examples = generateExamples(["^ls.*$", "^ls -la$"]); // Both match ls
455
+ const lsCount = examples.filter(e => e === "ls -la").length;
456
+ assert.strictEqual(lsCount, 1);
457
+ });
458
+ });
459
+ // ============================================
460
+ // Server Selection Tests
461
+ // ============================================
462
+ describe("Server Selection Logic", () => {
463
+ let mockManager;
464
+ beforeEach(() => {
465
+ mockManager = new MockSSHConnectionManager();
466
+ });
467
+ it("should use first enabled server as default", () => {
468
+ mockManager.setConfig({
469
+ alpha: { host: "10.0.0.1", username: "a", password: "a" },
470
+ beta: { host: "10.0.0.2", username: "b", password: "b" },
471
+ }, ["beta", "alpha"]); // beta is first
472
+ const config = mockManager.getServerConfig();
473
+ assert.ok(config);
474
+ assert.strictEqual(config.host, "10.0.0.2"); // beta's host
475
+ });
476
+ it("should use explicit server name over default", () => {
477
+ mockManager.setConfig({
478
+ dev: { host: "10.0.0.1", username: "dev", password: "dev" },
479
+ prod: { host: "10.0.0.2", username: "prod", password: "prod" },
480
+ }, ["dev", "prod"]);
481
+ const config = mockManager.getServerConfig("prod");
482
+ assert.ok(config);
483
+ assert.strictEqual(config.host, "10.0.0.2");
484
+ });
485
+ });
486
+ console.log("\n🧪 Running tools unit tests...\n");
@@ -0,0 +1,31 @@
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 file download tool
7
+ */
8
+ export function registerDownloadTool(server) {
9
+ const sshManager = SSHConnectionManager.getInstance();
10
+ server.tool("download", "Download a file from the remote server to the MCP host over SFTP. Use this when you need to inspect or save a remote file locally. The remote source must live inside one of the server's allowedRemoteDirectories; call show-whitelist to discover the allowed paths.", {
11
+ remotePath: z.string().describe("Path to the file on the remote server. Must be an absolute POSIX path inside one of the server's allowedRemoteDirectories (e.g. /var/log/app.log)."),
12
+ localPath: z.string().describe("Destination path on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
13
+ connectionName: z.string().optional().describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
14
+ }, async ({ remotePath, localPath, connectionName }) => {
15
+ try {
16
+ const resolvedName = sshManager.resolveServer(connectionName);
17
+ const result = await sshManager.download(remotePath, localPath, resolvedName);
18
+ return {
19
+ content: [{ type: "text", text: result }],
20
+ };
21
+ }
22
+ catch (error) {
23
+ const toolError = toToolError(error, "SFTP_ERROR");
24
+ Logger.handleError(toolError, "Failed to download file");
25
+ return {
26
+ content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
27
+ isError: true,
28
+ };
29
+ }
30
+ });
31
+ }
@@ -0,0 +1,80 @@
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 execute command tool
7
+ *
8
+ * Unified command execution tool with streaming by default.
9
+ * - stream=true (default): Real-time output with 5 min timeout
10
+ * - stream=false: Wait for completion, 30s timeout
11
+ */
12
+ export function registerExecuteCommandTool(server) {
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.", {
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 whitelist rules even if each subcommand is safe."),
16
+ connectionName: z
17
+ .string()
18
+ .optional()
19
+ .describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
20
+ timeout: z
21
+ .number()
22
+ .optional()
23
+ .describe("Maximum runtime in milliseconds. Defaults to 300000 when stream=true and 30000 when stream=false. Increase this for long-running commands; reduce it for fast probes."),
24
+ stream: z
25
+ .boolean()
26
+ .optional()
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
+ maxOutputBytes: z
29
+ .number()
30
+ .int()
31
+ .nonnegative()
32
+ .optional()
33
+ .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) => {
35
+ try {
36
+ const resolvedName = sshManager.resolveServer(connectionName);
37
+ const useStream = stream !== false;
38
+ if (useStream) {
39
+ const progressToken = extra._meta?.progressToken;
40
+ let progressCounter = 0;
41
+ const onProgress = progressToken
42
+ ? (chunk) => {
43
+ progressCounter++;
44
+ extra.sendNotification({
45
+ method: "notifications/progress",
46
+ params: {
47
+ progressToken,
48
+ progress: progressCounter,
49
+ message: chunk,
50
+ },
51
+ });
52
+ }
53
+ : undefined;
54
+ const result = await sshManager.executeCommandWithProgress(cmdString, resolvedName, {
55
+ timeout: timeout || 300000,
56
+ maxOutputBytes,
57
+ onProgress,
58
+ });
59
+ return {
60
+ content: [{ type: "text", text: result }],
61
+ };
62
+ }
63
+ const result = await sshManager.executeCommand(cmdString, resolvedName, {
64
+ timeout: timeout || 30000,
65
+ maxOutputBytes,
66
+ });
67
+ return {
68
+ content: [{ type: "text", text: result }],
69
+ };
70
+ }
71
+ catch (error) {
72
+ const toolError = toToolError(error, "COMMAND_EXECUTION_ERROR");
73
+ Logger.handleError(toolError, "Failed to execute command");
74
+ return {
75
+ content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
76
+ isError: true,
77
+ };
78
+ }
79
+ });
80
+ }