diff --git a/README.md b/README.md index 370b3be..ce7625c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid. - **Health-Check:** `http://:3002/health` (offen, ohne Auth) - **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token) - **Auth:** API-Key (Bearer-Token) für `/mcp` -- **Tools:** 33 (Docker, Community Applications, Netzwerk und Unraid-System) +- **Tools:** 35 (Docker, Community Applications, Netzwerk, Unraid-System und optionale Toolbox) --- @@ -238,11 +238,17 @@ MUA kann einen allgemeinen Werkzeugcontainer dynamisch über das Docker-Label existiert, wird in der WebGUI der Schalter **„Werkzeugcontainer für KI-Agenten bekanntgeben“** verfügbar. Fehlt das Label, bleibt er ausgegraut. -Bei aktiviertem Schalter nennt MUA den aktuell erkannten Containernamen und -die Nutzung über `docker exec` in seinen MCP-Anweisungen. Der Name ist nicht -fest in MUA eingebaut. Wird der Container ersetzt, genügt dasselbe Label am -Nachfolger. Die eigentliche Ausführung benötigt das separat freigegebene -Werkzeug `unraid_system_shell`. +Bei aktiviertem Schalter stellt MUA zwei eigene, dynamisch sichtbare Werkzeuge +bereit: + +- `unraid_toolbox_status` erkennt den Container und prüft angefragte Programme + wie `ffmpeg`, `ffprobe`, `yt-dlp` oder `mediainfo`. +- `unraid_toolbox_exec` führt Befehle direkt darin aus und kann auf ausdrücklichen + Auftrag fehlende Pakete dort installieren. + +Der Containername ist nicht fest in MUA eingebaut. Wird der Container ersetzt, +genügt dasselbe Label am Nachfolger. Die Toolbox-Werkzeuge erscheinen in keinem +MCP-Client, solange der Schalter aus ist oder kein passender Container existiert. Beispiel für Docker beziehungsweise DockerMan `ExtraParams`: diff --git a/dist/mua-2026.08.29.r027-x86_64-1.txz b/dist/mua-2026.08.29.r027-x86_64-1.txz new file mode 100644 index 0000000..5cadadb Binary files /dev/null and b/dist/mua-2026.08.29.r027-x86_64-1.txz differ diff --git a/docs/HERMES.md b/docs/HERMES.md index 62d1829..d97d218 100644 --- a/docs/HERMES.md +++ b/docs/HERMES.md @@ -119,9 +119,10 @@ mehrzeilige Skripte laufen synchron; nur Arbeiten über 30 Minuten verwenden die asynchronen Job-Werkzeuge. Wenn in der MUA-WebGUI der optionale Agent-Werkzeugcontainer aktiviert ist, -liefert MUA dessen dynamisch per Label erkannten Namen bereits in den -MCP-Anweisungen. Hermes verwendet ihn dann über `unraid_system_shell` und -`docker exec`; im Hermes-Container selbst werden keine Hilfsprogramme +erscheinen `unraid_toolbox_status` und `unraid_toolbox_exec` direkt im nativen +Hermes-Werkzeugkatalog. Hermes prüft benötigte Programme zuerst mit dem +Statuswerkzeug und führt Medien- oder Downloadbefehle anschließend direkt im +Toolbox-Container aus. Im Hermes-Container selbst werden keine Hilfsprogramme nachinstalliert. MUA setzt keinen festen Containernamen voraus. ## 5. Entfernen diff --git a/mcp/helpers.php b/mcp/helpers.php index 4efa8c9..e26f7c1 100644 --- a/mcp/helpers.php +++ b/mcp/helpers.php @@ -10,7 +10,7 @@ error_reporting(E_ALL); ini_set('display_errors', 0); // Fehler werden als JSON-RPC-Error zurückgegeben, nicht als HTML -const MUA_VERSION = '2026.08.29.r026'; +const MUA_VERSION = '2026.08.29.r027'; const MUA_SERVER_NAME = 'mua'; /** diff --git a/mcp/server.php b/mcp/server.php index 287534f..51e88cf 100644 --- a/mcp/server.php +++ b/mcp/server.php @@ -21,7 +21,7 @@ const MUA_PORT = 3002; const MUA_BIND = '0.0.0.0'; const MUA_SESSION_TTL = 3600; // Sekunden const MUA_SERVER_NAME = 'mua'; -const MUA_VERSION = '2026.08.29.r026'; +const MUA_VERSION = '2026.08.29.r027'; // ── Session-Management (in-memory) ─────────────────────────────────────── $sessions = []; // session_id => ['created' => time, 'initialized' => bool] diff --git a/package.json b/package.json index d87df46..ed3f519 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.29.r026", + "version": "2026.08.29.r027", "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", "type": "module", "main": "src/index.ts", diff --git a/plugin/mua.plg b/plugin/mua.plg index fd817e1..d4228b2 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.29.r027 +- Stellt bei aktivierter Toolbox zwei echte MCP-Werkzeuge bereit: `unraid_toolbox_status` und `unraid_toolbox_exec`. +- Agenten erkennen und verwenden Medienwerkzeuge direkt im dynamisch gelabelten Container, ohne lokales Docker oder Umwege über die Host-Shell. +- Toolbox-Werkzeuge verschwinden automatisch aus `tools/list`, wenn der GUI-Schalter aus ist oder kein gelabelter Container existiert. + ### 2026.08.29.r026 - Erkennt optionale Agent-Werkzeugcontainer dynamisch über `mike.ai.role=toolbox` statt über einen fest eingebauten Namen. - Die WebGUI bietet den Toolbox-Schalter nur bei vorhandenem Label an und zeigt andernfalls eine konkrete Einrichtungsanleitung. @@ -162,7 +167,7 @@ Das .txz enthält: install/doinst.sh (läuft nach Installation) =========================================== --> - + &txzURL; &txzSHA256; diff --git a/plugin/plugin.json b/plugin/plugin.json index fb47eca..faa07c0 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mua", "author": "Michael", - "version": "2026.08.29.r026", + "version": "2026.08.29.r027", "minver": "7.0.0", "pluginDirectory": "/usr/local/emhttp/plugins/mua", "configDirectory": "/boot/config/plugins/mua", diff --git a/scripts/mua.page b/scripts/mua.page index 7b9da8b..5f5eb27 100644 --- a/scripts/mua.page +++ b/scripts/mua.page @@ -169,10 +169,10 @@ $active_count = $all_tools_enabled ? count($all_tools) : count($enabled_tools);

Agent-Werkzeugcontainer

✅ Gefunden: $container): ?> ()

-
+

Kein Werkzeugcontainer erkannt

- +
diff --git a/src/helpers.ts b/src/helpers.ts index 5668855..c1b2c1f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; -export const MUA_VERSION = "2026.08.29.r026"; +export const MUA_VERSION = "2026.08.29.r027"; 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"; @@ -348,6 +348,104 @@ export async function discoverToolboxContainers(): Promise { return rows; } +function selectRunningToolbox( + containers: ToolboxContainer[], + requestedName?: string, +): ToolboxContainer { + const running = containers.filter((container) => container.state === "running"); + if (requestedName) { + const selected = running.find((container) => container.name === requestedName); + if (!selected) throw new Error(`Running toolbox container not found: ${requestedName}`); + return selected; + } + if (running.length === 0) throw new Error("No labeled toolbox container is running"); + if (running.length > 1) { + throw new Error( + `Multiple toolbox containers are running; specify container: ${running.map((c) => c.name).join(", ")}`, + ); + } + return running[0]; +} + +/** Return labeled toolbox state and optionally probe specific command names. */ +export async function toolboxStatus(programs: string[] = []): Promise { + if (programs.length > 32) throw new Error("programs accepts at most 32 names"); + for (const program of programs) { + if (!/^[A-Za-z0-9._+-]{1,128}$/.test(program)) { + throw new Error(`Invalid program name: ${program}`); + } + } + const containers = await discoverToolboxContainers(); + const running = containers.filter((container) => container.state === "running"); + const result: Record = { + label: "mike.ai.role=toolbox", + available: containers.length > 0, + running: running.length > 0, + containers, + }; + if (programs.length === 0 || running.length === 0) return JSON.stringify(result); + + if (running.length > 1) { + result["note"] = "Multiple toolboxes are running; availability is reported for the first one."; + } + const selected = running[0]; + const script = + 'for p do path=$(command -v "$p" 2>/dev/null || true); ' + + 'if [ -n "$path" ]; then printf "%s\\t%s\\n" "$p" "$path"; ' + + 'else printf "%s\\t\\n" "$p"; fi; done'; + const raw = await runArgv( + "toolbox_status", + ["/usr/bin/docker", "exec", selected.name, "/bin/sh", "-c", script, "toolbox-status", ...programs], + 30, + 30_000, + 8_000, + ); + const paths = new Map( + raw.split("\n").map((line) => { + const tab = line.indexOf("\t"); + return tab >= 0 ? [line.slice(0, tab), line.slice(tab + 1)] : [line, ""]; + }), + ); + result["selected_container"] = selected.name; + result["programs"] = programs.map((program) => ({ + name: program, + available: Boolean(paths.get(program)), + path: paths.get(program) || null, + })); + return JSON.stringify(result); +} + +/** Execute an explicitly requested shell command inside one labeled toolbox. */ +export async function toolboxExec( + command: string, + timeoutSec = 300, + requestedName?: string, +): Promise { + const selected = selectRunningToolbox(await discoverToolboxContainers(), requestedName); + try { + const proc = spawn( + ["/usr/bin/docker", "exec", selected.name, "/bin/sh", "-lc", command], + { stdout: "pipe", stderr: "pipe", cwd: "/" }, + ); + const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000); + const [stdout, stderr, code] = await Promise.all([ + readStreamLimited(proc.stdout, 100_000), + readStreamLimited(proc.stderr, 20_000), + proc.exited, + ]); + clearTimeout(timeout); + return JSON.stringify({ + container: selected.name, + exit_code: code, + stdout: sanitizeLogOutput(stdout.text.trim(), 100_000), + stderr: sanitizeLogOutput(stderr.text.trim(), 20_000), + truncated: stdout.truncated || stderr.truncated, + }); + } catch (error) { + throw new Error(`toolbox execution failed: ${String(error)}`); + } +} + /** * Delegiert eine Write-Operation an den PHP-Helper. */ diff --git a/src/index.ts b/src/index.ts index 5dd752a..7330ff6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,13 +61,9 @@ async function toolboxGuidance(): Promise<{ if (enabled) { const running = containers.filter((c) => c.state === "running"); if (running.length > 0) { - instruction = isToolEnabled("unraid_system_shell") - ? `Optional agent toolbox available: ${running.map((c) => c.name).join(", ")}. ` + - "When a command-line utility is missing, use unraid_system_shell and run it with " + - "docker exec inside a labeled toolbox container. Install missing utilities there, " + - "not in the MCP client or Hermes container." - : `Agent toolbox detected (${running.map((c) => c.name).join(", ")}), but ` + - "unraid_system_shell is disabled, so the toolbox cannot currently be used through MUA."; + instruction = `Agent toolbox available: ${running.map((c) => c.name).join(", ")}. ` + + "Use unraid_toolbox_status to check programs and unraid_toolbox_exec to run or install " + + "utilities there. Do not install helper programs in Hermes or the MCP client."; } else { instruction = `Agent toolbox configured but currently stopped: ${containers.map((c) => c.name).join(", ")}. ` + @@ -77,6 +73,12 @@ async function toolboxGuidance(): Promise<{ return { available, enabled, containers, instruction }; } +const TOOLBOX_TOOL_NAMES = new Set(["unraid_toolbox_status", "unraid_toolbox_exec"]); + +function isToolAvailable(name: string, toolboxEnabled: boolean): boolean { + return TOOLBOX_TOOL_NAMES.has(name) ? toolboxEnabled : isToolEnabled(name); +} + // ── JSON-RPC Helpers ──────────────────────────────────────────────────── function rpcResult(id: number | string | null, result: unknown) { return { jsonrpc: "2.0", id, result }; @@ -164,15 +166,12 @@ async function handleMcpRequest( if (sessionId) touchSession(sessionId); const toolbox = await toolboxGuidance(); // Tool-Filter: nur aktive Tools anzeigen - const activeTools = TOOLS.filter((t) => isToolEnabled(t.name)); + const activeTools = TOOLS.filter((t) => isToolAvailable(t.name, toolbox.enabled)); return { response: rpcResult(id, { tools: activeTools.map((t) => ({ name: t.name, - description: - t.name === "unraid_system_shell" && toolbox.instruction - ? `${t.description} ${toolbox.instruction}` - : t.description, + description: t.description, inputSchema: t.inputSchema, })), }), @@ -196,7 +195,8 @@ async function handleMcpRequest( }; } // Tool-Filter: deaktivierte Tools ablehnen - if (!isToolEnabled(toolName)) { + const toolbox = await toolboxGuidance(); + if (!isToolAvailable(toolName, toolbox.enabled)) { return { response: rpcResult(id, { content: [{ type: "text", text: `ERROR: Tool disabled: ${toolName}` }], @@ -470,7 +470,7 @@ try { apiKeyMasked: getMaskedApiKey(), enabledTools: getEnabledTools(), allToolsEnabled: getAllToolsEnabled(), - allTools: TOOLS.map((t) => ({ + allTools: TOOLS.filter((t) => !TOOLBOX_TOOL_NAMES.has(t.name)).map((t) => ({ name: t.name, description: t.description, risk: getToolRisk(t.name), @@ -498,7 +498,9 @@ try { let generatedApiKey: string | undefined; if (body["generate"] === true) generatedApiKey = generateApiKey(); if (Array.isArray(body["enabledTools"])) { - const known = new Set(TOOLS.map((t) => t.name)); + const known = new Set( + TOOLS.filter((t) => !TOOLBOX_TOOL_NAMES.has(t.name)).map((t) => t.name), + ); const tools = (body["enabledTools"] as unknown[]) .filter((t): t is string => typeof t === "string" && known.has(t)); setEnabledTools(tools, body["allToolsEnabled"] === true); diff --git a/src/security.test.ts b/src/security.test.ts index 5270143..4b06654 100644 --- a/src/security.test.ts +++ b/src/security.test.ts @@ -74,6 +74,8 @@ describe("secret handling", () => { describe("risk classification", () => { test("classifies root shell and container changes as critical", () => { expect(getToolRisk("unraid_system_shell")).toBe("critical"); + expect(getToolRisk("unraid_toolbox_exec")).toBe("critical"); + expect(getToolRisk("unraid_toolbox_status")).toBe("read"); expect(getToolRisk("unraid_system_job_start")).toBe("critical"); expect(getToolRisk("unraid_system_job_cleanup")).toBe("critical"); expect(getToolRisk("unraid_system_job_status")).toBe("read"); @@ -91,6 +93,13 @@ describe("risk classification", () => { }); }); +describe("agent toolbox tools", () => { + test("publishes explicit status and execution schemas", () => { + expect(toolByName("unraid_toolbox_status")).toBeDefined(); + expect(toolByName("unraid_toolbox_exec")).toBeDefined(); + }); +}); + describe("direct Unraid terminal", () => { test("accepts a normal command with the extended timeout", async () => { const tool = toolByName("unraid_system_shell"); diff --git a/src/tools.ts b/src/tools.ts index ff1b18e..935c2c0 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -28,6 +28,8 @@ import { searchCommunityApps, previewCommunityAppInstall, installCommunityApp, + toolboxStatus, + toolboxExec, } from "./helpers"; export interface ToolDef { @@ -46,6 +48,7 @@ const CRITICAL_TOOLS = new Set([ "unraid_docker_update_verified_batch", "unraid_docker_rebuild", "unraid_system_shell", + "unraid_toolbox_exec", "unraid_system_job_start", "unraid_system_job_cleanup", "unraid_ca_install", @@ -92,6 +95,58 @@ function validateShellCommand(value: unknown): string { } export const TOOLS: ToolDef[] = [ + // These two tools are exposed dynamically only when a labeled toolbox is + // present and the dedicated WebGUI switch is enabled (see index.ts). + { + name: "unraid_toolbox_status", + description: + "Discover the optional labeled agent toolbox on Unraid and check whether command-line programs such as ffmpeg, ffprobe, yt-dlp or mediainfo are installed there. Use this first when a required CLI program may be missing; do not probe the local Hermes or MCP-client container.", + inputSchema: { + type: "object", + properties: { + programs: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._+-]+$" }, + maxItems: 32, + description: "Optional command names to check inside the running toolbox", + }, + }, + additionalProperties: false, + }, + handler: (a) => { + const raw = a["programs"] ?? []; + if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string")) { + throw new Error("programs must be an array of strings"); + } + return toolboxStatus(raw as string[]); + }, + }, + { + name: "unraid_toolbox_exec", + description: + "Run a command directly inside the labeled agent toolbox on Unraid. Use it for media and download utilities and to install a missing package inside that toolbox when the user requests the job. Never install helper programs into Hermes or the MCP client. The toolbox container is selected dynamically by Docker label, not by a hard-coded name.", + inputSchema: { + type: "object", + properties: { + command: shellCommand("Command or multi-line shell script to run inside the toolbox"), + timeout_seconds: int("Timeout in seconds (1-1800, default 300)"), + container: str("Optional toolbox container name; required only if multiple labeled toolboxes are running"), + }, + required: ["command"], + additionalProperties: false, + }, + handler: (a) => { + const command = validateShellCommand(a["command"]); + const timeout = Number(a["timeout_seconds"] ?? 300); + if (!Number.isInteger(timeout) || timeout < 1 || timeout > 1800) { + throw new Error("timeout_seconds must be between 1 and 1800"); + } + const container = a["container"] === undefined + ? undefined + : validateName(a["container"], "container"); + return toolboxExec(command, timeout, container); + }, + }, // ── Docker (14) ─────────────────────────────────────────────────────── { name: "unraid_docker_list",