release r010 add Unraid health and Community Apps tools

This commit is contained in:
Mikei386
2026-08-21 10:14:16 +02:00
parent e586b22b13
commit 771b8c43a1
14 changed files with 831 additions and 33 deletions
+7
View File
@@ -44,12 +44,19 @@ export const SAFE_DEFAULT_TOOLS = [
"unraid_docker_processes",
"unraid_docker_stats",
"unraid_docker_info",
"unraid_docker_update_status",
"unraid_network_inventory",
"unraid_network_list",
"unraid_network_inspect",
"unraid_network_host_state",
"unraid_system_connection_test",
"unraid_system_shell_readonly",
"unraid_system_health",
"unraid_storage_status",
"unraid_disk_health",
"unraid_notifications_list",
"unraid_shares_list",
"unraid_share_inspect",
];
// ── Config laden ────────────────────────────────────────────────────────
+275 -3
View File
@@ -10,12 +10,14 @@
import { spawn } from "bun";
import { createConnection, type Socket } from "net";
import { createHash, randomUUID } from "node:crypto";
// ── Konstanten ──────────────────────────────────────────────────────────
export const MUA_SERVER_NAME = "mua";
export const MUA_VERSION = "2026.08.21.r009";
export const MUA_VERSION = "2026.08.21.r010";
export const MUA_PROTOCOL_VERSION = "2025-03-26";
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
// ── Command Execution ───────────────────────────────────────────────────
export interface CmdResult {
@@ -81,6 +83,31 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise<string> {
}
}
async function runArgv(
label: string,
argv: string[],
timeoutSec = 60,
maxStdout = 100_000,
maxStderr = 20_000,
): Promise<string> {
try {
const proc = spawn(argv, { stdout: "pipe", stderr: "pipe", cwd: "/" });
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
const [stdout, stderr, code] = await Promise.all([
readStreamLimited(proc.stdout, maxStdout),
readStreamLimited(proc.stderr, maxStderr),
proc.exited,
]);
clearTimeout(timeout);
if (code !== 0) {
throw new Error(`${label} failed (exit ${code}): ${sanitizeLogOutput(stderr.text || stdout.text, maxStderr)}`);
}
return sanitizeLogOutput(stdout.text.trim(), maxStdout);
} catch (error) {
throw new Error(`${label} failed: ${String(error)}`);
}
}
const READ_ONLY_PROGRAMS = new Set([
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
@@ -212,8 +239,14 @@ export async function runPhpHelper(
if (!Bun.file(helper).exists()) {
throw new Error(`PHP helper not found: ${helper}`);
}
const escaped = [action, ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
return runLocal(`php_helper:${action}`, `/usr/bin/php ${helper} ${escaped}`, 300);
return runArgv(`php_helper:${action}`, ["/usr/bin/php", helper, action, ...args], 300);
}
export async function runStatusHelper(action: string, ...args: string[]): Promise<string> {
if (!Bun.file(STATUS_HELPER).exists()) {
throw new Error(`Status helper not found: ${STATUS_HELPER}`);
}
return runArgv(`status_helper:${action}`, ["/usr/bin/php", STATUS_HELPER, action, ...args], 60);
}
// ── Validierung ─────────────────────────────────────────────────────────
@@ -668,3 +701,242 @@ export async function connectionTest(): Promise<string> {
15,
);
}
// ── Community Applications ─────────────────────────────────────────────
const CA_FEED_URL = "https://ca.unraid.net/assets/feed/applicationFeed.json";
const CA_CACHE_MS = 15 * 60 * 1000;
interface CaConfig {
"@attributes"?: Record<string, unknown>;
value?: unknown;
}
interface CaApp {
Name?: unknown;
Repository?: unknown;
Network?: unknown;
Privileged?: unknown;
Overview?: unknown;
CategoryList?: unknown;
TemplateURL?: unknown;
Repo?: unknown;
Project?: unknown;
Support?: unknown;
Config?: unknown;
downloads?: unknown;
stars?: unknown;
}
let caCache: { loadedAt: number; updated?: unknown; apps: CaApp[] } | null = null;
const caApprovalTickets = new Map<string, { fingerprint: string; expiresAt: number }>();
function caText(value: unknown, max = 500): string {
if (typeof value !== "string") return "";
return value
.replace(/\[br\]/gi, " ")
.replace(/\[(?:\/?(?:b|i|u|span|li|font|center|url)[^\]]*)\]/gi, " ")
.replace(/&#xD;|&amp;/gi, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, max);
}
function caAppId(app: CaApp): string {
return createHash("sha256").update(String(app.TemplateURL ?? "")).digest("hex").slice(0, 16);
}
async function caFeed(): Promise<{ updated?: unknown; apps: CaApp[] }> {
if (caCache && Date.now() - caCache.loadedAt < CA_CACHE_MS) return caCache;
const response = await fetch(CA_FEED_URL, { signal: AbortSignal.timeout(30_000) });
if (!response.ok) throw new Error(`Community Applications feed returned HTTP ${response.status}`);
const contentLength = Number(response.headers.get("content-length") ?? 0);
if (contentLength > 40_000_000) throw new Error("Community Applications feed is unexpectedly large");
const raw = await response.text();
if (raw.length > 40_000_000) throw new Error("Community Applications feed is unexpectedly large");
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!Array.isArray(parsed["applist"])) throw new Error("Community Applications feed has an invalid schema");
const apps = (parsed["applist"] as CaApp[]).filter((app) =>
typeof app?.Name === "string" &&
typeof app?.Repository === "string" &&
typeof app?.TemplateURL === "string" &&
app.Repository !== "" &&
app.TemplateURL !== ""
);
caCache = { loadedAt: Date.now(), updated: parsed["last_updated"], apps };
return caCache;
}
function caConfigSummary(app: CaApp): Record<string, unknown>[] {
if (!Array.isArray(app.Config)) return [];
return (app.Config as CaConfig[]).slice(0, 64).map((config) => {
const attrs = config?.["@attributes"] ?? {};
const target = caText(attrs["Target"], 200);
const masked = String(attrs["Mask"] ?? "false").toLowerCase() === "true" ||
/(?:api[_-]?key|token|secret|password|passwd)/i.test(target);
return {
type: caText(attrs["Type"], 40),
name: caText(attrs["Name"], 120),
target,
default: masked ? "[MASKED]" : caText(config.value ?? attrs["Default"], 500),
required: String(attrs["Required"] ?? "false").toLowerCase() === "true",
masked,
description: caText(attrs["Description"], 300),
};
});
}
export async function searchCommunityApps(query: string, limit = 10): Promise<string> {
const needle = query.trim().toLowerCase();
if (needle.length < 2 || needle.length > 100) throw new Error("query must contain 2-100 characters");
limit = Math.max(1, Math.min(25, Math.floor(limit)));
const feed = await caFeed();
const scored = feed.apps.map((app) => {
const name = caText(app.Name, 200);
const repo = caText(app.Repository, 300);
const extra = `${caText(app.Overview, 1000)} ${caText(app.Repo, 200)}`.toLowerCase();
const lower = name.toLowerCase();
let score = 0;
if (lower === needle) score += 100;
else if (lower.startsWith(needle)) score += 60;
else if (lower.includes(needle)) score += 40;
if (repo.toLowerCase().includes(needle)) score += 20;
if (extra.includes(needle)) score += 5;
return { app, score, name, repo };
}).filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
.slice(0, limit);
return JSON.stringify({
schema_version: "1.0",
source: "Unraid Community Applications official feed",
feed_updated: feed.updated ?? null,
query,
result_count: scored.length,
results: scored.map(({ app, name, repo }) => ({
app_id: caAppId(app),
name,
image: repo,
network: caText(app.Network, 80),
privileged: String(app.Privileged ?? "false").toLowerCase() === "true",
categories: Array.isArray(app.CategoryList) ? app.CategoryList.slice(0, 12) : [],
overview: caText(app.Overview, 500),
template_repository: caText(app.Repo, 160),
project: caText(app.Project, 500),
support: caText(app.Support, 500),
downloads: Number(app.downloads ?? 0),
stars: Number(app.stars ?? 0),
})),
});
}
async function caFindById(appId: string): Promise<CaApp> {
if (!/^[a-f0-9]{16}$/.test(appId)) throw new Error("Invalid app_id");
const feed = await caFeed();
const app = feed.apps.find((candidate) => caAppId(candidate) === appId);
if (!app) throw new Error("Community Applications entry no longer exists");
const url = new URL(String(app.TemplateURL));
if (url.protocol !== "https:") throw new Error("Only HTTPS Community Applications templates are accepted");
if (url.hostname === "localhost" || url.hostname.endsWith(".local")) throw new Error("Private template hosts are rejected");
return app;
}
function normalizeCaOverrides(value: unknown): Record<string, string> {
if (value === undefined || value === null) return {};
if (typeof value !== "object" || Array.isArray(value)) throw new Error("overrides must be an object");
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length > 32) throw new Error("At most 32 overrides are allowed");
const normalized: Record<string, string> = {};
for (const [key, item] of entries.sort(([a], [b]) => a.localeCompare(b))) {
if (!/^[A-Za-z0-9_./:-]{1,200}$/.test(key)) throw new Error(`Invalid override target: ${key}`);
if (typeof item !== "string" || item.length > 4096 || /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(item)) {
throw new Error(`Invalid override value for ${key}`);
}
normalized[key] = item;
}
return normalized;
}
function caFingerprint(appId: string, containerName: string, overrides: Record<string, string>, start: boolean): string {
return createHash("sha256").update(JSON.stringify({ appId, containerName, overrides, start })).digest("hex");
}
export async function previewCommunityAppInstall(
appId: string,
containerName: string,
rawOverrides: unknown,
start: boolean,
): Promise<string> {
containerName = validateName(containerName, "container_name");
const overrides = normalizeCaOverrides(rawOverrides);
const app = await caFindById(appId);
const configuration = caConfigSummary(app);
const allowedTargets = new Set(configuration.map((item) => String(item["target"] ?? "")));
for (const target of Object.keys(overrides)) {
if (!allowedTargets.has(target)) throw new Error(`Override target is not present in the CA template: ${target}`);
}
const names = (await dockerExec("ps -a --format '{{.Names}}'", 30)).split("\n").map((x) => x.trim());
if (names.includes(containerName)) throw new Error(`Container already exists: ${containerName}`);
if (Bun.file(`/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`).exists()) {
throw new Error(`Unraid user template already exists: ${containerName}`);
}
const ticket = randomUUID();
const expiresAt = Date.now() + 10 * 60 * 1000;
caApprovalTickets.set(ticket, { fingerprint: caFingerprint(appId, containerName, overrides, start), expiresAt });
for (const [key, value] of caApprovalTickets) {
if (value.expiresAt < Date.now()) caApprovalTickets.delete(key);
}
return JSON.stringify({
schema_version: "1.0",
action: "preview-only",
app: { app_id: appId, name: caText(app.Name, 200), image: caText(app.Repository, 300) },
container_name: containerName,
network: caText(app.Network, 80),
privileged: String(app.Privileged ?? "false").toLowerCase() === "true",
configuration,
requested_overrides: Object.entries(overrides).map(([target, value]) => ({
target,
value: /(?:api[_-]?key|token|secret|password|passwd)/i.test(target) ? "[REDACTED]" : value,
})),
start_after_install: start,
writes_user_template: `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`,
approval_ticket: ticket,
approval_expires_in_seconds: 600,
next_step: "Review this preview. Only after explicit user approval call unraid_ca_install with exactly the same app_id, container_name, overrides and start_after_install plus confirm=true and this approval_ticket.",
});
}
export async function installCommunityApp(
appId: string,
containerName: string,
rawOverrides: unknown,
start: boolean,
confirm: boolean,
ticket: string,
): Promise<string> {
containerName = validateName(containerName, "container_name");
const overrides = normalizeCaOverrides(rawOverrides);
const approval = caApprovalTickets.get(ticket);
const fingerprint = caFingerprint(appId, containerName, overrides, start);
if (!confirm || !approval || approval.expiresAt < Date.now() || approval.fingerprint !== fingerprint) {
throw new Error("Approval ticket missing, expired, or does not match this exact change. Run unraid_ca_install_preview, show its preview to the user, then repeat unchanged with confirm=true and the returned approval_ticket.");
}
caApprovalTickets.delete(ticket);
const app = await caFindById(appId);
const result = await runPhpHelper(
"ca-install",
String(app.TemplateURL),
containerName,
JSON.stringify(overrides),
start ? "true" : "false",
);
return JSON.stringify({
schema_version: "1.0",
ok: true,
app: caText(app.Name, 200),
image: caText(app.Repository, 300),
container_name: containerName,
started: start,
gui_managed: true,
template: `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`,
helper_result: result.slice(0, 3000),
});
}
+8 -13
View File
@@ -179,20 +179,15 @@ async function handleMcpRequest(
const result: Record<string, unknown> = {
content: [{ type: "text", text }],
};
// structuredContent für Tools mit JSON-Output
if (
[
"unraid_docker_list",
"unraid_network_inventory",
"unraid_docker_analyze_logs",
"unraid_network_audit_tcp",
].includes(toolName)
) {
try {
result.structuredContent = JSON.parse(text);
} catch {
// ignore
// JSON-Ausgaben zusätzlich strukturiert bereitstellen. Reiner Text
// bleibt unverändert im normalen MCP-Content-Feld.
try {
const structured = JSON.parse(text);
if (structured !== null && typeof structured === "object") {
result.structuredContent = structured;
}
} catch {
// Kein JSON-Tool: Textausgabe genügt.
}
return { response: rpcResult(id, result), sessionId: sessionId ?? "" };
} catch (e) {
+13 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth";
import { runReadOnlyCommand, sanitizeLogOutput } from "./helpers";
import { installCommunityApp, runReadOnlyCommand, sanitizeLogOutput } from "./helpers";
import { getToolRisk } from "./tools";
describe("secure tool configuration", () => {
@@ -44,6 +44,10 @@ describe("risk classification", () => {
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
expect(getToolRisk("unraid_docker_list")).toBe("read");
expect(getToolRisk("unraid_system_shell_readonly")).toBe("read");
expect(getToolRisk("unraid_system_health")).toBe("read");
expect(getToolRisk("unraid_ca_search")).toBe("active");
expect(getToolRisk("unraid_ca_install_preview")).toBe("active");
expect(getToolRisk("unraid_ca_install")).toBe("critical");
});
});
@@ -61,3 +65,11 @@ describe("read-only shell", () => {
await expect(runReadOnlyCommand("ss", ["-K", "dst", "127.0.0.1"], 5)).rejects.toThrow();
});
});
describe("Community Applications approval", () => {
test("rejects an install without a matching preview ticket before any write", async () => {
await expect(
installCommunityApp("aaaaaaaaaaaaaaaa", "mua-test", {}, false, true, "missing"),
).rejects.toThrow("Approval ticket missing");
});
});
+146 -1
View File
@@ -1,6 +1,6 @@
/**
* MUA — Mikes Unraid Agent
* tools.ts — MCP Tool-Definitionen (23 Tools)
* tools.ts — MCP Tool-Definitionen
*
* Portiert von mcp/tools.php. Schema: unraid_<kategorie>_<aktion>
* Kategorien: docker (14), network (6), system (2).
@@ -21,6 +21,10 @@ import {
validateName,
runShell,
runReadOnlyCommand,
runStatusHelper,
searchCommunityApps,
previewCommunityAppInstall,
installCommunityApp,
} from "./helpers";
export interface ToolDef {
@@ -38,6 +42,7 @@ const CRITICAL_TOOLS = new Set([
"unraid_docker_update",
"unraid_docker_rebuild",
"unraid_system_shell",
"unraid_ca_install",
]);
const WRITE_TOOLS = new Set([
"unraid_docker_start",
@@ -47,6 +52,8 @@ const WRITE_TOOLS = new Set([
const ACTIVE_TOOLS = new Set([
"unraid_network_audit_tcp",
"unraid_network_lan_probe",
"unraid_ca_search",
"unraid_ca_install_preview",
]);
export function getToolRisk(name: string): ToolRisk {
@@ -158,6 +165,13 @@ export const TOOLS: ToolDef[] = [
inputSchema: empty,
handler: () => dockerExec(`info --format '{{json .}}'`),
},
{
name: "unraid_docker_update_status",
description:
"Read Unraid's cached Docker image update state and return a compact per-container summary. Does not pull or update images.",
inputSchema: empty,
handler: () => runStatusHelper("docker-update-status"),
},
{
name: "unraid_docker_start",
description: "Start a Docker container.",
@@ -248,6 +262,75 @@ export const TOOLS: ToolDef[] = [
handler: (a) => runPhpHelper("rebuild", validateName(a["container"], "container")),
},
// ── Community Applications ──────────────────────────────────────────
{
name: "unraid_ca_search",
description:
"Search the official Unraid Community Applications feed. Returns compact app metadata and a stable app_id; does not install anything.",
inputSchema: {
type: "object",
properties: {
query: str("Application name, image, repository or purpose"),
limit: int("Maximum results (1-25, default 10)"),
},
required: ["query"],
additionalProperties: false,
},
handler: (a) => searchCommunityApps(String(a["query"] ?? ""), Number(a["limit"] ?? 10)),
},
{
name: "unraid_ca_install_preview",
description:
"Prepare and preview installation of an exact Community Applications entry as an Unraid GUI-managed container. Returns a short-lived approval ticket and performs no writes.",
inputSchema: {
type: "object",
properties: {
app_id: str("Exact app_id returned by unraid_ca_search"),
container_name: str("New unique Unraid container name"),
overrides: {
type: "object",
additionalProperties: { type: "string" },
description: "Optional Config Target to value overrides, e.g. {\"8080\":\"18080\"}",
},
start_after_install: bool("Start the container after installation (default false)"),
},
required: ["app_id", "container_name"],
additionalProperties: false,
},
handler: (a) => previewCommunityAppInstall(
String(a["app_id"] ?? ""),
String(a["container_name"] ?? ""),
a["overrides"] ?? {},
Boolean(a["start_after_install"] ?? false),
),
},
{
name: "unraid_ca_install",
description:
"CRITICAL: Install a previously previewed Community Applications entry. Writes an Unraid user template, pulls the image and creates the container so it remains manageable through the Unraid GUI. Requires an exact, unexpired approval ticket and confirm=true.",
inputSchema: {
type: "object",
properties: {
app_id: str("Exact app_id returned by unraid_ca_search"),
container_name: str("New unique Unraid container name"),
overrides: { type: "object", additionalProperties: { type: "string" } },
start_after_install: bool("Start the container after installation"),
confirm: bool("Must be true after explicit user approval"),
approval_ticket: str("Ticket returned by unraid_ca_install_preview"),
},
required: ["app_id", "container_name", "confirm", "approval_ticket"],
additionalProperties: false,
},
handler: (a) => installCommunityApp(
String(a["app_id"] ?? ""),
String(a["container_name"] ?? ""),
a["overrides"] ?? {},
Boolean(a["start_after_install"] ?? false),
Boolean(a["confirm"] ?? false),
String(a["approval_ticket"] ?? ""),
),
},
// ── Netzwerk (6) ──────────────────────────────────────────────────────
{
name: "unraid_network_inventory",
@@ -323,6 +406,68 @@ export const TOOLS: ToolDef[] = [
},
// ── System (3) ────────────────────────────────────────────────────────
{
name: "unraid_system_health",
description:
"Get compact host health: uptime, load averages, logical CPUs, memory use and available temperature sensors.",
inputSchema: empty,
handler: () => runStatusHelper("system-health"),
},
{
name: "unraid_storage_status",
description:
"Get compact Unraid array, parity, pool and disk status including disabled, missing or invalid disk alerts. Serial numbers are omitted.",
inputSchema: empty,
handler: () => runStatusHelper("storage-status"),
},
{
name: "unraid_disk_health",
description:
"Get compact cached SMART health and important error/wear attributes for one disk or all disks. Does not start a SMART test or spin up disks explicitly.",
inputSchema: {
type: "object",
properties: { disk: str("Optional Unraid disk or pool member name, e.g. disk1") },
additionalProperties: false,
},
handler: (a) => runStatusHelper("disk-health", typeof a["disk"] === "string" ? a["disk"] : ""),
},
{
name: "unraid_notifications_list",
description:
"List recent Unraid notifications with compact, secret-redacted subjects and descriptions.",
inputSchema: {
type: "object",
properties: {
limit: int("Number of notifications (1-50, default 20)"),
importance: { type: "string", enum: ["all", "normal", "warning", "alert"] },
},
additionalProperties: false,
},
handler: (a) => runStatusHelper(
"notifications",
String(Math.max(1, Math.min(50, Number(a["limit"] ?? 20)))),
String(a["importance"] ?? "all"),
),
},
{
name: "unraid_shares_list",
description:
"List Unraid shares with pool placement and compact capacity information. Does not list files or file contents.",
inputSchema: empty,
handler: () => runStatusHelper("shares-list"),
},
{
name: "unraid_share_inspect",
description:
"Inspect one Unraid share's allocation, pool/cache placement and capacity. Does not list or read files.",
inputSchema: {
type: "object",
properties: { share: str("Exact Unraid share name") },
required: ["share"],
additionalProperties: false,
},
handler: (a) => runStatusHelper("share-inspect", String(a["share"] ?? "")),
},
{
name: "unraid_system_connection_test",
description: