Add native toolbox MCP tools
This commit is contained in:
@@ -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)
|
- **Health-Check:** `http://<unraid-ip>:3002/health` (offen, ohne Auth)
|
||||||
- **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token)
|
- **Config-Server:** `http://127.0.0.1:3013/config` (localhost + separater Admin-Token)
|
||||||
- **Auth:** API-Key (Bearer-Token) für `/mcp`
|
- **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
|
existiert, wird in der WebGUI der Schalter **„Werkzeugcontainer für
|
||||||
KI-Agenten bekanntgeben“** verfügbar. Fehlt das Label, bleibt er ausgegraut.
|
KI-Agenten bekanntgeben“** verfügbar. Fehlt das Label, bleibt er ausgegraut.
|
||||||
|
|
||||||
Bei aktiviertem Schalter nennt MUA den aktuell erkannten Containernamen und
|
Bei aktiviertem Schalter stellt MUA zwei eigene, dynamisch sichtbare Werkzeuge
|
||||||
die Nutzung über `docker exec` in seinen MCP-Anweisungen. Der Name ist nicht
|
bereit:
|
||||||
fest in MUA eingebaut. Wird der Container ersetzt, genügt dasselbe Label am
|
|
||||||
Nachfolger. Die eigentliche Ausführung benötigt das separat freigegebene
|
- `unraid_toolbox_status` erkennt den Container und prüft angefragte Programme
|
||||||
Werkzeug `unraid_system_shell`.
|
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`:
|
Beispiel für Docker beziehungsweise DockerMan `ExtraParams`:
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
+4
-3
@@ -119,9 +119,10 @@ mehrzeilige Skripte laufen synchron; nur Arbeiten über 30 Minuten verwenden
|
|||||||
die asynchronen Job-Werkzeuge.
|
die asynchronen Job-Werkzeuge.
|
||||||
|
|
||||||
Wenn in der MUA-WebGUI der optionale Agent-Werkzeugcontainer aktiviert ist,
|
Wenn in der MUA-WebGUI der optionale Agent-Werkzeugcontainer aktiviert ist,
|
||||||
liefert MUA dessen dynamisch per Label erkannten Namen bereits in den
|
erscheinen `unraid_toolbox_status` und `unraid_toolbox_exec` direkt im nativen
|
||||||
MCP-Anweisungen. Hermes verwendet ihn dann über `unraid_system_shell` und
|
Hermes-Werkzeugkatalog. Hermes prüft benötigte Programme zuerst mit dem
|
||||||
`docker exec`; im Hermes-Container selbst werden keine Hilfsprogramme
|
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.
|
nachinstalliert. MUA setzt keinen festen Containernamen voraus.
|
||||||
|
|
||||||
## 5. Entfernen
|
## 5. Entfernen
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0); // Fehler werden als JSON-RPC-Error zurückgegeben, nicht als HTML
|
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';
|
const MUA_SERVER_NAME = 'mua';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ const MUA_PORT = 3002;
|
|||||||
const MUA_BIND = '0.0.0.0';
|
const MUA_BIND = '0.0.0.0';
|
||||||
const MUA_SESSION_TTL = 3600; // Sekunden
|
const MUA_SESSION_TTL = 3600; // Sekunden
|
||||||
const MUA_SERVER_NAME = 'mua';
|
const MUA_SERVER_NAME = 'mua';
|
||||||
const MUA_VERSION = '2026.08.29.r026';
|
const MUA_VERSION = '2026.08.29.r027';
|
||||||
|
|
||||||
// ── Session-Management (in-memory) ───────────────────────────────────────
|
// ── Session-Management (in-memory) ───────────────────────────────────────
|
||||||
$sessions = []; // session_id => ['created' => time, 'initialized' => bool]
|
$sessions = []; // session_id => ['created' => time, 'initialized' => bool]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"version": "2026.08.29.r026",
|
"version": "2026.08.29.r027",
|
||||||
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
+9
-4
@@ -2,13 +2,13 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "mua">
|
<!ENTITY name "mua">
|
||||||
<!ENTITY author "Michael">
|
<!ENTITY author "Michael">
|
||||||
<!ENTITY version "2026.08.29.r026">
|
<!ENTITY version "2026.08.29.r027">
|
||||||
<!ENTITY launch "Settings/mua">
|
<!ENTITY launch "Settings/mua">
|
||||||
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
|
<!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 pluginLOC "/boot/config/plugins/&name;">
|
||||||
<!ENTITY emhttpLOC "/usr/local/emhttp/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 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 "c1c045ce32d41cfec4c985a3f9ff08d8f2150d2103a75146bd17323922d562bb">
|
<!ENTITY txzSHA256 "f7fcbc3b3de260f6df0d413ac072d0f3e674bc25425e6a50b8a11b78a4e5007c">
|
||||||
]>
|
]>
|
||||||
|
|
||||||
<PLUGIN name="&name;"
|
<PLUGIN name="&name;"
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<CHANGES>
|
<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
|
### 2026.08.29.r026
|
||||||
- Erkennt optionale Agent-Werkzeugcontainer dynamisch über `mike.ai.role=toolbox` statt über einen fest eingebauten Namen.
|
- 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.
|
- 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)
|
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>
|
<URL>&txzURL;</URL>
|
||||||
<SHA256>&txzSHA256;</SHA256>
|
<SHA256>&txzSHA256;</SHA256>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"author": "Michael",
|
"author": "Michael",
|
||||||
"version": "2026.08.29.r026",
|
"version": "2026.08.29.r027",
|
||||||
"minver": "7.0.0",
|
"minver": "7.0.0",
|
||||||
"pluginDirectory": "/usr/local/emhttp/plugins/mua",
|
"pluginDirectory": "/usr/local/emhttp/plugins/mua",
|
||||||
"configDirectory": "/boot/config/plugins/mua",
|
"configDirectory": "/boot/config/plugins/mua",
|
||||||
|
|||||||
+2
-2
@@ -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') ?>">
|
<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): ?>
|
<?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>
|
<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: ?>
|
<?php else: ?>
|
||||||
<p class="mua-summary mua-muted">Kein Werkzeugcontainer erkannt</p>
|
<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; ?>
|
<?php endif; ?>
|
||||||
</div></div>
|
</div></div>
|
||||||
|
|
||||||
|
|||||||
+99
-1
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
|
|||||||
|
|
||||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||||
export const MUA_SERVER_NAME = "mua";
|
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 MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||||
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-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;
|
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.
|
* Delegiert eine Write-Operation an den PHP-Helper.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+17
-15
@@ -61,13 +61,9 @@ async function toolboxGuidance(): Promise<{
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
const running = containers.filter((c) => c.state === "running");
|
const running = containers.filter((c) => c.state === "running");
|
||||||
if (running.length > 0) {
|
if (running.length > 0) {
|
||||||
instruction = isToolEnabled("unraid_system_shell")
|
instruction = `Agent toolbox available: ${running.map((c) => c.name).join(", ")}. ` +
|
||||||
? `Optional agent toolbox available: ${running.map((c) => c.name).join(", ")}. ` +
|
"Use unraid_toolbox_status to check programs and unraid_toolbox_exec to run or install " +
|
||||||
"When a command-line utility is missing, use unraid_system_shell and run it with " +
|
"utilities there. Do not install helper programs in Hermes or the MCP client.";
|
||||||
"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.";
|
|
||||||
} else {
|
} else {
|
||||||
instruction =
|
instruction =
|
||||||
`Agent toolbox configured but currently stopped: ${containers.map((c) => c.name).join(", ")}. ` +
|
`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 };
|
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 ────────────────────────────────────────────────────
|
// ── JSON-RPC Helpers ────────────────────────────────────────────────────
|
||||||
function rpcResult(id: number | string | null, result: unknown) {
|
function rpcResult(id: number | string | null, result: unknown) {
|
||||||
return { jsonrpc: "2.0", id, result };
|
return { jsonrpc: "2.0", id, result };
|
||||||
@@ -164,15 +166,12 @@ async function handleMcpRequest(
|
|||||||
if (sessionId) touchSession(sessionId);
|
if (sessionId) touchSession(sessionId);
|
||||||
const toolbox = await toolboxGuidance();
|
const toolbox = await toolboxGuidance();
|
||||||
// Tool-Filter: nur aktive Tools anzeigen
|
// 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 {
|
return {
|
||||||
response: rpcResult(id, {
|
response: rpcResult(id, {
|
||||||
tools: activeTools.map((t) => ({
|
tools: activeTools.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description:
|
description: t.description,
|
||||||
t.name === "unraid_system_shell" && toolbox.instruction
|
|
||||||
? `${t.description} ${toolbox.instruction}`
|
|
||||||
: t.description,
|
|
||||||
inputSchema: t.inputSchema,
|
inputSchema: t.inputSchema,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
@@ -196,7 +195,8 @@ async function handleMcpRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Tool-Filter: deaktivierte Tools ablehnen
|
// Tool-Filter: deaktivierte Tools ablehnen
|
||||||
if (!isToolEnabled(toolName)) {
|
const toolbox = await toolboxGuidance();
|
||||||
|
if (!isToolAvailable(toolName, toolbox.enabled)) {
|
||||||
return {
|
return {
|
||||||
response: rpcResult(id, {
|
response: rpcResult(id, {
|
||||||
content: [{ type: "text", text: `ERROR: Tool disabled: ${toolName}` }],
|
content: [{ type: "text", text: `ERROR: Tool disabled: ${toolName}` }],
|
||||||
@@ -470,7 +470,7 @@ try {
|
|||||||
apiKeyMasked: getMaskedApiKey(),
|
apiKeyMasked: getMaskedApiKey(),
|
||||||
enabledTools: getEnabledTools(),
|
enabledTools: getEnabledTools(),
|
||||||
allToolsEnabled: getAllToolsEnabled(),
|
allToolsEnabled: getAllToolsEnabled(),
|
||||||
allTools: TOOLS.map((t) => ({
|
allTools: TOOLS.filter((t) => !TOOLBOX_TOOL_NAMES.has(t.name)).map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
risk: getToolRisk(t.name),
|
risk: getToolRisk(t.name),
|
||||||
@@ -498,7 +498,9 @@ try {
|
|||||||
let generatedApiKey: string | undefined;
|
let generatedApiKey: string | undefined;
|
||||||
if (body["generate"] === true) generatedApiKey = generateApiKey();
|
if (body["generate"] === true) generatedApiKey = generateApiKey();
|
||||||
if (Array.isArray(body["enabledTools"])) {
|
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[])
|
const tools = (body["enabledTools"] as unknown[])
|
||||||
.filter((t): t is string => typeof t === "string" && known.has(t));
|
.filter((t): t is string => typeof t === "string" && known.has(t));
|
||||||
setEnabledTools(tools, body["allToolsEnabled"] === true);
|
setEnabledTools(tools, body["allToolsEnabled"] === true);
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ describe("secret handling", () => {
|
|||||||
describe("risk classification", () => {
|
describe("risk classification", () => {
|
||||||
test("classifies root shell and container changes as critical", () => {
|
test("classifies root shell and container changes as critical", () => {
|
||||||
expect(getToolRisk("unraid_system_shell")).toBe("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_start")).toBe("critical");
|
||||||
expect(getToolRisk("unraid_system_job_cleanup")).toBe("critical");
|
expect(getToolRisk("unraid_system_job_cleanup")).toBe("critical");
|
||||||
expect(getToolRisk("unraid_system_job_status")).toBe("read");
|
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", () => {
|
describe("direct Unraid terminal", () => {
|
||||||
test("accepts a normal command with the extended timeout", async () => {
|
test("accepts a normal command with the extended timeout", async () => {
|
||||||
const tool = toolByName("unraid_system_shell");
|
const tool = toolByName("unraid_system_shell");
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
searchCommunityApps,
|
searchCommunityApps,
|
||||||
previewCommunityAppInstall,
|
previewCommunityAppInstall,
|
||||||
installCommunityApp,
|
installCommunityApp,
|
||||||
|
toolboxStatus,
|
||||||
|
toolboxExec,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
export interface ToolDef {
|
export interface ToolDef {
|
||||||
@@ -46,6 +48,7 @@ const CRITICAL_TOOLS = new Set([
|
|||||||
"unraid_docker_update_verified_batch",
|
"unraid_docker_update_verified_batch",
|
||||||
"unraid_docker_rebuild",
|
"unraid_docker_rebuild",
|
||||||
"unraid_system_shell",
|
"unraid_system_shell",
|
||||||
|
"unraid_toolbox_exec",
|
||||||
"unraid_system_job_start",
|
"unraid_system_job_start",
|
||||||
"unraid_system_job_cleanup",
|
"unraid_system_job_cleanup",
|
||||||
"unraid_ca_install",
|
"unraid_ca_install",
|
||||||
@@ -92,6 +95,58 @@ function validateShellCommand(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TOOLS: ToolDef[] = [
|
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) ───────────────────────────────────────────────────────
|
// ── Docker (14) ───────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
name: "unraid_docker_list",
|
name: "unraid_docker_list",
|
||||||
|
|||||||
Reference in New Issue
Block a user