diff --git a/README.md b/README.md index 231f8b0..4fb3f57 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,10 @@ Oder manuell: ```bash # .txz von Gitea laden -curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r019-x86_64-1.txz +curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r020-x86_64-1.txz # Installieren -upgradepkg --install-new mua-2026.08.24.r019-x86_64-1.txz +upgradepkg --install-new mua-2026.08.24.r020-x86_64-1.txz ``` ### 2. Service starten @@ -94,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit). ```bash curl http://192.168.1.2:3002/health -# → {"status":"ok","version":"2026.08.24.r019","auth":"required"} +# → {"status":"ok","version":"2026.08.24.r020","auth":"required"} ``` --- @@ -215,7 +215,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden: | **Docker (16)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_update_status`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_update_verified_batch`, `unraid_docker_rebuild` | | **Community Applications (3)** | `unraid_ca_search`, `unraid_ca_install_preview`, `unraid_ca_install` | | **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` | -| **System (9)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` | +| **System (10)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` | Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in `tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`). diff --git a/dist/mua-2026.08.24.r020-x86_64-1.txz b/dist/mua-2026.08.24.r020-x86_64-1.txz new file mode 100644 index 0000000..c23b34f Binary files /dev/null and b/dist/mua-2026.08.24.r020-x86_64-1.txz differ diff --git a/package.json b/package.json index 2056664..dd47e85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mua", - "version": "2026.08.24.r019", + "version": "2026.08.24.r020", "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 5a14bbf..163f80c 100644 --- a/plugin/mua.plg +++ b/plugin/mua.plg @@ -2,13 +2,13 @@ - + - - + + ]> +### 2026.08.24.r020 +- Neues begrenztes Nur-Lese-Werkzeug `unraid_files_inventory` erfasst Datei- und Ordnernamen eines Shares in einem Aufruf, ohne Dateiinhalte zu lesen. +- Ein Namensfilter findet gezielt Sammlungsordner und liefert deren Nachfahren; Medien- und Folgenprüfungen benötigen keine Kette aus wiederholten `ls`/`find`-Aufrufen mehr. +- Bestehende Freigaben der Nur-Lese-Shell erhalten das engere Inventarwerkzeug automatisch, ohne Schreibrechte hinzuzufügen. + ### 2026.08.24.r019 - Ein gebündelter Docker-Updateablauf prüft echte Image-IDs, überspringt bereits aktuelle Images trotz veraltetem Unraid-Cache und erhält den ursprünglichen Laufzustand. - Bis zu 25 ausdrücklich benannte Container werden in einem Werkzeugaufruf aktualisiert und anschließend kompakt auf Container-ID, Zustand, RestartCount und Health geprüft. @@ -131,7 +136,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 c426f57..277eb19 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mua", "author": "Michael", - "version": "2026.08.24.r019", + "version": "2026.08.24.r020", "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 80de40d..2ebe968 100644 --- a/scripts/mua.page +++ b/scripts/mua.page @@ -74,6 +74,7 @@ $readonly = [ 'unraid_system_shell_readonly', 'unraid_docker_update_status', 'unraid_system_health', 'unraid_storage_status', 'unraid_disk_health', 'unraid_notifications_list', 'unraid_shares_list', 'unraid_share_inspect', + 'unraid_files_inventory', ]; $operator = array_merge($readonly, [ 'unraid_network_audit_tcp', 'unraid_network_lan_probe', diff --git a/scripts/package.sh b/scripts/package.sh index fc30b05..a8c2cdc 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -104,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF | |${PKG_NAME} - Mikes Unraid Agent |MCP over HTTP (Streamable HTTP) Server für Unraid. -|34 Tools: Docker, Community Applications, Netzwerk und Unraid-System. +|35 Tools: Docker, Community Applications, Netzwerk und Unraid-System. |Port: 3002, Endpunkt: /mcp | |Runtime: TypeScript (Bun Runtime, kompiliertes Binary) diff --git a/scripts/unraid-mcp-status-helper.php b/scripts/unraid-mcp-status-helper.php index 333c17e..2a56769 100644 --- a/scripts/unraid-mcp-status-helper.php +++ b/scripts/unraid-mcp-status-helper.php @@ -234,6 +234,96 @@ function shares_list(?string $requested): array { return ['schema_version' => '1.0', 'share_count' => count($items), 'shares' => $items]; } +function files_inventory( + string $share, + string $relative_path, + string $name_contains, + int $max_depth, + int $max_entries, + bool $include_files, + bool $include_directories +): array { + $known = []; + foreach (ini_sections('/var/local/emhttp/shares.ini') as $section => $entry) { + $name = (string)($entry['name'] ?? trim($section, '"')); + if ($name !== '') $known[$name] = true; + } + if (!isset($known[$share])) fail_status('Unknown share name'); + if ($relative_path !== '' && ($relative_path[0] === '/' || str_contains($relative_path, "\0"))) { + fail_status('relative_path must stay below the selected share'); + } + $parts = array_values(array_filter(explode('/', str_replace('\\', '/', $relative_path)), fn($p) => $p !== '')); + if (in_array('..', $parts, true) || in_array('.', $parts, true)) { + fail_status('relative_path traversal is not allowed'); + } + $relative_path = implode('/', $parts); + $root = '/mnt/user/' . $share . ($relative_path !== '' ? '/' . $relative_path : ''); + if (!is_dir($root) || !is_readable($root)) fail_status('Selected inventory root is not a readable directory'); + + $max_depth = max(1, min(20, $max_depth)); + $max_entries = max(1, min(5000, $max_entries)); + $needle = mb_strtolower(trim($name_contains)); + $queue = [[$root, '', 0, $needle === '']]; + $entries = []; + $scanned = 0; + $max_scanned = max(1000, min(100000, $max_entries * 50)); + $truncated = false; + + while ($queue) { + [$directory, $directory_relative, $depth, $inside_match] = array_shift($queue); + if ($depth >= $max_depth) continue; + $children = @scandir($directory); + if (!is_array($children)) continue; + natcasesort($children); + foreach ($children as $name) { + if ($name === '.' || $name === '..') continue; + $full = $directory . '/' . $name; + $relative = $directory_relative === '' ? $name : $directory_relative . '/' . $name; + $is_link = is_link($full); + $is_dir = !$is_link && is_dir($full); + $matches = $needle === '' || mb_stripos($relative, $needle) !== false; + $selected = $inside_match || $matches; + $scanned++; + if ($scanned > $max_scanned) { + $truncated = true; + break 2; + } + + if ($selected && (($is_dir && $include_directories) || (!$is_dir && $include_files))) { + $extension = $is_dir ? '' : strtolower((string)pathinfo($name, PATHINFO_EXTENSION)); + $entries[] = [ + 'relative_path' => $relative, + 'name' => $name, + 'type' => $is_link ? 'symlink' : ($is_dir ? 'directory' : 'file'), + 'extension' => $extension, + 'size_bytes' => (!$is_dir && !$is_link) ? max(0, (int)@filesize($full)) : 0, + 'depth' => $depth + 1, + ]; + if (count($entries) >= $max_entries) { + $truncated = true; + break 2; + } + } + if ($is_dir) $queue[] = [$full, $relative, $depth + 1, $inside_match || $matches]; + } + } + + return [ + 'schema_version' => '1.0', + 'share' => $share, + 'inventory_root' => $relative_path, + 'name_contains' => $name_contains, + 'entry_count' => count($entries), + 'scanned_entries' => $scanned, + 'truncated' => $truncated, + 'max_depth' => $max_depth, + 'max_entries' => $max_entries, + 'max_scanned_entries' => $max_scanned, + 'content_read' => false, + 'entries' => $entries, + ]; +} + function docker_update_state_value(mixed $value): ?string { if (is_array($value)) { foreach (['update_available', 'updateAvailable'] as $field) { @@ -356,6 +446,11 @@ if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) { case 'notifications': respond(notification_list((int)($argv[2] ?? 20), $argv[3] ?? 'all')); break; case 'shares-list': respond(shares_list(null)); break; case 'share-inspect': respond(shares_list($argv[2] ?? '')); break; + case 'files-inventory': respond(files_inventory( + $argv[2] ?? '', $argv[3] ?? '', $argv[4] ?? '', + (int)($argv[5] ?? 10), (int)($argv[6] ?? 2000), + ($argv[7] ?? '1') !== '0', ($argv[8] ?? '1') !== '0' + )); break; case 'docker-update-status': respond(docker_update_status()); break; default: fail_status('Unknown read-only status action'); } diff --git a/src/auth.ts b/src/auth.ts index 457bca4..311120a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -57,6 +57,7 @@ export const SAFE_DEFAULT_TOOLS = [ "unraid_notifications_list", "unraid_shares_list", "unraid_share_inspect", + "unraid_files_inventory", ]; // ── Config laden ──────────────────────────────────────────────────────── @@ -104,6 +105,17 @@ export function parseConfig(content: string): MUAConfig { ) { cfg.enabledTools.push("unraid_docker_update_verified_batch"); } + // r020 adds a bounded metadata-only filesystem inventory. It is strictly + // less powerful than the already-enabled read-only shell and avoids many + // repeated ls/find calls, so existing read-only installations may receive + // it without granting a new write capability. + if ( + !cfg.allToolsEnabled + && cfg.enabledTools.includes("unraid_system_shell_readonly") + && !cfg.enabledTools.includes("unraid_files_inventory") + ) { + cfg.enabledTools.push("unraid_files_inventory"); + } return cfg; } diff --git a/src/helpers.ts b/src/helpers.ts index 96e4df0..afabf74 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -14,7 +14,7 @@ import { createHash, randomUUID } from "node:crypto"; // ── Konstanten ────────────────────────────────────────────────────────── export const MUA_SERVER_NAME = "mua"; -export const MUA_VERSION = "2026.08.24.r019"; +export const MUA_VERSION = "2026.08.24.r020"; 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"; diff --git a/src/security.test.ts b/src/security.test.ts index 66b8dea..d093087 100644 --- a/src/security.test.ts +++ b/src/security.test.ts @@ -30,6 +30,14 @@ describe("secure tool configuration", () => { expect(cfg.enabledTools).toContain("unraid_docker_update"); expect(cfg.enabledTools).toContain("unraid_docker_update_verified_batch"); }); + + test("an enabled read-only shell also exposes bounded file inventory", () => { + const cfg = parseConfig( + "MUA_API_KEY=test\nMUA_ENABLED_TOOLS=unraid_system_shell_readonly\n", + ); + expect(cfg.enabledTools).toContain("unraid_files_inventory"); + expect(cfg.enabledTools).not.toContain("unraid_system_shell"); + }); }); describe("secret handling", () => { @@ -53,6 +61,7 @@ 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_files_inventory")).toBe("read"); expect(getToolRisk("unraid_system_health")).toBe("read"); expect(getToolRisk("unraid_ca_search")).toBe("active"); expect(getToolRisk("unraid_ca_install_preview")).toBe("active"); diff --git a/src/tools.ts b/src/tools.ts index e709acb..dfb40b2 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -533,6 +533,35 @@ export const TOOLS: ToolDef[] = [ }, handler: (a) => runStatusHelper("share-inspect", String(a["share"] ?? "")), }, + { + name: "unraid_files_inventory", + description: + "Inventory file and directory names below one exact Unraid share in one bounded read-only call. Use this instead of repeated ls/find calls for media-library audits, missing-episode checks and locating a named collection. It never reads file contents. If name_contains matches a directory, that directory and its descendants are returned; unrelated trees are omitted.", + inputSchema: { + type: "object", + properties: { + share: str("Exact Unraid share name, for example Audiobooks"), + relative_path: str("Optional path below the share; never use /mnt/user or an absolute path"), + name_contains: str("Optional case-insensitive name fragment, for example 'drei'. Matching directories include their descendants"), + max_depth: int("Maximum directory depth below relative_path (1-20, default 10)"), + max_entries: int("Maximum returned entries (1-5000, default 2000)"), + include_files: { type: "boolean", description: "Include files (default true)" }, + include_directories: { type: "boolean", description: "Include directories (default true)" }, + }, + required: ["share"], + additionalProperties: false, + }, + handler: (a) => runStatusHelper( + "files-inventory", + String(a["share"] ?? ""), + String(a["relative_path"] ?? ""), + String(a["name_contains"] ?? ""), + String(Math.max(1, Math.min(20, Number(a["max_depth"] ?? 10)))), + String(Math.max(1, Math.min(5000, Number(a["max_entries"] ?? 2000)))), + a["include_files"] === false ? "0" : "1", + a["include_directories"] === false ? "0" : "1", + ), + }, { name: "unraid_system_connection_test", description: