@suzko/mcp-server 0.1.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,738 @@
1
+ import { z } from "zod";
2
+ import { execFile } from "node:child_process";
3
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { shellQuote, assertSafePath, assertSafeDomain, assertSafeEmail, } from "../security.js";
7
+ // --- Server Registry ---
8
+ const SERVERS_FILE = join(homedir(), ".suzko", "servers.json");
9
+ async function loadServers() {
10
+ try {
11
+ const raw = await readFile(SERVERS_FILE, "utf-8");
12
+ const data = JSON.parse(raw);
13
+ return data.servers || [];
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
19
+ async function saveServers(servers) {
20
+ const dir = join(homedir(), ".suzko");
21
+ await mkdir(dir, { recursive: true });
22
+ await writeFile(SERVERS_FILE, JSON.stringify({ servers }, null, 2), "utf-8");
23
+ }
24
+ function getServer(servers, alias) {
25
+ return servers.find((s) => s.alias.toLowerCase() === alias.toLowerCase());
26
+ }
27
+ // --- SSH Execution ---
28
+ function resolveKeyPath(keyPath) {
29
+ return keyPath.replace(/^~/, homedir());
30
+ }
31
+ async function sshExec(server, command, timeoutMs = 60_000) {
32
+ const keyPath = resolveKeyPath(server.keyPath);
33
+ return new Promise((resolve, reject) => {
34
+ execFile("ssh", [
35
+ "-i",
36
+ keyPath,
37
+ "-p",
38
+ String(server.port),
39
+ "-o",
40
+ "StrictHostKeyChecking=accept-new",
41
+ "-o",
42
+ "ConnectTimeout=10",
43
+ "-o",
44
+ "BatchMode=yes",
45
+ `${server.user}@${server.host}`,
46
+ command,
47
+ ], { timeout: timeoutMs }, (error, stdout, stderr) => {
48
+ if (error && !stdout && !stderr) {
49
+ reject(new Error(`SSH connection failed: ${error.message}`));
50
+ }
51
+ else {
52
+ resolve({
53
+ stdout: stdout?.toString() ?? "",
54
+ stderr: stderr?.toString() ?? "",
55
+ });
56
+ }
57
+ });
58
+ });
59
+ }
60
+ async function scpUpload(server, localPath, remotePath) {
61
+ const keyPath = resolveKeyPath(server.keyPath);
62
+ return new Promise((resolve, reject) => {
63
+ execFile("scp", [
64
+ "-r",
65
+ "-i",
66
+ keyPath,
67
+ "-P",
68
+ String(server.port),
69
+ "-o",
70
+ "StrictHostKeyChecking=accept-new",
71
+ "-o",
72
+ "ConnectTimeout=10",
73
+ localPath,
74
+ `${server.user}@${server.host}:${remotePath}`,
75
+ ], { timeout: 120_000 }, (error, stdout, stderr) => {
76
+ if (error && !stdout && !stderr) {
77
+ reject(new Error(`SCP upload failed: ${error.message}`));
78
+ }
79
+ else {
80
+ resolve({
81
+ stdout: stdout?.toString() ?? "",
82
+ stderr: stderr?.toString() ?? "",
83
+ });
84
+ }
85
+ });
86
+ });
87
+ }
88
+ // --- Command safety ---
89
+ const DANGEROUS_PATTERNS = [
90
+ /\brm\s+-rf?\s+\/(\s|$|\*|\.|;|&)/,
91
+ /\bdd\s+if=/,
92
+ /\bmkfs(\.|\s)/,
93
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;/,
94
+ />\s*\/dev\/sd[a-z]/,
95
+ /\bchmod\s+-R\s+777\s+\//,
96
+ /\bwget\b.*\|\s*(sudo\s+)?sh\b/,
97
+ /\bcurl\b.*\|\s*(sudo\s+)?sh\b/,
98
+ ];
99
+ const WARN_PATTERNS = [
100
+ /\bshutdown\b/,
101
+ /\breboot\b/,
102
+ /\bhalt\b/,
103
+ /\bpoweroff\b/,
104
+ /\binit\s+0\b/,
105
+ ];
106
+ function checkCommandSafety(command) {
107
+ if (command.includes("\0")) {
108
+ return { blocked: true, warning: "Blocked: command contains a NUL byte." };
109
+ }
110
+ // Match against canonicalised whitespace + case.
111
+ const canon = command.replace(/[\t ]+/g, " ").toLowerCase();
112
+ for (const pattern of DANGEROUS_PATTERNS) {
113
+ if (pattern.test(canon)) {
114
+ return {
115
+ blocked: true,
116
+ warning: `Blocked: command matches dangerous pattern "${pattern.source}". This command could destroy the server.`,
117
+ };
118
+ }
119
+ }
120
+ for (const pattern of WARN_PATTERNS) {
121
+ if (pattern.test(canon)) {
122
+ return {
123
+ blocked: false,
124
+ warning: `⚠️ Warning: this command may shut down or reboot the server. Proceeding anyway.`,
125
+ };
126
+ }
127
+ }
128
+ return { blocked: false, warning: null };
129
+ }
130
+ // --- Helpers ---
131
+ function text(content) {
132
+ return { content: [{ type: "text", text: content }] };
133
+ }
134
+ function notFound(alias) {
135
+ return text(`Server "${alias}" not found. Use list_servers to see registered servers, or connect_server to add one.`);
136
+ }
137
+ // --- Tool Registration ---
138
+ export function registerServerAdminTools(server, _client) {
139
+ // 1. connect_server — register a new server
140
+ server.tool("connect_server", "Register a new server in the local server registry (~/.suzko/servers.json) and test the SSH connection. No Suzko authentication required.", {
141
+ alias: z
142
+ .string()
143
+ .describe("Unique alias for this server (e.g. 'my-vps', 'prod-1')"),
144
+ host: z
145
+ .string()
146
+ .describe("Server hostname or IP address"),
147
+ user: z
148
+ .string()
149
+ .default("root")
150
+ .describe("SSH username (default: root)"),
151
+ port: z
152
+ .number()
153
+ .default(22)
154
+ .describe("SSH port (default: 22)"),
155
+ keyPath: z
156
+ .string()
157
+ .default("~/.ssh/id_ed25519")
158
+ .describe("Path to SSH private key (default: ~/.ssh/id_ed25519)"),
159
+ }, async ({ alias, host, user, port, keyPath }) => {
160
+ const servers = await loadServers();
161
+ // Check for duplicate alias
162
+ if (getServer(servers, alias)) {
163
+ return text(`Server with alias "${alias}" already exists. Remove it first or use a different alias.`);
164
+ }
165
+ const config = { alias, host, user, port, keyPath };
166
+ // Test connection
167
+ try {
168
+ const { stdout } = await sshExec(config, "echo 'SSH_OK' && hostname", 15_000);
169
+ if (!stdout.includes("SSH_OK")) {
170
+ return text(`SSH connection to ${user}@${host}:${port} succeeded but produced unexpected output. Server NOT saved.\n\nOutput:\n${stdout}`);
171
+ }
172
+ servers.push(config);
173
+ await saveServers(servers);
174
+ const hostname = stdout.split("\n")[1]?.trim() || "unknown";
175
+ return text(`✅ Server "${alias}" registered successfully.\n\n` +
176
+ ` Host: ${host}\n` +
177
+ ` User: ${user}\n` +
178
+ ` Port: ${port}\n` +
179
+ ` Key: ${keyPath}\n` +
180
+ ` Hostname: ${hostname}\n\n` +
181
+ `Saved to ${SERVERS_FILE}`);
182
+ }
183
+ catch (e) {
184
+ const msg = e instanceof Error ? e.message : String(e);
185
+ return text(`❌ SSH connection test failed for ${user}@${host}:${port}\n\n` +
186
+ `Error: ${msg}\n\n` +
187
+ `Server was NOT saved. Check:\n` +
188
+ ` - Host is reachable\n` +
189
+ ` - SSH key exists at ${keyPath}\n` +
190
+ ` - Key is authorized on the server\n` +
191
+ ` - Port ${port} is open`);
192
+ }
193
+ });
194
+ // 2. list_servers — list registered servers
195
+ server.tool("list_servers", "List all registered servers from the local registry (~/.suzko/servers.json). No Suzko authentication required.", {}, async () => {
196
+ const servers = await loadServers();
197
+ if (servers.length === 0) {
198
+ return text("No servers registered yet. Use connect_server to add one.");
199
+ }
200
+ const lines = servers.map((s, i) => `${i + 1}. ${s.alias}\n` +
201
+ ` Host: ${s.user}@${s.host}:${s.port}\n` +
202
+ ` Key: ${s.keyPath}`);
203
+ return text(`Registered servers (${servers.length}):\n\n${lines.join("\n\n")}`);
204
+ });
205
+ // 3. inspect_server — run diagnostic commands
206
+ server.tool("inspect_server", "SSH into a registered server and run diagnostic commands (uname, uptime, disk, memory, docker status). Returns a formatted system report.", {
207
+ alias: z.string().describe("Server alias from the registry"),
208
+ }, async ({ alias }) => {
209
+ const servers = await loadServers();
210
+ const srv = getServer(servers, alias);
211
+ if (!srv)
212
+ return notFound(alias);
213
+ const commands = [
214
+ { label: "System", cmd: "uname -a" },
215
+ { label: "Uptime", cmd: "uptime" },
216
+ {
217
+ label: "CPU",
218
+ cmd: "nproc && cat /proc/cpuinfo | grep 'model name' | head -1 | cut -d: -f2",
219
+ },
220
+ { label: "Memory", cmd: "free -m" },
221
+ { label: "Disk", cmd: "df -h" },
222
+ {
223
+ label: "Docker",
224
+ cmd: "docker --version 2>/dev/null && docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}' 2>/dev/null || echo 'Docker not installed'",
225
+ },
226
+ {
227
+ label: "Network",
228
+ cmd: "ip -4 addr show | grep inet | grep -v '127.0.0.1' | awk '{print $2}'",
229
+ },
230
+ ];
231
+ const sections = [];
232
+ sections.push(`🖥️ Server Inspection: ${alias} (${srv.host})\n`);
233
+ for (const { label, cmd } of commands) {
234
+ try {
235
+ const { stdout, stderr } = await sshExec(srv, cmd, 15_000);
236
+ const output = (stdout || stderr).trim() || "(no output)";
237
+ sections.push(`── ${label} ──\n${output}`);
238
+ }
239
+ catch (e) {
240
+ const msg = e instanceof Error ? e.message : String(e);
241
+ sections.push(`── ${label} ──\n⚠️ Failed: ${msg}`);
242
+ }
243
+ }
244
+ return text(sections.join("\n\n"));
245
+ });
246
+ // 4. run_server_command — execute arbitrary command with safety checks
247
+ server.tool("run_server_command", "Execute an arbitrary shell command on a registered server via SSH. Dangerous commands (rm -rf /, dd, mkfs, fork bombs) are blocked. Shutdown/reboot commands trigger a warning but are allowed.", {
248
+ alias: z.string().describe("Server alias from the registry"),
249
+ command: z.string().describe("Shell command to execute"),
250
+ timeout: z
251
+ .number()
252
+ .default(60000)
253
+ .describe("Timeout in milliseconds (default: 60000)"),
254
+ }, async ({ alias, command, timeout }) => {
255
+ const servers = await loadServers();
256
+ const srv = getServer(servers, alias);
257
+ if (!srv)
258
+ return notFound(alias);
259
+ // Safety check
260
+ const safety = checkCommandSafety(command);
261
+ if (safety.blocked) {
262
+ return text(`🚫 ${safety.warning}`);
263
+ }
264
+ const parts = [];
265
+ if (safety.warning) {
266
+ parts.push(safety.warning + "\n");
267
+ }
268
+ parts.push(`$ ${command}\n`);
269
+ try {
270
+ const { stdout, stderr } = await sshExec(srv, command, timeout);
271
+ if (stdout.trim())
272
+ parts.push(stdout.trim());
273
+ if (stderr.trim())
274
+ parts.push(`[stderr]\n${stderr.trim()}`);
275
+ if (!stdout.trim() && !stderr.trim())
276
+ parts.push("(command completed with no output)");
277
+ }
278
+ catch (e) {
279
+ const msg = e instanceof Error ? e.message : String(e);
280
+ parts.push(`❌ Error: ${msg}`);
281
+ }
282
+ return text(parts.join("\n"));
283
+ });
284
+ // 5. install_docker — install Docker via official script
285
+ server.tool("install_docker", "Install Docker on a registered server using the official convenience script (get.docker.com). Also installs docker compose plugin and adds the SSH user to the docker group.", {
286
+ alias: z.string().describe("Server alias from the registry"),
287
+ }, async ({ alias }) => {
288
+ const servers = await loadServers();
289
+ const srv = getServer(servers, alias);
290
+ if (!srv)
291
+ return notFound(alias);
292
+ const steps = [
293
+ {
294
+ label: "Check existing Docker",
295
+ cmd: "docker --version 2>/dev/null || echo 'NOT_INSTALLED'",
296
+ },
297
+ {
298
+ label: "Install Docker",
299
+ cmd: "curl -fsSL https://get.docker.com | sh 2>&1",
300
+ },
301
+ {
302
+ label: "Add user to docker group",
303
+ cmd: `usermod -aG docker ${srv.user} 2>/dev/null || true`,
304
+ },
305
+ {
306
+ label: "Enable Docker service",
307
+ cmd: "systemctl enable docker && systemctl start docker",
308
+ },
309
+ {
310
+ label: "Verify installation",
311
+ cmd: "docker --version && docker compose version 2>/dev/null || docker-compose --version 2>/dev/null || echo 'compose not available'",
312
+ },
313
+ ];
314
+ const output = [];
315
+ output.push(`🐳 Installing Docker on "${alias}" (${srv.host})...\n`);
316
+ for (const { label, cmd } of steps) {
317
+ output.push(`── ${label} ──`);
318
+ try {
319
+ const { stdout, stderr } = await sshExec(srv, cmd, 180_000);
320
+ const result = (stdout || stderr).trim();
321
+ // Skip actual install if Docker already present
322
+ if (label === "Install Docker" &&
323
+ output.some((l) => l.includes("Docker version"))) {
324
+ output.push("Docker already installed, skipping.\n");
325
+ continue;
326
+ }
327
+ output.push(result.length > 2000
328
+ ? result.slice(-2000) + "\n...(truncated)"
329
+ : result);
330
+ output.push("");
331
+ }
332
+ catch (e) {
333
+ const msg = e instanceof Error ? e.message : String(e);
334
+ output.push(`⚠️ Failed: ${msg}\n`);
335
+ }
336
+ }
337
+ return text(output.join("\n"));
338
+ });
339
+ // 6. deploy_to_server — upload and docker compose up
340
+ server.tool("deploy_to_server", "Deploy a local project directory to a registered server. Uploads files via SCP, then runs `docker compose up -d` on the remote path. The local directory should contain a docker-compose.yml.", {
341
+ alias: z.string().describe("Server alias from the registry"),
342
+ localPath: z
343
+ .string()
344
+ .describe("Local directory path to upload"),
345
+ remotePath: z
346
+ .string()
347
+ .default("/opt/deploy")
348
+ .describe("Remote directory path to upload to (default: /opt/deploy)"),
349
+ }, async ({ alias, localPath, remotePath }) => {
350
+ const servers = await loadServers();
351
+ const srv = getServer(servers, alias);
352
+ if (!srv)
353
+ return notFound(alias);
354
+ let safeRemote;
355
+ try {
356
+ safeRemote = assertSafePath(remotePath, {
357
+ allowedPrefixes: ["/opt", "/srv", "/home", "/var/www"],
358
+ });
359
+ }
360
+ catch (e) {
361
+ return text(`❌ Invalid remotePath: ${e instanceof Error ? e.message : String(e)}\n` +
362
+ `Use an absolute path under /opt, /srv, /home, or /var/www.`);
363
+ }
364
+ const qRemote = shellQuote(safeRemote);
365
+ const output = [];
366
+ output.push(`🚀 Deploying to "${alias}" (${srv.host})\n` +
367
+ ` Local: ${localPath}\n` +
368
+ ` Remote: ${safeRemote}\n`);
369
+ // Ensure remote directory exists
370
+ try {
371
+ await sshExec(srv, `mkdir -p ${qRemote}`);
372
+ output.push("✅ Remote directory ready");
373
+ }
374
+ catch (e) {
375
+ const msg = e instanceof Error ? e.message : String(e);
376
+ return text(`❌ Failed to create remote directory: ${msg}`);
377
+ }
378
+ // Upload via SCP
379
+ try {
380
+ output.push("📦 Uploading files via SCP...");
381
+ await scpUpload(srv, localPath, safeRemote);
382
+ output.push("✅ Upload complete");
383
+ }
384
+ catch (e) {
385
+ const msg = e instanceof Error ? e.message : String(e);
386
+ return text(output.join("\n") + `\n\n❌ SCP upload failed: ${msg}`);
387
+ }
388
+ // Docker compose up
389
+ try {
390
+ output.push("🐳 Running docker compose up -d...");
391
+ const { stdout, stderr } = await sshExec(srv, `cd ${qRemote} && docker compose up -d 2>&1 || docker-compose up -d 2>&1`, 120_000);
392
+ const result = (stdout || stderr).trim();
393
+ output.push(result || "(no output)");
394
+ output.push("\n✅ Deployment complete!");
395
+ }
396
+ catch (e) {
397
+ const msg = e instanceof Error ? e.message : String(e);
398
+ output.push(`\n❌ docker compose failed: ${msg}`);
399
+ }
400
+ return text(output.join("\n"));
401
+ });
402
+ // 7. list_server_containers — docker ps
403
+ server.tool("list_server_containers", "List all Docker containers on a registered server (running and stopped).", {
404
+ alias: z.string().describe("Server alias from the registry"),
405
+ all: z
406
+ .boolean()
407
+ .default(true)
408
+ .describe("Show all containers including stopped (default: true)"),
409
+ }, async ({ alias, all }) => {
410
+ const servers = await loadServers();
411
+ const srv = getServer(servers, alias);
412
+ if (!srv)
413
+ return notFound(alias);
414
+ const flag = all ? "-a " : "";
415
+ try {
416
+ const { stdout, stderr } = await sshExec(srv, `docker ps ${flag}--format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'`);
417
+ const result = (stdout || stderr).trim();
418
+ if (!result || result === "NAMES\tIMAGE\tSTATUS\tPORTS") {
419
+ return text(`No containers found on "${alias}". Is Docker installed?`);
420
+ }
421
+ return text(`🐳 Containers on "${alias}" (${srv.host}):\n\n${result}`);
422
+ }
423
+ catch (e) {
424
+ const msg = e instanceof Error ? e.message : String(e);
425
+ return text(`❌ Failed to list containers: ${msg}\n\nMake sure Docker is installed (use install_docker).`);
426
+ }
427
+ });
428
+ // 8. manage_server_container — start/stop/restart/remove
429
+ server.tool("manage_server_container", "Start, stop, restart, or remove a Docker container on a registered server.", {
430
+ alias: z.string().describe("Server alias from the registry"),
431
+ container: z.string().describe("Container name or ID"),
432
+ action: z
433
+ .enum(["start", "stop", "restart", "remove"])
434
+ .describe("Action to perform on the container"),
435
+ }, async ({ alias, container, action }) => {
436
+ const servers = await loadServers();
437
+ const srv = getServer(servers, alias);
438
+ if (!srv)
439
+ return notFound(alias);
440
+ // Sanitize container name to prevent injection
441
+ if (!/^[a-zA-Z0-9_.-]+$/.test(container)) {
442
+ return text("Invalid container name. Only alphanumeric characters, hyphens, underscores, and dots are allowed.");
443
+ }
444
+ const dockerCmd = action === "remove"
445
+ ? `docker rm -f ${shellQuote(container)}`
446
+ : `docker ${action} ${shellQuote(container)}`;
447
+ try {
448
+ const { stdout, stderr } = await sshExec(srv, dockerCmd);
449
+ const result = (stdout || stderr).trim();
450
+ return text(`✅ Container "${container}" — ${action} successful.\n\n${result}`);
451
+ }
452
+ catch (e) {
453
+ const msg = e instanceof Error ? e.message : String(e);
454
+ return text(`❌ Failed to ${action} container: ${msg}`);
455
+ }
456
+ });
457
+ // 9. setup_ssl — install certbot and get SSL certificate
458
+ server.tool("setup_ssl", "Install Certbot and obtain an SSL certificate for a domain on a registered server. Supports nginx and standalone modes.", {
459
+ alias: z.string().describe("Server alias from the registry"),
460
+ domain: z
461
+ .string()
462
+ .describe("Domain name to get SSL certificate for"),
463
+ email: z
464
+ .string()
465
+ .optional()
466
+ .describe("Email for Let's Encrypt notifications (optional)"),
467
+ mode: z
468
+ .enum(["nginx", "standalone"])
469
+ .default("standalone")
470
+ .describe("Certbot mode: 'nginx' for nginx plugin, 'standalone' for port 80 binding (default: standalone)"),
471
+ }, async ({ alias, domain, email, mode }) => {
472
+ const servers = await loadServers();
473
+ const srv = getServer(servers, alias);
474
+ if (!srv)
475
+ return notFound(alias);
476
+ // Strict domain + email validation prevents flag smuggling into certbot.
477
+ let safeDomain;
478
+ let safeEmail;
479
+ try {
480
+ safeDomain = assertSafeDomain(domain);
481
+ if (email)
482
+ safeEmail = assertSafeEmail(email);
483
+ }
484
+ catch (e) {
485
+ return text(`❌ ${e instanceof Error ? e.message : String(e)}`);
486
+ }
487
+ const emailFlag = safeEmail
488
+ ? `--email ${shellQuote(safeEmail)}`
489
+ : "--register-unsafely-without-email";
490
+ const pluginFlag = mode === "nginx" ? "--nginx" : "--standalone";
491
+ const qDomain = shellQuote(safeDomain);
492
+ const steps = [
493
+ {
494
+ label: "Install Certbot",
495
+ cmd: "which certbot >/dev/null 2>&1 && echo 'ALREADY_INSTALLED' || (apt-get update -qq && apt-get install -y -qq certbot" +
496
+ (mode === "nginx"
497
+ ? " python3-certbot-nginx"
498
+ : "") +
499
+ " 2>&1 | tail -5)",
500
+ },
501
+ {
502
+ label: "Obtain SSL Certificate",
503
+ cmd: `certbot certonly ${pluginFlag} -d ${qDomain} ${emailFlag} --agree-tos --non-interactive 2>&1`,
504
+ },
505
+ {
506
+ label: "Verify Certificate",
507
+ cmd: `certbot certificates -d ${qDomain} 2>&1`,
508
+ },
509
+ ];
510
+ const output = [];
511
+ output.push(`🔒 Setting up SSL for "${safeDomain}" on "${alias}" (${srv.host})\n`);
512
+ for (const { label, cmd } of steps) {
513
+ output.push(`── ${label} ──`);
514
+ try {
515
+ const { stdout, stderr } = await sshExec(srv, cmd, 120_000);
516
+ const result = (stdout || stderr).trim();
517
+ output.push(result || "(no output)");
518
+ output.push("");
519
+ }
520
+ catch (e) {
521
+ const msg = e instanceof Error ? e.message : String(e);
522
+ output.push(`⚠️ Failed: ${msg}\n`);
523
+ }
524
+ }
525
+ output.push("💡 Certbot auto-renewal is typically set up via systemd timer.\n" +
526
+ " Check with: systemctl list-timers | grep certbot");
527
+ return text(output.join("\n"));
528
+ });
529
+ // 10. get_server_status — system resource status
530
+ server.tool("get_server_status", "Get current system resource status from a registered server: CPU usage, memory, disk, load average, uptime, and top processes.", {
531
+ alias: z.string().describe("Server alias from the registry"),
532
+ }, async ({ alias }) => {
533
+ const servers = await loadServers();
534
+ const srv = getServer(servers, alias);
535
+ if (!srv)
536
+ return notFound(alias);
537
+ const script = `
538
+ echo "=== UPTIME ==="
539
+ uptime
540
+ echo ""
541
+ echo "=== LOAD AVERAGE ==="
542
+ cat /proc/loadavg
543
+ echo ""
544
+ echo "=== CPU USAGE ==="
545
+ top -bn1 | head -5
546
+ echo ""
547
+ echo "=== MEMORY ==="
548
+ free -h
549
+ echo ""
550
+ echo "=== DISK ==="
551
+ df -h | grep -E '^/dev|Filesystem'
552
+ echo ""
553
+ echo "=== TOP PROCESSES (by CPU) ==="
554
+ ps aux --sort=-%cpu | head -8
555
+ echo ""
556
+ echo "=== NETWORK CONNECTIONS ==="
557
+ ss -tuln | head -15
558
+ `.trim();
559
+ try {
560
+ const { stdout, stderr } = await sshExec(srv, script, 15_000);
561
+ const result = (stdout || stderr).trim();
562
+ return text(`📊 Server Status: "${alias}" (${srv.host})\n\n${result}`);
563
+ }
564
+ catch (e) {
565
+ const msg = e instanceof Error ? e.message : String(e);
566
+ return text(`❌ Failed to get server status: ${msg}`);
567
+ }
568
+ });
569
+ // 11. get_server_logs — read logs from various sources
570
+ server.tool("get_server_logs", "Read logs from a registered server. Supports journalctl (systemd), docker container logs, or tailing arbitrary log files.", {
571
+ alias: z.string().describe("Server alias from the registry"),
572
+ source: z
573
+ .enum(["journalctl", "docker", "file"])
574
+ .describe("Log source: 'journalctl' for systemd, 'docker' for container logs, 'file' to tail a log file"),
575
+ target: z
576
+ .string()
577
+ .optional()
578
+ .describe("For docker: container name. For file: log file path. For journalctl: optional unit name (e.g. 'nginx')."),
579
+ lines: z
580
+ .number()
581
+ .default(50)
582
+ .describe("Number of log lines to retrieve (default: 50)"),
583
+ }, async ({ alias, source, target, lines }) => {
584
+ const servers = await loadServers();
585
+ const srv = getServer(servers, alias);
586
+ if (!srv)
587
+ return notFound(alias);
588
+ let cmd;
589
+ switch (source) {
590
+ case "journalctl":
591
+ cmd = target
592
+ ? `journalctl -u ${target} -n ${lines} --no-pager`
593
+ : `journalctl -n ${lines} --no-pager`;
594
+ break;
595
+ case "docker":
596
+ if (!target) {
597
+ return text("A container name is required for docker logs. Use list_server_containers to find container names.");
598
+ }
599
+ // Sanitize container name
600
+ if (!/^[a-zA-Z0-9_.-]+$/.test(target)) {
601
+ return text("Invalid container name.");
602
+ }
603
+ cmd = `docker logs --tail ${lines} ${shellQuote(target)} 2>&1`;
604
+ break;
605
+ case "file":
606
+ if (!target) {
607
+ return text("A file path is required. Common locations:\n" +
608
+ " /var/log/syslog\n" +
609
+ " /var/log/nginx/access.log\n" +
610
+ " /var/log/nginx/error.log\n" +
611
+ " /var/log/auth.log");
612
+ }
613
+ try {
614
+ assertSafePath(target);
615
+ }
616
+ catch (e) {
617
+ return text(`Invalid file path: ${e instanceof Error ? e.message : String(e)}`);
618
+ }
619
+ cmd = `tail -n ${lines} ${shellQuote(target)} 2>&1`;
620
+ break;
621
+ }
622
+ try {
623
+ const { stdout, stderr } = await sshExec(srv, cmd, 30_000);
624
+ const result = (stdout || stderr).trim();
625
+ if (!result) {
626
+ return text(`No log output from ${source}${target ? ` (${target})` : ""}.`);
627
+ }
628
+ return text(`📋 Logs from "${alias}" — ${source}${target ? ` (${target})` : ""} [last ${lines} lines]:\n\n${result}`);
629
+ }
630
+ catch (e) {
631
+ const msg = e instanceof Error ? e.message : String(e);
632
+ return text(`❌ Failed to read logs: ${msg}`);
633
+ }
634
+ });
635
+ // 12. manage_env_file — read or write .env files on the server
636
+ server.tool("manage_env_file", "Read or write a .env file on a registered server. In GET mode, reads and displays the file. In SET mode, writes key=value pairs (creates the file if it doesn't exist).", {
637
+ alias: z.string().describe("Server alias from the registry"),
638
+ remotePath: z
639
+ .string()
640
+ .describe("Path to the .env file on the server (e.g. /opt/myapp/.env)"),
641
+ mode: z
642
+ .enum(["get", "set"])
643
+ .describe("'get' to read the .env file, 'set' to write key=value pairs"),
644
+ values: z
645
+ .record(z.string())
646
+ .optional()
647
+ .describe("Key-value pairs to write when mode is 'set' (e.g. { \"DB_HOST\": \"localhost\", \"DB_PORT\": \"5432\" })"),
648
+ }, async ({ alias, remotePath, mode, values }) => {
649
+ const servers = await loadServers();
650
+ const srv = getServer(servers, alias);
651
+ if (!srv)
652
+ return notFound(alias);
653
+ let safePath;
654
+ try {
655
+ safePath = assertSafePath(remotePath);
656
+ }
657
+ catch (e) {
658
+ return text(`Invalid remotePath: ${e instanceof Error ? e.message : String(e)}`);
659
+ }
660
+ const qPath = shellQuote(safePath);
661
+ if (mode === "get") {
662
+ try {
663
+ const { stdout, stderr } = await sshExec(srv, `cat ${qPath} 2>&1`);
664
+ const result = (stdout || stderr).trim();
665
+ if (result.includes("No such file") ||
666
+ result.includes("Permission denied")) {
667
+ return text(`⚠️ ${result}`);
668
+ }
669
+ return text(`📄 ${safePath} on "${alias}":\n\n${result}`);
670
+ }
671
+ catch (e) {
672
+ const msg = e instanceof Error ? e.message : String(e);
673
+ return text(`❌ Failed to read .env file: ${msg}`);
674
+ }
675
+ }
676
+ // mode === "set"
677
+ if (!values || Object.keys(values).length === 0) {
678
+ return text("No values provided. Pass key-value pairs in the 'values' parameter.");
679
+ }
680
+ // Validate keys — only allow safe env var names
681
+ for (const key of Object.keys(values)) {
682
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
683
+ return text(`Invalid environment variable name: "${key}". Only letters, numbers, and underscores are allowed.`);
684
+ }
685
+ }
686
+ // Reject control characters in values.
687
+ for (const [key, value] of Object.entries(values)) {
688
+ if (/[\0\r\n]/.test(value)) {
689
+ return text(`Invalid value for "${key}": contains a newline or NUL byte.`);
690
+ }
691
+ }
692
+ // Build the .env content — read existing, merge, write back
693
+ try {
694
+ // Read existing
695
+ let existing = "";
696
+ try {
697
+ const { stdout } = await sshExec(srv, `cat ${qPath} 2>/dev/null || true`);
698
+ existing = stdout;
699
+ }
700
+ catch {
701
+ // File doesn't exist yet, that's fine
702
+ }
703
+ // Parse existing key=value pairs
704
+ const envMap = new Map();
705
+ for (const line of existing.split("\n")) {
706
+ const trimmed = line.trim();
707
+ if (!trimmed || trimmed.startsWith("#"))
708
+ continue;
709
+ const eqIdx = trimmed.indexOf("=");
710
+ if (eqIdx > 0) {
711
+ envMap.set(trimmed.slice(0, eqIdx), trimmed.slice(eqIdx + 1));
712
+ }
713
+ }
714
+ // Merge new values
715
+ for (const [key, value] of Object.entries(values)) {
716
+ envMap.set(key, value);
717
+ }
718
+ // Build content
719
+ const content = Array.from(envMap.entries())
720
+ .map(([k, v]) => `${k}=${v}`)
721
+ .join("\n");
722
+ // Write via printf — quote every variable, including the dir and path.
723
+ const dir = safePath.substring(0, safePath.lastIndexOf("/")) || "/";
724
+ const escapedContent = content.replace(/'/g, "'\\''");
725
+ const cmd = `mkdir -p ${shellQuote(dir)} && printf '%s\\n' '${escapedContent}' > ${qPath}`;
726
+ await sshExec(srv, cmd);
727
+ const updatedKeys = Object.keys(values).join(", ");
728
+ return text(`✅ Updated ${safePath} on "${alias}".\n\n` +
729
+ `Updated keys: ${updatedKeys}\n` +
730
+ `Total keys: ${envMap.size}\n\n` +
731
+ `Current contents:\n${content}`);
732
+ }
733
+ catch (e) {
734
+ const msg = e instanceof Error ? e.message : String(e);
735
+ return text(`❌ Failed to update .env file: ${msg}`);
736
+ }
737
+ });
738
+ }