@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.
- package/LICENSE +16 -0
- package/README.md +329 -0
- package/build/config/config-loader.js +329 -0
- package/build/config/index.js +5 -0
- package/build/config/server.js +25 -0
- package/build/config/ssh-config-loader.js +376 -0
- package/build/core/mcp-server.js +176 -0
- package/build/index.js +11 -0
- package/build/models/types.js +1 -0
- package/build/services/ssh-connection-manager.js +1726 -0
- package/build/tests/command-validation.test.js +876 -0
- package/build/tests/config.test.js +617 -0
- package/build/tests/integration.test.js +348 -0
- package/build/tests/output-collector.test.js +70 -0
- package/build/tests/output-log-writer.test.js +205 -0
- package/build/tests/recent-fixes.test.js +229 -0
- package/build/tests/tool-error.test.js +26 -0
- package/build/tests/tools.test.js +486 -0
- package/build/tools/download.js +31 -0
- package/build/tools/execute-command.js +80 -0
- package/build/tools/help.js +147 -0
- package/build/tools/index.js +20 -0
- package/build/tools/list-servers.js +36 -0
- package/build/tools/show-whitelist.js +216 -0
- package/build/tools/transfer.js +96 -0
- package/build/tools/upload.js +36 -0
- package/build/utils/logger.js +27 -0
- package/build/utils/output-collector.js +76 -0
- package/build/utils/output-log-writer.js +232 -0
- package/build/utils/status-collector.js +212 -0
- package/build/utils/tool-error.js +26 -0
- package/package.json +52 -0
|
@@ -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
|
+
}
|