Add native toolbox MCP tools

This commit is contained in:
Mikei386
2026-08-29 13:27:28 +02:00
parent 63542bb29e
commit 454bb111fe
13 changed files with 211 additions and 35 deletions
+12 -6
View File
@@ -7,7 +7,7 @@ Kompiliertes Bun-Binary (TypeScript), läuft als SysVinit-Service auf Unraid.
- **Health-Check:** `http://<unraid-ip>: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`:
Binary file not shown.
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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';
/**
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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",
+9 -4
View File
@@ -2,13 +2,13 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "mua">
<!ENTITY author "Michael">
<!ENTITY version "2026.08.29.r026">
<!ENTITY version "2026.08.29.r027">
<!ENTITY launch "Settings/mua">
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
<!ENTITY pluginLOC "/boot/config/plugins/&name;">
<!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;">
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.29.r026-x86_64-1.txz">
<!ENTITY txzSHA256 "c1c045ce32d41cfec4c985a3f9ff08d8f2150d2103a75146bd17323922d562bb">
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.29.r027-x86_64-1.txz">
<!ENTITY txzSHA256 "f7fcbc3b3de260f6df0d413ac072d0f3e674bc25425e6a50b8a11b78a4e5007c">
]>
<PLUGIN name="&name;"
@@ -23,6 +23,11 @@
>
<CHANGES>
### 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)
===========================================
-->
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.29.r026-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.29.r027-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<URL>&txzURL;</URL>
<SHA256>&txzSHA256;</SHA256>
</FILE>
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -169,10 +169,10 @@ $active_count = $all_tools_enabled ? count($all_tools) : count($enabled_tools);
<div class="mua-section"><h2>Agent-Werkzeugcontainer</h2><div class="mua-card <?= !$toolbox_available ? '' : ($toolbox_enabled ? 'mua-risk-safe' : 'mua-risk-notice') ?>">
<?php if ($toolbox_available): ?>
<p class="mua-summary">✅ Gefunden: <?php foreach ($toolbox_containers as $i => $container): ?><?= $i ? ', ' : '' ?><strong><?= htmlspecialchars($container['name'] ?? 'unbekannt') ?></strong> <span class="mua-muted">(<?= htmlspecialchars($container['state'] ?? 'unbekannt') ?>)</span><?php endforeach; ?></p>
<form method="post"><input type="hidden" name="csrf_token" value="<?= htmlspecialchars($unraid_csrf) ?>"><input type="hidden" name="mua_csrf" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="save_toolbox"><label class="mua-switch"><input type="checkbox" name="toolbox_enabled" <?= $toolbox_enabled ? 'checked' : '' ?>><span><strong>Werkzeugcontainer für KI-Agenten bekanntgeben</strong><br><span class="mua-muted">MUA nennt den dynamisch erkannten Container in seinen MCP-Anweisungen und erklärt die Nutzung über <code>docker exec</code>.</span></span></label><button type="submit" class="mua-btn mua-btn-primary">Einstellung speichern</button></form>
<form method="post"><input type="hidden" name="csrf_token" value="<?= htmlspecialchars($unraid_csrf) ?>"><input type="hidden" name="mua_csrf" value="<?= htmlspecialchars($csrf) ?>"><input type="hidden" name="action" value="save_toolbox"><label class="mua-switch"><input type="checkbox" name="toolbox_enabled" <?= $toolbox_enabled ? 'checked' : '' ?>><span><strong>Werkzeugcontainer für KI-Agenten bereitstellen</strong><br><span class="mua-muted">Blendet <code>unraid_toolbox_status</code> und <code>unraid_toolbox_exec</code> für MCP-Clients ein. Der Container wird dynamisch über sein Label erkannt.</span></span></label><button type="submit" class="mua-btn mua-btn-primary">Einstellung speichern</button></form>
<?php else: ?>
<p class="mua-summary mua-muted">Kein Werkzeugcontainer erkannt</p>
<label class="mua-switch"><input type="checkbox" disabled><span><strong>Werkzeugcontainer für KI-Agenten bekanntgeben</strong><br><span class="mua-muted">Nicht verfügbar. Erstelle einen Docker-Container und versehe ihn mit dem Label <code>mike.ai.role=toolbox</code>. Danach diese Seite neu laden.</span></span></label><button type="button" class="mua-btn" disabled>Einstellung speichern</button>
<label class="mua-switch"><input type="checkbox" disabled><span><strong>Werkzeugcontainer für KI-Agenten bereitstellen</strong><br><span class="mua-muted">Nicht verfügbar. Erstelle einen Docker-Container und versehe ihn mit dem Label <code>mike.ai.role=toolbox</code>. Danach diese Seite neu laden.</span></span></label><button type="button" class="mua-btn" disabled>Einstellung speichern</button>
<?php endif; ?>
</div></div>
+99 -1
View File
@@ -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<ToolboxContainer[]> {
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<string> {
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<string, unknown> = {
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<string> {
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.
*/
+17 -15
View File
@@ -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);
+9
View File
@@ -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");
+55
View File
@@ -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",