@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,232 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { Logger } from "./logger.js";
4
+ export class OutputLogWriter {
5
+ filePath;
6
+ stdoutPartPath;
7
+ stderrPartPath;
8
+ serverName;
9
+ username;
10
+ command;
11
+ startedAt;
12
+ stdoutBytes = 0;
13
+ stderrBytes = 0;
14
+ stdoutFd = null;
15
+ stderrFd = null;
16
+ dirEnsured = false;
17
+ closed = false;
18
+ writeFailed = false;
19
+ constructor(opts) {
20
+ this.serverName = sanitizeSegment(opts.serverName) || "unknown";
21
+ this.username = sanitizeSegment(opts.username) || "unknown";
22
+ this.command = opts.command;
23
+ this.startedAt = opts.startedAt ?? new Date();
24
+ const dir = path.join(opts.rootDir, this.serverName, this.username);
25
+ const fileName = makeLogFileName(this.startedAt);
26
+ this.filePath = path.join(dir, fileName);
27
+ this.stdoutPartPath = `${this.filePath}.stdout.part`;
28
+ this.stderrPartPath = `${this.filePath}.stderr.part`;
29
+ }
30
+ /**
31
+ * The absolute path the log will (or did) live at.
32
+ */
33
+ getPath() {
34
+ return this.filePath;
35
+ }
36
+ appendStdout(chunk) {
37
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
38
+ if (buf.length === 0 || this.writeFailed || this.closed)
39
+ return;
40
+ const fd = this.openPartLazily("stdout");
41
+ if (fd === null)
42
+ return;
43
+ try {
44
+ fs.writeSync(fd, buf);
45
+ this.stdoutBytes += buf.length;
46
+ }
47
+ catch (err) {
48
+ this.markFailed(`stdout write: ${err.message}`);
49
+ }
50
+ }
51
+ appendStderr(chunk) {
52
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
53
+ if (buf.length === 0 || this.writeFailed || this.closed)
54
+ return;
55
+ const fd = this.openPartLazily("stderr");
56
+ if (fd === null)
57
+ return;
58
+ try {
59
+ fs.writeSync(fd, buf);
60
+ this.stderrBytes += buf.length;
61
+ }
62
+ catch (err) {
63
+ this.markFailed(`stderr write: ${err.message}`);
64
+ }
65
+ }
66
+ /**
67
+ * Finalize the log: assemble header + stdout part + stderr part + footer
68
+ * into the final file, then delete the parts. Safe to call multiple
69
+ * times; only the first call performs work.
70
+ */
71
+ close(meta) {
72
+ if (this.closed)
73
+ return;
74
+ this.closed = true;
75
+ // Always close FDs first so the parts are flushed on disk.
76
+ this.closeFd("stdout");
77
+ this.closeFd("stderr");
78
+ if (this.writeFailed) {
79
+ // Best-effort: clean up any partial parts so we don't leave junk.
80
+ this.unlinkQuiet(this.stdoutPartPath);
81
+ this.unlinkQuiet(this.stderrPartPath);
82
+ return;
83
+ }
84
+ const finishedAt = meta.finishedAt ?? new Date();
85
+ try {
86
+ this.ensureDir();
87
+ const header = `=== META ===\n` +
88
+ `server: ${this.serverName}\n` +
89
+ `user: ${this.username}\n` +
90
+ `command: ${this.command}\n` +
91
+ `started: ${this.startedAt.toISOString()}\n` +
92
+ `=== STDOUT ===\n`;
93
+ const stderrMarker = `\n=== STDERR ===\n`;
94
+ const footer = `\n=== END ===\n` +
95
+ `exitCode: ${meta.exitCode === null ? "null" : meta.exitCode}\n` +
96
+ `durationMs: ${meta.durationMs}\n` +
97
+ `stdoutBytes: ${this.stdoutBytes}\n` +
98
+ `stderrBytes: ${this.stderrBytes}\n` +
99
+ `finished: ${finishedAt.toISOString()}\n`;
100
+ const outFd = fs.openSync(this.filePath, "w");
101
+ try {
102
+ fs.writeSync(outFd, header);
103
+ this.appendPartTo(outFd, this.stdoutPartPath);
104
+ fs.writeSync(outFd, stderrMarker);
105
+ this.appendPartTo(outFd, this.stderrPartPath);
106
+ fs.writeSync(outFd, footer);
107
+ }
108
+ finally {
109
+ fs.closeSync(outFd);
110
+ }
111
+ }
112
+ catch (err) {
113
+ this.markFailed(`finalize: ${err.message}`);
114
+ }
115
+ finally {
116
+ this.unlinkQuiet(this.stdoutPartPath);
117
+ this.unlinkQuiet(this.stderrPartPath);
118
+ }
119
+ }
120
+ didWriteFail() {
121
+ return this.writeFailed;
122
+ }
123
+ /**
124
+ * Open the stdout/stderr part file on first use. Returns the FD or null
125
+ * if the open failed (in which case the writer is marked failed).
126
+ */
127
+ openPartLazily(which) {
128
+ const existing = which === "stdout" ? this.stdoutFd : this.stderrFd;
129
+ if (existing !== null)
130
+ return existing;
131
+ try {
132
+ this.ensureDir();
133
+ const partPath = which === "stdout" ? this.stdoutPartPath : this.stderrPartPath;
134
+ const fd = fs.openSync(partPath, "w");
135
+ if (which === "stdout")
136
+ this.stdoutFd = fd;
137
+ else
138
+ this.stderrFd = fd;
139
+ return fd;
140
+ }
141
+ catch (err) {
142
+ this.markFailed(`open ${which} part: ${err.message}`);
143
+ return null;
144
+ }
145
+ }
146
+ closeFd(which) {
147
+ const fd = which === "stdout" ? this.stdoutFd : this.stderrFd;
148
+ if (fd === null)
149
+ return;
150
+ try {
151
+ fs.closeSync(fd);
152
+ }
153
+ catch {
154
+ // Ignore close errors — we still want to attempt finalization.
155
+ }
156
+ if (which === "stdout")
157
+ this.stdoutFd = null;
158
+ else
159
+ this.stderrFd = null;
160
+ }
161
+ ensureDir() {
162
+ if (this.dirEnsured)
163
+ return;
164
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
165
+ this.dirEnsured = true;
166
+ }
167
+ /**
168
+ * Stream the contents of a part file into the given output FD, 64 KiB at
169
+ * a time. Missing parts are silently skipped (they just mean that stream
170
+ * never produced any bytes).
171
+ */
172
+ appendPartTo(outFd, partPath) {
173
+ let inFd;
174
+ try {
175
+ inFd = fs.openSync(partPath, "r");
176
+ }
177
+ catch {
178
+ return; // part never created
179
+ }
180
+ try {
181
+ const chunk = Buffer.allocUnsafe(64 * 1024);
182
+ while (true) {
183
+ const n = fs.readSync(inFd, chunk, 0, chunk.length, null);
184
+ if (n <= 0)
185
+ break;
186
+ fs.writeSync(outFd, chunk, 0, n);
187
+ }
188
+ }
189
+ finally {
190
+ fs.closeSync(inFd);
191
+ }
192
+ }
193
+ unlinkQuiet(p) {
194
+ try {
195
+ fs.unlinkSync(p);
196
+ }
197
+ catch {
198
+ // Missing or in-use: ignore. Cleanup is best-effort.
199
+ }
200
+ }
201
+ markFailed(reason) {
202
+ this.writeFailed = true;
203
+ Logger.log(`OutputLogWriter failure for ${this.filePath}: ${reason}`, "error");
204
+ }
205
+ }
206
+ /**
207
+ * Build a filename like `20250516T021530Z-12345-ab12cd34.log` so concurrent
208
+ * commands never collide and the names sort chronologically.
209
+ */
210
+ function makeLogFileName(startedAt) {
211
+ const iso = startedAt.toISOString().replace(/[-:.]/g, "").replace(/Z$/, "Z");
212
+ // iso is now like "20250516T021530123Z"; trim millis for brevity
213
+ const ts = iso.replace(/(\d{8}T\d{6})\d*Z$/, "$1Z");
214
+ const pid = process.pid;
215
+ const rand = Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, "0");
216
+ return `${ts}-${pid}-${rand}.log`;
217
+ }
218
+ /**
219
+ * Sanitize a server / user name into a safe path segment. Replaces anything
220
+ * outside [A-Za-z0-9._-] with `_` and forbids `..` / leading dots so the
221
+ * result can never escape the root dir.
222
+ */
223
+ function sanitizeSegment(seg) {
224
+ if (!seg)
225
+ return "";
226
+ let cleaned = seg.replace(/[^A-Za-z0-9._-]/g, "_");
227
+ // Collapse leading dots so we can't accidentally produce ".." or a hidden segment.
228
+ cleaned = cleaned.replace(/^\.+/, "");
229
+ if (cleaned === "" || cleaned === "..")
230
+ return "";
231
+ return cleaned;
232
+ }
@@ -0,0 +1,212 @@
1
+ import { Logger } from "./logger.js";
2
+ /**
3
+ * Collect system status information from remote server
4
+ */
5
+ export async function collectSystemStatus(client, connectionName) {
6
+ const status = {
7
+ reachable: true,
8
+ lastUpdated: new Date().toISOString(),
9
+ };
10
+ try {
11
+ // Helper function to execute command and parse output
12
+ const execCommand = (command) => {
13
+ return new Promise((resolve, reject) => {
14
+ client.exec(command, (err, stream) => {
15
+ if (err) {
16
+ reject(err);
17
+ return;
18
+ }
19
+ let data = "";
20
+ stream.on("data", (chunk) => {
21
+ data += chunk.toString();
22
+ });
23
+ stream.on("close", (code) => {
24
+ if (code === 0) {
25
+ resolve(data.trim());
26
+ }
27
+ else {
28
+ reject(new Error(`Command exited with code ${code}`));
29
+ }
30
+ });
31
+ stream.stderr.on("data", (chunk) => {
32
+ // Collect stderr but don't fail on it
33
+ data += chunk.toString();
34
+ });
35
+ });
36
+ });
37
+ };
38
+ // Collect all status information in parallel where possible
39
+ const commands = {
40
+ hostname: "hostname",
41
+ ipAddresses: "ip -o addr show | awk '{print $4}' | grep -v '^127\\.' | cut -d'/' -f1",
42
+ osName: "uname -s",
43
+ osVersion: "cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d'=' -f2 | tr -d '\"' || uname -o",
44
+ kernelVersion: "uname -r",
45
+ uptime: "uptime -p 2>/dev/null || uptime | awk -F'up ' '{print $2}' | awk -F',' '{print $1}'",
46
+ diskSpace: "df -h / | tail -1 | awk '{print \"free:\" $4 \" total:\" $2}'",
47
+ memory: "free -h | grep '^Mem:' | awk '{print \"free:\" $7 \" total:\" $2}'",
48
+ cpuName: "sh -c '(lscpu 2>/dev/null | grep \"^Model name:\" | cut -d\":\" -f2 | xargs || cat /proc/cpuinfo 2>/dev/null | grep \"model name\" | head -1 | cut -d\":\" -f2 | xargs || echo \"$(nproc 2>/dev/null || echo '\''?'\'')-core $(uname -m 2>/dev/null || echo '\''unknown'\'') processor\") || true'",
49
+ cpuUsage: "top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - $1}'",
50
+ gpus: "sh -c '(nvidia-smi --query-gpu=name,utilization.gpu --format=csv,noheader,nounits 2>/dev/null | while IFS=\",\" read -r name usage; do echo \"NVIDIA|${name}|${usage}\"; done || lspci | grep -iE \"vga|3d|display\" | while read -r line; do gpu_name=$(echo \"$line\" | cut -d\":\" -f3 | xargs); echo \"OTHER|${gpu_name}|\"; done) || true'",
51
+ gpuPaths: "ls -1 /dev/dri/card* 2>/dev/null | sort -V || echo ''",
52
+ drives: "df -h | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|overlay|shfs|rootfs)$/ && $6 !~ /^(\\/dev|\\/run|\\/sys|\\/proc|\\/boot|\\/usr|\\/lib)$/ && $6 != \"\" {print $1\"|\"$2\"|\"$3\"|\"$4\"|\"$5\"|\"$6}'",
53
+ // Old gpuPaths: "sh -c '(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>/dev/null | head -1 || rocm-smi --showuse 2>/dev/null | grep -i \"GPU use\" | head -1 | awk \"{print \\$NF}\" | tr -d \"%\" || radeontop -l 1 -d - 2>/dev/null | tail -1 | sed -n \"s/.*gpu \\([0-9.]*\\)%.*/\\1/p\" || intel_gpu_top -l 1 -o - 2>/dev/null | tail -1 | awk \"{print \\$NF}\" | tr -d \"%\" || echo \"N/A\") || echo \"N/A\"'",
54
+ processes: "ps aux | wc -l",
55
+ threads: "ps -eLf | wc -l",
56
+ servicesRunning: "systemctl list-units --type=service --state=running 2>/dev/null | wc -l || service --status-all 2>/dev/null | grep running | wc -l || echo '0'",
57
+ servicesInstalled: "systemctl list-unit-files --type=service 2>/dev/null | wc -l || ls /etc/init.d/ 2>/dev/null | wc -l || echo '0'",
58
+ };
59
+ // Execute commands and collect results
60
+ const results = await Promise.allSettled([
61
+ execCommand(commands.hostname).catch(() => ""),
62
+ execCommand(commands.ipAddresses).catch(() => ""),
63
+ execCommand(commands.osName).catch(() => ""),
64
+ execCommand(commands.osVersion).catch(() => ""),
65
+ execCommand(commands.kernelVersion).catch(() => ""),
66
+ execCommand(commands.uptime).catch(() => ""),
67
+ execCommand(commands.diskSpace).catch(() => ""),
68
+ execCommand(commands.memory).catch(() => ""),
69
+ execCommand(commands.cpuName).catch(() => ""),
70
+ execCommand(commands.cpuUsage).catch(() => ""),
71
+ execCommand(commands.gpus).catch(() => ""),
72
+ execCommand(commands.gpuPaths).catch(() => ""),
73
+ execCommand(commands.drives).catch(() => ""),
74
+ execCommand(commands.processes).catch(() => "0"),
75
+ execCommand(commands.threads).catch(() => "0"),
76
+ execCommand(commands.servicesRunning).catch(() => "0"),
77
+ execCommand(commands.servicesInstalled).catch(() => "0"),
78
+ ]);
79
+ // Parse results
80
+ const [hostnameResult, ipAddressesResult, osNameResult, osVersionResult, kernelVersionResult, uptimeResult, diskSpaceResult, memoryResult, cpuNameResult, cpuUsageResult, gpusResult, gpuPathsResult, drivesResult, processesResult, threadsResult, servicesRunningResult, servicesInstalledResult,] = results;
81
+ if (hostnameResult.status === "fulfilled" && hostnameResult.value) {
82
+ status.hostname = hostnameResult.value;
83
+ }
84
+ if (ipAddressesResult.status === "fulfilled" && ipAddressesResult.value) {
85
+ status.ipAddresses = ipAddressesResult.value
86
+ .split("\n")
87
+ .filter((ip) => ip.trim() && !ip.includes("127.0.0.1"));
88
+ }
89
+ if (osNameResult.status === "fulfilled" && osNameResult.value) {
90
+ status.osName = osNameResult.value;
91
+ }
92
+ if (osVersionResult.status === "fulfilled" && osVersionResult.value) {
93
+ status.osVersion = osVersionResult.value;
94
+ }
95
+ if (kernelVersionResult.status === "fulfilled" && kernelVersionResult.value) {
96
+ status.kernelVersion = kernelVersionResult.value;
97
+ }
98
+ if (uptimeResult.status === "fulfilled" && uptimeResult.value) {
99
+ status.uptime = uptimeResult.value;
100
+ }
101
+ if (diskSpaceResult.status === "fulfilled" && diskSpaceResult.value) {
102
+ const diskMatch = diskSpaceResult.value.match(/free:(\S+)\s+total:(\S+)/);
103
+ if (diskMatch) {
104
+ status.diskSpace = {
105
+ free: diskMatch[1],
106
+ total: diskMatch[2],
107
+ };
108
+ }
109
+ }
110
+ if (memoryResult.status === "fulfilled" && memoryResult.value) {
111
+ const memMatch = memoryResult.value.match(/free:(\S+)\s+total:(\S+)/);
112
+ if (memMatch) {
113
+ status.memory = {
114
+ free: memMatch[1],
115
+ total: memMatch[2],
116
+ };
117
+ }
118
+ }
119
+ // Handle CPU name
120
+ if (cpuNameResult.status === "fulfilled" && cpuNameResult.value && cpuNameResult.value.trim()) {
121
+ status.cpu = {
122
+ name: cpuNameResult.value.trim(),
123
+ };
124
+ }
125
+ if (status.cpu && cpuUsageResult.status === "fulfilled" && cpuUsageResult.value && cpuUsageResult.value !== "N/A") {
126
+ status.cpu.usage = `${parseFloat(cpuUsageResult.value).toFixed(1)}%`;
127
+ }
128
+ // Handle GPUs
129
+ if (gpusResult.status === "fulfilled" && gpusResult.value && gpusResult.value.trim()) {
130
+ const gpuPaths = [];
131
+ if (gpuPathsResult.status === "fulfilled" && gpuPathsResult.value) {
132
+ gpuPaths.push(...gpuPathsResult.value.split("\n").filter((p) => p.trim()));
133
+ }
134
+ const gpuLines = gpusResult.value.split("\n").filter((line) => line.trim());
135
+ const gpus = [];
136
+ gpuLines.forEach((line, index) => {
137
+ const parts = line.split("|");
138
+ if (parts.length >= 2) {
139
+ const vendor = parts[0].trim();
140
+ const name = parts[1].trim();
141
+ const usage = parts[2]?.trim();
142
+ if (name && name !== "N/A") {
143
+ const gpu = {
144
+ name: name,
145
+ };
146
+ if (usage && usage.trim() !== "" && usage.trim() !== "N/A" && !isNaN(parseFloat(usage.trim()))) {
147
+ gpu.usage = `${parseFloat(usage.trim()).toFixed(1)}%`;
148
+ }
149
+ // Assign path if available
150
+ if (gpuPaths[index]) {
151
+ gpu.path = gpuPaths[index];
152
+ }
153
+ gpus.push(gpu);
154
+ }
155
+ }
156
+ });
157
+ if (gpus.length > 0) {
158
+ status.gpus = gpus;
159
+ }
160
+ }
161
+ // Handle drives
162
+ if (drivesResult.status === "fulfilled" && drivesResult.value && drivesResult.value.trim()) {
163
+ const driveLines = drivesResult.value.split("\n").filter((line) => line.trim());
164
+ const drives = [];
165
+ driveLines.forEach((line) => {
166
+ const parts = line.split("|");
167
+ if (parts.length >= 6) {
168
+ const device = parts[0].trim();
169
+ const total = parts[1].trim();
170
+ const used = parts[2].trim();
171
+ const free = parts[3].trim();
172
+ const usagePercent = parts[4].trim();
173
+ const mountPoint = parts[5].trim();
174
+ if (device && mountPoint) {
175
+ drives.push({
176
+ device,
177
+ mountPoint,
178
+ total,
179
+ used,
180
+ free,
181
+ usagePercent,
182
+ });
183
+ }
184
+ }
185
+ });
186
+ if (drives.length > 0) {
187
+ status.drives = drives;
188
+ }
189
+ }
190
+ if (processesResult.status === "fulfilled" && threadsResult.status === "fulfilled") {
191
+ const processCount = parseInt(processesResult.value, 10) - 1; // Subtract header line
192
+ const threadCount = parseInt(threadsResult.value, 10) - 1; // Subtract header line
193
+ status.processes = {
194
+ running: Math.max(0, processCount),
195
+ threads: Math.max(0, threadCount),
196
+ };
197
+ }
198
+ if (servicesRunningResult.status === "fulfilled" && servicesInstalledResult.status === "fulfilled") {
199
+ const runningCount = parseInt(servicesRunningResult.value, 10) - 1; // Subtract header line
200
+ const installedCount = parseInt(servicesInstalledResult.value, 10) - 1; // Subtract header line
201
+ status.services = {
202
+ running: Math.max(0, runningCount),
203
+ installed: Math.max(0, installedCount),
204
+ };
205
+ }
206
+ }
207
+ catch (error) {
208
+ Logger.log(`Failed to collect system status for [${connectionName}]: ${error.message}`, "error");
209
+ status.reachable = false;
210
+ }
211
+ return status;
212
+ }
@@ -0,0 +1,26 @@
1
+ export class ToolError extends Error {
2
+ code;
3
+ retriable;
4
+ constructor(code, message, retriable = false) {
5
+ super(message);
6
+ this.code = code;
7
+ this.retriable = retriable;
8
+ this.name = "ToolError";
9
+ }
10
+ }
11
+ export function toToolError(error, fallbackCode) {
12
+ if (error instanceof ToolError) {
13
+ return error;
14
+ }
15
+ if (error instanceof Error) {
16
+ return new ToolError(fallbackCode, error.message, false);
17
+ }
18
+ return new ToolError(fallbackCode, String(error), false);
19
+ }
20
+ export function formatToolErrorResponse(error) {
21
+ return JSON.stringify({
22
+ code: error.code,
23
+ message: error.message,
24
+ retriable: error.retriable,
25
+ }, null, 2);
26
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@aaarc/handfree-ssh-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
5
+ "main": "build/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "handfree-ssh-mcp": "build/index.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Ironboxplus/handfree-ssh-mcp.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/Ironboxplus/handfree-ssh-mcp/issues"
16
+ },
17
+ "homepage": "https://github.com/Ironboxplus/handfree-ssh-mcp#readme",
18
+ "scripts": {
19
+ "test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
20
+ "test:config": "npm run build && node --test build/tests/config.test.js",
21
+ "test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
22
+ "test:tools": "npm run build && node --test build/tests/tools.test.js",
23
+ "test:integration": "npm run build && node build/tests/integration.test.js",
24
+ "test:all": "npm run test && npm run test:integration",
25
+ "build": "node scripts/build.js",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "keywords": [
29
+ "ssh",
30
+ "mcp",
31
+ "server",
32
+ "cli"
33
+ ],
34
+ "author": "Junki",
35
+ "license": "ISC",
36
+ "dependencies": {
37
+ "@modelcontextprotocol/sdk": "1.17.5",
38
+ "js-yaml": "^4.1.0",
39
+ "socks": "^2.8.6",
40
+ "ssh2": "^1.16.0",
41
+ "zod": "^3.24.2"
42
+ },
43
+ "devDependencies": {
44
+ "@types/js-yaml": "^4.0.9",
45
+ "@types/node": "^22.13.10",
46
+ "@types/ssh2": "^1.15.5",
47
+ "typescript": "^5.8.2"
48
+ },
49
+ "files": [
50
+ "build/**/*"
51
+ ]
52
+ }