release r010 add Unraid health and Community Apps 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:** 23 (Docker: 14, Netzwerk: 6, System: 3)
|
- **Tools:** 33 (Docker, Community Applications, Netzwerk und Unraid-System)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,6 +55,10 @@ Die WebGUI schützt schreibende Formulare außerdem mit einem CSRF-Token.
|
|||||||
- Der API-Key erscheint nur unmittelbar nach seiner Erzeugung vollständig.
|
- Der API-Key erscheint nur unmittelbar nach seiner Erzeugung vollständig.
|
||||||
- Tool-Aufrufe werden ohne Argumente oder Ausgaben in
|
- Tool-Aufrufe werden ohne Argumente oder Ausgaben in
|
||||||
`/var/log/plugins/mua-audit.log` protokolliert.
|
`/var/log/plugins/mua-audit.log` protokolliert.
|
||||||
|
- Community-Applications-Installationen laufen immer über Suche, Vorschau
|
||||||
|
und ein exakt an diese Änderung gebundenes, zehn Minuten gültiges
|
||||||
|
Freigabeticket. Das Ergebnis bleibt über ein reguläres Unraid-
|
||||||
|
Benutzertemplate vollständig in der Docker-GUI verwaltbar.
|
||||||
- CORS ist standardmäßig aus. Requests sind auf 1 MiB, 120 pro Minute je
|
- CORS ist standardmäßig aus. Requests sind auf 1 MiB, 120 pro Minute je
|
||||||
Client und vier gleichzeitig laufende Werkzeuge begrenzt. Diese Grenzen
|
Client und vier gleichzeitig laufende Werkzeuge begrenzt. Diese Grenzen
|
||||||
lassen sich per Umgebungsvariable anpassen.
|
lassen sich per Umgebungsvariable anpassen.
|
||||||
@@ -72,10 +76,10 @@ Oder manuell:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .txz von Gitea laden
|
# .txz von Gitea laden
|
||||||
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r009-x86_64-1.txz
|
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r010-x86_64-1.txz
|
||||||
|
|
||||||
# Installieren
|
# Installieren
|
||||||
upgradepkg --install-new mua-2026.08.21.r009-x86_64-1.txz
|
upgradepkg --install-new mua-2026.08.21.r010-x86_64-1.txz
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Service starten
|
### 2. Service starten
|
||||||
@@ -90,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://192.168.1.2:3002/health
|
curl http://192.168.1.2:3002/health
|
||||||
# → {"status":"ok","version":"2026.08.21.r009","auth":"required"}
|
# → {"status":"ok","version":"2026.08.21.r010","auth":"required"}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -208,9 +212,10 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden:
|
|||||||
|
|
||||||
| Gruppe | Tools |
|
| Gruppe | Tools |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| **Docker (14)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_rebuild` |
|
| **Docker (15)** | `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_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` |
|
| **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` |
|
||||||
| **System (3)** | `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` |
|
| **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` |
|
||||||
|
|
||||||
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
|
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
|
||||||
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
|
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"version": "2026.08.21.r009",
|
"version": "2026.08.21.r010",
|
||||||
"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",
|
||||||
|
|||||||
+10
-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.21.r009">
|
<!ENTITY version "2026.08.21.r010">
|
||||||
<!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.21.r009-x86_64-1.txz">
|
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.21.r010-x86_64-1.txz">
|
||||||
<!ENTITY txzSHA256 "86cd473438c1aeb221b60b38e3e32d3f95f152df9d05e267ad6916a83d77debb">
|
<!ENTITY txzSHA256 "24e9efd787109c385b282daf056c54e6ca9984281fbde612cc39131f4107eaf1">
|
||||||
]>
|
]>
|
||||||
|
|
||||||
<PLUGIN name="&name;"
|
<PLUGIN name="&name;"
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.08.21.r010
|
||||||
|
- Sieben kompakte Unraid-Lesewerkzeuge für Systemzustand, Storage/Parity, SMART, Benachrichtigungen, Shares und Docker-Update-Status.
|
||||||
|
- Community Applications: offizieller Feed, kompakte Suche sowie sicherer Vorschau- und Installationsablauf.
|
||||||
|
- CA-Installationen benötigen ein exaktes, zehn Minuten gültiges Freigabeticket und erzeugen reguläre Unraid-Benutzertemplates für die GUI-Verwaltung.
|
||||||
|
- PHP-Helfer werden ohne Shell-Interpreter als direkte Argumentliste gestartet.
|
||||||
### 2026.08.21.r009
|
### 2026.08.21.r009
|
||||||
- WebGUI-Fix: Alle Formulare senden neben dem internen MUA-CSRF-Wert auch Unraids verpflichtendes csrf_token; Button-Klicks enden nicht mehr auf einer leeren Seite.
|
- WebGUI-Fix: Alle Formulare senden neben dem internen MUA-CSRF-Wert auch Unraids verpflichtendes csrf_token; Button-Klicks enden nicht mehr auf einer leeren Seite.
|
||||||
### 2026.08.21.r008
|
### 2026.08.21.r008
|
||||||
@@ -98,7 +103,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.21.r009-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.21.r010-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>
|
||||||
@@ -187,6 +192,7 @@ rm -f /usr/local/bin/mua
|
|||||||
|
|
||||||
# 4. PHP-Helper entfernen
|
# 4. PHP-Helper entfernen
|
||||||
rm -f /usr/local/bin/unraid-docker-mcp-helper.php
|
rm -f /usr/local/bin/unraid-docker-mcp-helper.php
|
||||||
|
rm -f /usr/local/bin/unraid-mcp-status-helper.php
|
||||||
|
|
||||||
# 5. Plugin-Verzeichnis entfernen
|
# 5. Plugin-Verzeichnis entfernen
|
||||||
rm -rf &emhttpLOC;
|
rm -rf &emhttpLOC;
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"author": "Michael",
|
"author": "Michael",
|
||||||
"version": "2026.08.21.r009",
|
"version": "2026.08.21.r010",
|
||||||
"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",
|
||||||
|
|||||||
+4
-1
@@ -71,10 +71,13 @@ $readonly = [
|
|||||||
'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats',
|
'unraid_docker_analyze_logs', 'unraid_docker_processes', 'unraid_docker_stats',
|
||||||
'unraid_docker_info', 'unraid_network_inventory', 'unraid_network_list',
|
'unraid_docker_info', 'unraid_network_inventory', 'unraid_network_list',
|
||||||
'unraid_network_inspect', 'unraid_network_host_state', 'unraid_system_connection_test',
|
'unraid_network_inspect', 'unraid_network_host_state', 'unraid_system_connection_test',
|
||||||
'unraid_system_shell_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',
|
||||||
];
|
];
|
||||||
$operator = array_merge($readonly, [
|
$operator = array_merge($readonly, [
|
||||||
'unraid_network_audit_tcp', 'unraid_network_lan_probe',
|
'unraid_network_audit_tcp', 'unraid_network_lan_probe',
|
||||||
|
'unraid_ca_search', 'unraid_ca_install_preview',
|
||||||
'unraid_docker_start', 'unraid_docker_stop', 'unraid_docker_restart',
|
'unraid_docker_start', 'unraid_docker_stop', 'unraid_docker_restart',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -65,6 +65,10 @@ chmod 644 "${BUILD_DIR}/usr/local/emhttp/plugins/mua/mua.page"
|
|||||||
cp "${ROOT}/scripts/unraid-docker-mcp-helper.php" "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
|
cp "${ROOT}/scripts/unraid-docker-mcp-helper.php" "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
|
||||||
chmod 755 "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
|
chmod 755 "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
|
||||||
|
|
||||||
|
# Kompakter Read-only-Helper für Unraid-Systemzustand
|
||||||
|
cp "${ROOT}/scripts/unraid-mcp-status-helper.php" "${BUILD_DIR}/usr/local/bin/unraid-mcp-status-helper.php"
|
||||||
|
chmod 755 "${BUILD_DIR}/usr/local/bin/unraid-mcp-status-helper.php"
|
||||||
|
|
||||||
# 3. doinst.sh (läuft nach Installation)
|
# 3. doinst.sh (läuft nach Installation)
|
||||||
cat > "${BUILD_DIR}/install/doinst.sh" << 'DOEOF'
|
cat > "${BUILD_DIR}/install/doinst.sh" << 'DOEOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
@@ -100,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
|||||||
|
|
|
|
||||||
|${PKG_NAME} - Mikes Unraid Agent
|
|${PKG_NAME} - Mikes Unraid Agent
|
||||||
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|
||||||
|23 Tools: Docker (14), Netzwerk (6), System (3).
|
|33 Tools: Docker, Community Applications, Netzwerk und Unraid-System.
|
||||||
|Port: 3002, Endpunkt: /mcp
|
|Port: 3002, Endpunkt: /mcp
|
||||||
|
|
|
|
||||||
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* modify <container> <field> <value>
|
* modify <container> <field> <value>
|
||||||
* update <container>
|
* update <container>
|
||||||
* rebuild <container>
|
* rebuild <container>
|
||||||
|
* ca-install <template_url> <container_name> <overrides_json> <start:true|false>
|
||||||
*
|
*
|
||||||
* modify fields:
|
* modify fields:
|
||||||
* port value: newExternalPort (z.B. "8466")
|
* port value: newExternalPort (z.B. "8466")
|
||||||
@@ -242,6 +243,80 @@ function modify_template(string $name, string $field, string $value): void {
|
|||||||
rebuild_container($name, false, true);
|
rebuild_container($name, false, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ca_install(string $template_url, string $name, string $overrides_json, bool $start): void {
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9._-]{1,128}$/', $name)) {
|
||||||
|
fail('Invalid container name');
|
||||||
|
}
|
||||||
|
$url = parse_url($template_url);
|
||||||
|
if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host'])) {
|
||||||
|
fail('Community Applications template must use HTTPS');
|
||||||
|
}
|
||||||
|
$host = strtolower((string)$url['host']);
|
||||||
|
if ($host === 'localhost' || str_ends_with($host, '.local')) {
|
||||||
|
fail('Private template hosts are rejected');
|
||||||
|
}
|
||||||
|
if (filter_var($host, FILTER_VALIDATE_IP) &&
|
||||||
|
!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||||
|
fail('Private or reserved template IPs are rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
$template_path = "/boot/config/plugins/dockerMan/templates-user/my-$name.xml";
|
||||||
|
if (file_exists($template_path)) fail("Template already exists: $name");
|
||||||
|
$existing = trim(docker_exec('inspect --type container --format {{.Name}} ' . escapeshellarg($name)));
|
||||||
|
if ($existing !== '' && stripos($existing, 'Error:') !== 0) fail("Container already exists: $name");
|
||||||
|
|
||||||
|
$context = stream_context_create(['http' => [
|
||||||
|
'timeout' => 30,
|
||||||
|
'follow_location' => 0,
|
||||||
|
'user_agent' => 'MUA Community Applications Installer',
|
||||||
|
]]);
|
||||||
|
$xml = @file_get_contents($template_url, false, $context, 0, 1024 * 1024 + 1);
|
||||||
|
if ($xml === false || strlen($xml) === 0) fail('Unable to download Community Applications template');
|
||||||
|
if (strlen($xml) > 1024 * 1024) fail('Community Applications template is too large');
|
||||||
|
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
$old = libxml_use_internal_errors(true);
|
||||||
|
$loaded = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOBLANKS);
|
||||||
|
libxml_clear_errors();
|
||||||
|
libxml_use_internal_errors($old);
|
||||||
|
if (!$loaded || !$dom->documentElement || $dom->documentElement->nodeName !== 'Container') {
|
||||||
|
fail('Invalid Community Applications container template');
|
||||||
|
}
|
||||||
|
$repositories = $dom->getElementsByTagName('Repository');
|
||||||
|
if ($repositories->length !== 1 || trim($repositories->item(0)->textContent) === '') {
|
||||||
|
fail('Template has no valid Docker repository');
|
||||||
|
}
|
||||||
|
$names = $dom->getElementsByTagName('Name');
|
||||||
|
if ($names->length === 0) {
|
||||||
|
$name_node = $dom->createElement('Name', $name);
|
||||||
|
$dom->documentElement->insertBefore($name_node, $dom->documentElement->firstChild);
|
||||||
|
} else {
|
||||||
|
$names->item(0)->nodeValue = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
$overrides = json_decode($overrides_json, true);
|
||||||
|
if (!is_array($overrides) || count($overrides) > 32) fail('Invalid overrides object');
|
||||||
|
foreach ($overrides as $target => $value) {
|
||||||
|
if (!is_string($target) || !is_string($value) || strlen($value) > 4096) fail('Invalid override');
|
||||||
|
$found = false;
|
||||||
|
foreach ($dom->getElementsByTagName('Config') as $config) {
|
||||||
|
if ($config->getAttribute('Target') === $target) {
|
||||||
|
set_config_value($config, $value);
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) fail("Override target not present in template: $target");
|
||||||
|
}
|
||||||
|
|
||||||
|
$dom->formatOutput = true;
|
||||||
|
if ($dom->save($template_path) === false) fail('Unable to write Unraid user template');
|
||||||
|
chmod($template_path, 0600);
|
||||||
|
out("Community Applications template saved: $name");
|
||||||
|
rebuild_container($name, true, $start);
|
||||||
|
out('Container is managed by the Unraid user template');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main ─────────────────────────────────────────────────────────────────
|
// ── Main ─────────────────────────────────────────────────────────────────
|
||||||
$argv = $_SERVER['argv'];
|
$argv = $_SERVER['argv'];
|
||||||
$action = $argv[1] ?? '';
|
$action = $argv[1] ?? '';
|
||||||
@@ -273,6 +348,11 @@ switch ($action) {
|
|||||||
rebuild_container($arg1, false);
|
rebuild_container($arg1, false);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'ca-install':
|
||||||
|
if (!$arg1 || !$arg2) fail('Usage: ca-install <template_url> <container_name> <overrides_json> <start:true|false>');
|
||||||
|
ca_install($arg1, $arg2, $arg3 ?: '{}', ($argv[5] ?? 'false') === 'true');
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
fail("Unknown action: $action. Usage: create|modify|update|rebuild");
|
fail("Unknown action: $action. Usage: create|modify|update|rebuild|ca-install");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* MUA read-only Unraid status helper.
|
||||||
|
*
|
||||||
|
* It deliberately returns compact, selected fields instead of raw Unraid
|
||||||
|
* configuration files, SMART dumps or notification archives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
|
||||||
|
function respond(array $value): void {
|
||||||
|
echo json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail_status(string $message): void {
|
||||||
|
respond(['ok' => false, 'error' => $message]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ini_sections(string $path): array {
|
||||||
|
if (!is_readable($path)) return [];
|
||||||
|
$result = parse_ini_file($path, true, INI_SCANNER_RAW);
|
||||||
|
return is_array($result) ? $result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ini_flat(string $path): array {
|
||||||
|
if (!is_readable($path)) return [];
|
||||||
|
$result = parse_ini_file($path, false, INI_SCANNER_RAW);
|
||||||
|
return is_array($result) ? $result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function number_value($value): int|float {
|
||||||
|
if (!is_numeric($value)) return 0;
|
||||||
|
return str_contains((string)$value, '.') ? (float)$value : (int)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean_text(string $text, int $max = 500): string {
|
||||||
|
$text = preg_replace('/\x1b\[[0-9;]*[A-Za-z]/', '', $text) ?? '';
|
||||||
|
$text = preg_replace('/((?:api[_-]?key|token|secret|password|passwd|authorization|cookie)\s*[=:]\s*)([^\s,;]+)/i', '$1[REDACTED]', $text) ?? '';
|
||||||
|
$text = preg_replace('/("(?:api[_-]?key|token|secret|password|passwd|authorization|cookie)"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', $text) ?? '';
|
||||||
|
$text = trim(preg_replace('/\s+/', ' ', $text) ?? '');
|
||||||
|
return mb_substr($text, 0, $max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes_from_meminfo(string $key): int {
|
||||||
|
$raw = @file_get_contents('/proc/meminfo') ?: '';
|
||||||
|
if (preg_match('/^' . preg_quote($key, '/') . ':\s+(\d+)\s+kB$/mi', $raw, $m)) {
|
||||||
|
return (int)$m[1] * 1024;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thermal_readings(): array {
|
||||||
|
$out = [];
|
||||||
|
foreach (glob('/sys/class/hwmon/hwmon*') ?: [] as $dir) {
|
||||||
|
$chip = trim(@file_get_contents($dir . '/name') ?: basename($dir));
|
||||||
|
foreach (glob($dir . '/temp*_input') ?: [] as $input) {
|
||||||
|
$raw = trim(@file_get_contents($input) ?: '');
|
||||||
|
if (!is_numeric($raw)) continue;
|
||||||
|
$value = (float)$raw / 1000;
|
||||||
|
if ($value < -20 || $value > 150) continue;
|
||||||
|
$prefix = preg_replace('/_input$/', '', $input);
|
||||||
|
$label = trim(@file_get_contents($prefix . '_label') ?: basename((string)$prefix));
|
||||||
|
$out[] = ['sensor' => $chip . ':' . $label, 'celsius' => round($value, 1)];
|
||||||
|
if (count($out) >= 24) break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function system_health(): array {
|
||||||
|
$total = bytes_from_meminfo('MemTotal');
|
||||||
|
$available = bytes_from_meminfo('MemAvailable');
|
||||||
|
$load = array_map('floatval', array_slice(preg_split('/\s+/', trim(@file_get_contents('/proc/loadavg') ?: '0 0 0')), 0, 3));
|
||||||
|
$uptime = (int)floor((float)explode(' ', trim(@file_get_contents('/proc/uptime') ?: '0'))[0]);
|
||||||
|
return [
|
||||||
|
'schema_version' => '1.0',
|
||||||
|
'hostname' => gethostname() ?: '',
|
||||||
|
'uptime_seconds' => $uptime,
|
||||||
|
'cpu_logical' => (int)trim((string)shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null')),
|
||||||
|
'load_average' => ['one' => $load[0] ?? 0, 'five' => $load[1] ?? 0, 'fifteen' => $load[2] ?? 0],
|
||||||
|
'memory' => [
|
||||||
|
'total_bytes' => $total,
|
||||||
|
'available_bytes' => $available,
|
||||||
|
'used_bytes' => max(0, $total - $available),
|
||||||
|
'used_percent' => $total > 0 ? round((($total - $available) / $total) * 100, 1) : 0,
|
||||||
|
],
|
||||||
|
'temperatures' => thermal_readings(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selected_disk(array $disk): array {
|
||||||
|
$size = number_value($disk['fsSize'] ?? $disk['size'] ?? 0);
|
||||||
|
$used = number_value($disk['fsUsed'] ?? 0);
|
||||||
|
$free = number_value($disk['fsFree'] ?? 0);
|
||||||
|
return [
|
||||||
|
'name' => $disk['name'] ?? '',
|
||||||
|
'device' => $disk['device'] ?? '',
|
||||||
|
'type' => $disk['type'] ?? '',
|
||||||
|
'status' => $disk['status'] ?? '',
|
||||||
|
'state' => $disk['state'] ?? '',
|
||||||
|
'temperature_c' => number_value($disk['temp'] ?? 0),
|
||||||
|
'errors' => number_value($disk['numErrors'] ?? 0),
|
||||||
|
'filesystem' => $disk['fsType'] ?? '',
|
||||||
|
'filesystem_status' => $disk['fsStatus'] ?? '',
|
||||||
|
'size_bytes' => $size,
|
||||||
|
'used_bytes' => $used,
|
||||||
|
'free_bytes' => $free,
|
||||||
|
'used_percent' => $size > 0 ? round(($used / $size) * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function storage_status(): array {
|
||||||
|
$var = ini_flat('/var/local/emhttp/var.ini');
|
||||||
|
$disks = array_values(array_map('selected_disk', ini_sections('/var/local/emhttp/disks.ini')));
|
||||||
|
$disabled = number_value($var['mdNumDisabled'] ?? 0);
|
||||||
|
$missing = number_value($var['mdNumMissing'] ?? 0);
|
||||||
|
$invalid = number_value($var['mdNumInvalid'] ?? 0);
|
||||||
|
$alerts = [];
|
||||||
|
if ($disabled > 0) $alerts[] = "$disabled disk(s) disabled";
|
||||||
|
if ($missing > 0) $alerts[] = "$missing disk(s) missing";
|
||||||
|
if ($invalid > 0) $alerts[] = "$invalid disk(s) invalid";
|
||||||
|
return [
|
||||||
|
'schema_version' => '1.0',
|
||||||
|
'array_state' => $var['mdState'] ?? '',
|
||||||
|
'filesystem_state' => $var['fsState'] ?? '',
|
||||||
|
'disabled_disks' => $disabled,
|
||||||
|
'missing_disks' => $missing,
|
||||||
|
'invalid_disks' => $invalid,
|
||||||
|
'parity' => [
|
||||||
|
'running' => number_value($var['mdResync'] ?? 0) > 0,
|
||||||
|
'action' => $var['mdResyncAction'] ?? '',
|
||||||
|
'position' => number_value($var['mdResyncPos'] ?? 0),
|
||||||
|
'size' => number_value($var['mdResyncSize'] ?? 0),
|
||||||
|
'corrections' => number_value($var['mdResyncCorr'] ?? 0),
|
||||||
|
],
|
||||||
|
'alerts' => $alerts,
|
||||||
|
'disk_count' => count($disks),
|
||||||
|
'disks' => $disks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function smart_attributes(string $name): array {
|
||||||
|
$path = '/var/local/emhttp/smart/' . $name;
|
||||||
|
if (!is_readable($path)) return [];
|
||||||
|
$wanted = [5, 9, 187, 188, 197, 198, 199, 202, 231, 241, 242];
|
||||||
|
$result = [];
|
||||||
|
foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||||
|
if (!preg_match('/^\s*(\d+)\s+([A-Za-z0-9_-]+)\s+.*?\s(-|FAILING_NOW)\s+(.+)$/', $line, $m)) continue;
|
||||||
|
$id = (int)$m[1];
|
||||||
|
if (!in_array($id, $wanted, true)) continue;
|
||||||
|
$result[] = ['id' => $id, 'name' => $m[2], 'when_failed' => $m[3], 'raw' => clean_text($m[4], 80)];
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disk_health(?string $requested): array {
|
||||||
|
$sections = ini_sections('/var/local/emhttp/disks.ini');
|
||||||
|
$result = [];
|
||||||
|
foreach ($sections as $section => $disk) {
|
||||||
|
$name = trim((string)($disk['name'] ?? trim($section, '"')));
|
||||||
|
if ($requested !== null && $requested !== '' && $name !== $requested) continue;
|
||||||
|
$entry = selected_disk($disk);
|
||||||
|
$entry['smart_attributes'] = smart_attributes($name);
|
||||||
|
$entry['healthy'] = number_value($disk['numErrors'] ?? 0) === 0 && !in_array($disk['status'] ?? '', ['DISK_DSBL', 'DISK_NP'], true);
|
||||||
|
$result[] = $entry;
|
||||||
|
}
|
||||||
|
if ($requested && count($result) === 0) fail_status('Unknown disk name');
|
||||||
|
return ['schema_version' => '1.0', 'disk_count' => count($result), 'disks' => $result];
|
||||||
|
}
|
||||||
|
|
||||||
|
function notification_list(int $limit, ?string $importance): array {
|
||||||
|
$limit = max(1, min(50, $limit));
|
||||||
|
$files = array_merge(glob('/tmp/notifications/*.notify') ?: [], glob('/tmp/notifications/archive/*.notify') ?: []);
|
||||||
|
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
|
||||||
|
$items = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$n = parse_ini_file($file, false, INI_SCANNER_RAW);
|
||||||
|
if (!is_array($n)) continue;
|
||||||
|
$level = trim((string)($n['importance'] ?? 'normal'), '"');
|
||||||
|
if ($importance && $importance !== 'all' && $level !== $importance) continue;
|
||||||
|
$items[] = [
|
||||||
|
'timestamp' => number_value(trim((string)($n['timestamp'] ?? filemtime($file)), '"')),
|
||||||
|
'event' => clean_text(trim((string)($n['event'] ?? ''), '"'), 100),
|
||||||
|
'importance' => $level,
|
||||||
|
'subject' => clean_text(trim((string)($n['subject'] ?? ''), '"'), 180),
|
||||||
|
'description' => clean_text(trim((string)($n['description'] ?? ''), '"'), 500),
|
||||||
|
'archived' => str_contains($file, '/archive/'),
|
||||||
|
];
|
||||||
|
if (count($items) >= $limit) break;
|
||||||
|
}
|
||||||
|
return ['schema_version' => '1.0', 'count' => count($items), 'notifications' => $items];
|
||||||
|
}
|
||||||
|
|
||||||
|
function share_entry(array $share, bool $details): array {
|
||||||
|
$size = number_value($share['size'] ?? 0);
|
||||||
|
$used = number_value($share['used'] ?? 0);
|
||||||
|
$entry = [
|
||||||
|
'name' => $share['name'] ?? '',
|
||||||
|
'pool' => $share['cachePool'] ?? '',
|
||||||
|
'secondary_pool' => $share['cachePool2'] ?? '',
|
||||||
|
'size_bytes' => $size,
|
||||||
|
'used_bytes' => $used,
|
||||||
|
'free_bytes' => number_value($share['free'] ?? 0),
|
||||||
|
'used_percent' => $size > 0 ? round(($used / $size) * 100, 1) : 0,
|
||||||
|
'exclusive' => ($share['exclusive'] ?? '') === 'yes',
|
||||||
|
'status_color' => $share['color'] ?? '',
|
||||||
|
];
|
||||||
|
if ($details) {
|
||||||
|
$entry += [
|
||||||
|
'comment' => clean_text((string)($share['comment'] ?? ''), 300),
|
||||||
|
'allocation_method' => $share['allocator'] ?? '',
|
||||||
|
'split_level' => $share['splitLevel'] ?? '',
|
||||||
|
'minimum_free_space' => number_value($share['floor'] ?? 0),
|
||||||
|
'included_disks' => $share['include'] ?? '',
|
||||||
|
'excluded_disks' => $share['exclude'] ?? '',
|
||||||
|
'cache_mode' => $share['useCache'] ?? '',
|
||||||
|
'has_config' => ($share['hasCfg'] ?? '') === 'yes',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shares_list(?string $requested): array {
|
||||||
|
$sections = ini_sections('/var/local/emhttp/shares.ini');
|
||||||
|
$items = [];
|
||||||
|
foreach ($sections as $section => $share) {
|
||||||
|
$name = (string)($share['name'] ?? trim($section, '"'));
|
||||||
|
if ($requested !== null && $requested !== '' && $name !== $requested) continue;
|
||||||
|
$items[] = share_entry($share, $requested !== null && $requested !== '');
|
||||||
|
}
|
||||||
|
if ($requested && count($items) === 0) fail_status('Unknown share name');
|
||||||
|
return ['schema_version' => '1.0', 'share_count' => count($items), 'shares' => $items];
|
||||||
|
}
|
||||||
|
|
||||||
|
function docker_update_status(): array {
|
||||||
|
$path = '/var/lib/docker/unraid-update-status.json';
|
||||||
|
$updates = is_readable($path) ? json_decode((string)file_get_contents($path), true) : [];
|
||||||
|
if (!is_array($updates)) $updates = [];
|
||||||
|
$rows = [];
|
||||||
|
exec('/usr/bin/docker ps -a --format ' . escapeshellarg('{{.Names}}|{{.Image}}'), $rows);
|
||||||
|
$items = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
[$name, $image] = array_pad(explode('|', $row, 2), 2, '');
|
||||||
|
$key = $image;
|
||||||
|
if (!isset($updates[$key]) && !str_contains($key, '/')) $key = 'library/' . $key;
|
||||||
|
$state = $updates[$key]['status'] ?? null;
|
||||||
|
$items[] = [
|
||||||
|
'container' => $name,
|
||||||
|
'image' => $image,
|
||||||
|
'status' => $state === 'true' ? 'up-to-date' : ($state === 'false' ? 'update-available' : 'unknown'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$available = count(array_filter($items, fn($x) => $x['status'] === 'update-available'));
|
||||||
|
return ['schema_version' => '1.0', 'container_count' => count($items), 'updates_available' => $available, 'containers' => $items];
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $argv[1] ?? '';
|
||||||
|
switch ($action) {
|
||||||
|
case 'system-health': respond(system_health()); break;
|
||||||
|
case 'storage-status': respond(storage_status()); break;
|
||||||
|
case 'disk-health': respond(disk_health($argv[2] ?? null)); break;
|
||||||
|
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 'docker-update-status': respond(docker_update_status()); break;
|
||||||
|
default: fail_status('Unknown read-only status action');
|
||||||
|
}
|
||||||
@@ -44,12 +44,19 @@ export const SAFE_DEFAULT_TOOLS = [
|
|||||||
"unraid_docker_processes",
|
"unraid_docker_processes",
|
||||||
"unraid_docker_stats",
|
"unraid_docker_stats",
|
||||||
"unraid_docker_info",
|
"unraid_docker_info",
|
||||||
|
"unraid_docker_update_status",
|
||||||
"unraid_network_inventory",
|
"unraid_network_inventory",
|
||||||
"unraid_network_list",
|
"unraid_network_list",
|
||||||
"unraid_network_inspect",
|
"unraid_network_inspect",
|
||||||
"unraid_network_host_state",
|
"unraid_network_host_state",
|
||||||
"unraid_system_connection_test",
|
"unraid_system_connection_test",
|
||||||
"unraid_system_shell_readonly",
|
"unraid_system_shell_readonly",
|
||||||
|
"unraid_system_health",
|
||||||
|
"unraid_storage_status",
|
||||||
|
"unraid_disk_health",
|
||||||
|
"unraid_notifications_list",
|
||||||
|
"unraid_shares_list",
|
||||||
|
"unraid_share_inspect",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Config laden ────────────────────────────────────────────────────────
|
// ── Config laden ────────────────────────────────────────────────────────
|
||||||
|
|||||||
+275
-3
@@ -10,12 +10,14 @@
|
|||||||
|
|
||||||
import { spawn } from "bun";
|
import { spawn } from "bun";
|
||||||
import { createConnection, type Socket } from "net";
|
import { createConnection, type Socket } from "net";
|
||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||||
export const MUA_SERVER_NAME = "mua";
|
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 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";
|
||||||
|
|
||||||
// ── Command Execution ───────────────────────────────────────────────────
|
// ── Command Execution ───────────────────────────────────────────────────
|
||||||
export interface CmdResult {
|
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([
|
const READ_ONLY_PROGRAMS = new Set([
|
||||||
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
|
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
|
||||||
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
|
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
|
||||||
@@ -212,8 +239,14 @@ export async function runPhpHelper(
|
|||||||
if (!Bun.file(helper).exists()) {
|
if (!Bun.file(helper).exists()) {
|
||||||
throw new Error(`PHP helper not found: ${helper}`);
|
throw new Error(`PHP helper not found: ${helper}`);
|
||||||
}
|
}
|
||||||
const escaped = [action, ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
return runArgv(`php_helper:${action}`, ["/usr/bin/php", helper, action, ...args], 300);
|
||||||
return runLocal(`php_helper:${action}`, `/usr/bin/php ${helper} ${escaped}`, 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 ─────────────────────────────────────────────────────────
|
// ── Validierung ─────────────────────────────────────────────────────────
|
||||||
@@ -668,3 +701,242 @@ export async function connectionTest(): Promise<string> {
|
|||||||
15,
|
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(/
|&/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
@@ -179,20 +179,15 @@ async function handleMcpRequest(
|
|||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
};
|
};
|
||||||
// structuredContent für Tools mit JSON-Output
|
// JSON-Ausgaben zusätzlich strukturiert bereitstellen. Reiner Text
|
||||||
if (
|
// bleibt unverändert im normalen MCP-Content-Feld.
|
||||||
[
|
try {
|
||||||
"unraid_docker_list",
|
const structured = JSON.parse(text);
|
||||||
"unraid_network_inventory",
|
if (structured !== null && typeof structured === "object") {
|
||||||
"unraid_docker_analyze_logs",
|
result.structuredContent = structured;
|
||||||
"unraid_network_audit_tcp",
|
|
||||||
].includes(toolName)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
result.structuredContent = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Kein JSON-Tool: Textausgabe genügt.
|
||||||
}
|
}
|
||||||
return { response: rpcResult(id, result), sessionId: sessionId ?? "" };
|
return { response: rpcResult(id, result), sessionId: sessionId ?? "" };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth";
|
import { parseConfig, SAFE_DEFAULT_TOOLS } from "./auth";
|
||||||
import { runReadOnlyCommand, sanitizeLogOutput } from "./helpers";
|
import { installCommunityApp, runReadOnlyCommand, sanitizeLogOutput } from "./helpers";
|
||||||
import { getToolRisk } from "./tools";
|
import { getToolRisk } from "./tools";
|
||||||
|
|
||||||
describe("secure tool configuration", () => {
|
describe("secure tool configuration", () => {
|
||||||
@@ -44,6 +44,10 @@ describe("risk classification", () => {
|
|||||||
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
|
expect(getToolRisk("unraid_network_lan_probe")).toBe("active");
|
||||||
expect(getToolRisk("unraid_docker_list")).toBe("read");
|
expect(getToolRisk("unraid_docker_list")).toBe("read");
|
||||||
expect(getToolRisk("unraid_system_shell_readonly")).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();
|
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
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* MUA — Mikes Unraid Agent
|
* 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>
|
* Portiert von mcp/tools.php. Schema: unraid_<kategorie>_<aktion>
|
||||||
* Kategorien: docker (14), network (6), system (2).
|
* Kategorien: docker (14), network (6), system (2).
|
||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
validateName,
|
validateName,
|
||||||
runShell,
|
runShell,
|
||||||
runReadOnlyCommand,
|
runReadOnlyCommand,
|
||||||
|
runStatusHelper,
|
||||||
|
searchCommunityApps,
|
||||||
|
previewCommunityAppInstall,
|
||||||
|
installCommunityApp,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
|
|
||||||
export interface ToolDef {
|
export interface ToolDef {
|
||||||
@@ -38,6 +42,7 @@ const CRITICAL_TOOLS = new Set([
|
|||||||
"unraid_docker_update",
|
"unraid_docker_update",
|
||||||
"unraid_docker_rebuild",
|
"unraid_docker_rebuild",
|
||||||
"unraid_system_shell",
|
"unraid_system_shell",
|
||||||
|
"unraid_ca_install",
|
||||||
]);
|
]);
|
||||||
const WRITE_TOOLS = new Set([
|
const WRITE_TOOLS = new Set([
|
||||||
"unraid_docker_start",
|
"unraid_docker_start",
|
||||||
@@ -47,6 +52,8 @@ const WRITE_TOOLS = new Set([
|
|||||||
const ACTIVE_TOOLS = new Set([
|
const ACTIVE_TOOLS = new Set([
|
||||||
"unraid_network_audit_tcp",
|
"unraid_network_audit_tcp",
|
||||||
"unraid_network_lan_probe",
|
"unraid_network_lan_probe",
|
||||||
|
"unraid_ca_search",
|
||||||
|
"unraid_ca_install_preview",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function getToolRisk(name: string): ToolRisk {
|
export function getToolRisk(name: string): ToolRisk {
|
||||||
@@ -158,6 +165,13 @@ export const TOOLS: ToolDef[] = [
|
|||||||
inputSchema: empty,
|
inputSchema: empty,
|
||||||
handler: () => dockerExec(`info --format '{{json .}}'`),
|
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",
|
name: "unraid_docker_start",
|
||||||
description: "Start a Docker container.",
|
description: "Start a Docker container.",
|
||||||
@@ -248,6 +262,75 @@ export const TOOLS: ToolDef[] = [
|
|||||||
handler: (a) => runPhpHelper("rebuild", validateName(a["container"], "container")),
|
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) ──────────────────────────────────────────────────────
|
// ── Netzwerk (6) ──────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
name: "unraid_network_inventory",
|
name: "unraid_network_inventory",
|
||||||
@@ -323,6 +406,68 @@ export const TOOLS: ToolDef[] = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── System (3) ────────────────────────────────────────────────────────
|
// ── 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",
|
name: "unraid_system_connection_test",
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user