commit f4e66ea5a171027a35ce2dd69bd9f9bed37346ac
Author: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Mon Aug 17 22:00:48 2026 +0200
MUA v1.0.0 — Mikes Unraid Agent
Natives Unraid-Plugin: 21 Docker-, Netzwerk- und System-Tools
als MCP-Server (Streamable HTTP) auf Port 3002.
- mcp/server.php: MCP-Server (JSON-RPC 2.0, Streamable HTTP)
- mcp/helpers.php: Lokale Tool-Implementierungen (kein SSH)
- mcp/tools.php: 21 Tool-Definitionen
- mcp/selftest.php: Selftest (12 Checks)
- scripts/unraid-docker-mcp-helper.php: Write-Operationen
- scripts/mua.service: systemd-Service
- scripts/install.sh, update.sh, remove.sh: Plugin-Scripts
- MUA.plg: Plugin-Manifest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b1c6b20
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,20 @@
+# PHP
+*.php.swp
+*.php.swo
+.phpunit.result.cache
+
+# OS
+.DS_Store
+Thumbs.db
+
+# IDE
+.idea/
+.vscode/
+*.iml
+
+# Logs
+*.log
+
+# Temp
+/tmp/
+*.tmp
diff --git a/MUA.plg b/MUA.plg
new file mode 100644
index 0000000..abeebd1
--- /dev/null
+++ b/MUA.plg
@@ -0,0 +1,71 @@
+
+
+ mua
+ 1.0.0
+ 1
+ x86_64
+ https://git-192.168.1.2:33/michael/MUA-Mikes-Unraid-Agent
+ MUA - Mikes Unraid Agent (MCP Server)
+ MUA (Mikes Unraid Agent) is a native Unraid plugin that exposes 21 Docker, network, and system tools as an MCP (Model Context Protocol) server over HTTP (Streamable HTTP transport). Runs locally on the Unraid host - no SSH required. Endpoint: http://<unraid-ip>:3002/mcp
+ Michael
+ MIT
+
+ Tools
+
+
+
+ mcp/server.php
+ /usr/local/plugins/mua/mcp/
+ 0644
+
+
+ mcp/helpers.php
+ /usr/local/plugins/mua/mcp/
+ 0644
+
+
+ mcp/tools.php
+ /usr/local/plugins/mua/mcp/
+ 0644
+
+
+ scripts/unraid-docker-mcp-helper.php
+ /usr/local/plugins/mua/scripts/
+ 0755
+
+
+ scripts/mua.service
+ /usr/local/plugins/mua/scripts/
+ 0644
+
+
+ scripts/install.sh
+ /usr/local/plugins/mua/scripts/
+ 0755
+
+
+ scripts/update.sh
+ /usr/local/plugins/mua/scripts/
+ 0755
+
+
+ scripts/remove.sh
+ /usr/local/plugins/mua/scripts/
+ 0755
+
+
+
+
+ install
+
+
+
+ update
+
+
+
+ remove
+
+
+
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6d58026
--- /dev/null
+++ b/README.md
@@ -0,0 +1,140 @@
+# MUA — Mikes Unraid Agent
+
+Natives Unraid-Plugin, das 21 Docker-, Netzwerk- und System-Tools als **MCP-Server** (Model Context Protocol) über **HTTP (Streamable HTTP)** exponiert.
+
+Läuft **lokal auf dem Unraid-Host** — kein SSH, kein Proxy, kein Key-Management.
+
+## Endpunkt
+
+```
+http://:3002/mcp
+```
+
+- **POST** `/mcp` — JSON-RPC 2.0 (MCP-Requests)
+- **GET** `/health` — Health-Check
+- **DELETE** `/mcp` — Session-End
+
+## Tools (21)
+
+### Docker (14)
+| Tool | Beschreibung |
+|------|-------------|
+| `unraid_docker_list` | Alle Container mit Runtime-Stats |
+| `unraid_docker_inspect` | Container-Details |
+| `unraid_docker_logs` | Container-Logs |
+| `unraid_docker_analyze_logs` | Log-Analyse (server-seitig) |
+| `unraid_docker_processes` | Prozesse im Container |
+| `unraid_docker_stats` | Live-Stats (CPU/RAM/Net) |
+| `unraid_docker_info` | Docker-Daemon-Info |
+| `unraid_docker_start` | Container starten |
+| `unraid_docker_stop` | Container stoppen |
+| `unraid_docker_restart` | Container neu starten |
+| `unraid_docker_create` | Container aus Template |
+| `unraid_docker_modify` | Template ändern |
+| `unraid_docker_update` | Container aktualisieren |
+| `unraid_docker_rebuild` | Container neu bauen |
+
+### Netzwerk (6)
+| Tool | Beschreibung |
+|------|-------------|
+| `unraid_network_inventory` | Netzwerk-Inventur |
+| `unraid_network_list` | Alle Netzwerke |
+| `unraid_network_inspect` | Netzwerk-Details |
+| `unraid_network_host_state` | Host-Netzwerk-Zustand |
+| `unraid_network_audit_tcp` | TCP-Endpunkte auditieren |
+| `unraid_network_lan_probe` | Dualstack-Probe |
+
+### System (1)
+| Tool | Beschreibung |
+|------|-------------|
+| `unraid_system_connection_test` | Verbindungstest |
+
+## Installation
+
+### Via Community Apps (Link)
+1. Community Apps öffnen
+2. "Add Plugin" → Link: `https://git-192.168.1.2:33/michael/MUA-Mikes-Unraid-Agent`
+3. Installieren
+
+### Manuell
+```bash
+# Dateien kopieren
+mkdir -p /usr/local/plugins/mua
+cp -r mcp scripts /usr/local/plugins/mua/
+
+# Helper installieren
+cp scripts/unraid-docker-mcp-helper.php /usr/local/bin/
+chmod 755 /usr/local/bin/unraid-docker-mcp-helper.php
+
+# Service installieren
+cp scripts/mua.service /etc/systemd/system/
+systemctl daemon-reload
+systemctl enable --now mua.service
+
+# Health-Check
+curl http://127.0.0.1:3002/health
+```
+
+## Anbindung an MCP-Client
+
+### Beispiel: Hermes Agent (config.yaml)
+```yaml
+mcp_servers:
+ mua:
+ url: http://192.168.1.2:3002/mcp
+ transport: http
+```
+
+### Beispiel: curl
+```bash
+# Initialize
+curl -X POST http://192.168.1.2:3002/mcp \
+ -H "Content-Type: application/json" \
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
+
+# Tools auflisten
+curl -X POST http://192.168.1.2:3002/mcp \
+ -H "Content-Type: application/json" \
+ -H "Mcp-Session-Id: " \
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
+
+# Tool aufrufen
+curl -X POST http://192.168.1.2:3002/mcp \
+ -H "Content-Type: application/json" \
+ -H "Mcp-Session-Id: " \
+ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"unraid_docker_list","arguments":{}}}'
+```
+
+## Selftest
+
+```bash
+php /usr/local/plugins/mua/mcp/selftest.php
+```
+
+## Architektur
+
+```
+MCP-Client (z.B. llama.cpp auf 196)
+ │
+ │ HTTP (Streamable HTTP)
+ ▼
+http://192.168.1.2:3002/mcp (MUA Plugin auf Unraid)
+ │
+ │ lokal (kein SSH)
+ ▼
+/usr/bin/docker, /usr/bin/ip, /usr/bin/ss, /usr/bin/php
+```
+
+## Port
+
+- **3002** (3000 ist belegt von `theippenguin/unraid-mcp`)
+
+## Git
+
+- Repo: `michael/MUA-Mikes-Unraid-Agent.git`
+- Zugang: `git@192.168.1.2:33` (SSH-Key `ha_agent`)
+- Updates: Neuer Commit + Push
+
+## Lizenz
+
+MIT
diff --git a/mcp/helpers.php b/mcp/helpers.php
new file mode 100644
index 0000000..ea2782c
--- /dev/null
+++ b/mcp/helpers.php
@@ -0,0 +1,471 @@
+ ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+ $proc = proc_open($cmd, $descriptors, $pipes);
+ if (!is_resource($proc)) {
+ throw new RuntimeException("proc_open failed for: $label");
+ }
+ fclose($pipes[0]);
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ $output = '';
+ $error = '';
+ $start = microtime(true);
+ while (true) {
+ $out = fread($pipes[1], 65536);
+ $err = fread($pipes[2], 65536);
+ if ($out !== false && $out !== '') $output .= $out;
+ if ($err !== false && $err !== '') $error .= $err;
+ if (feof($pipes[1]) && feof($pipes[2])) break;
+ if (microtime(true) - $start > $timeout) {
+ proc_terminate($proc, 9);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($proc);
+ throw new RuntimeException("Timeout after {$timeout}s: $label");
+ }
+ usleep(5000);
+ }
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ $rc = proc_close($proc);
+
+ $result = trim($output);
+ if ($result === '' && $error !== '') {
+ $result = trim($error);
+ }
+ return $result;
+}
+
+/**
+ * Docker-Befehl ausführen (kompakt).
+ */
+function docker_exec(string $cmd, int $timeout = 60): string {
+ return run_local('docker', "/usr/bin/docker $cmd 2>&1", $timeout);
+}
+
+/**
+ * Validiere einen Namen (Container, Network, Host, Template).
+ * Verhindert Shell-Injection.
+ *
+ * @param mixed $value
+ * @param string $field
+ * @return string
+ * @throws InvalidArgumentException
+ */
+function validate_name($value, string $field): string {
+ if (!is_string($value) || $value === '') {
+ throw new InvalidArgumentException("$field is required");
+ }
+ // Erlaubt: Buchstaben, Ziffern, -, _, .
+ if (!preg_match('/^[a-zA-Z0-9._-]{1,128}$/', $value)) {
+ throw new InvalidArgumentException("Invalid $field: $value");
+ }
+ return $value;
+}
+
+/**
+ * JSON-Lines parsen (eine JSON-Objekt pro Zeile).
+ */
+function json_lines(string $text): array {
+ $result = [];
+ foreach (explode("\n", $text) as $line) {
+ $line = trim($line);
+ if ($line === '') continue;
+ $decoded = json_decode($line, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ $result[] = $decoded;
+ }
+ }
+ return $result;
+}
+
+/**
+ * Extrahiere Host-Adressen aus `ip -j address show` + `ss`-Output.
+ *
+ * @return array{ipv4:string, ipv6:string, public_ipv6:string}
+ */
+function host_addresses(string $raw): array {
+ // Robust: JSON parsen statt Regex
+ $data = json_decode($raw, true);
+ if (!is_array($data)) {
+ return ['ipv4' => '', 'ipv6' => '', 'public_ipv6' => ''];
+ }
+
+ $ipv4 = '';
+ $ipv6 = '';
+ $public_ipv6 = '';
+
+ foreach ($data as $iface) {
+ $ifname = $iface['ifname'] ?? '';
+ if ($ifname === 'lo') continue; // Loopback überspringen
+
+ foreach ($iface['addr_info'] ?? [] as $addr) {
+ $local = $addr['local'] ?? '';
+ $family = $addr['family'] ?? '';
+
+ if ($family === 'inet') {
+ // IPv4: erste private Adresse
+ if ($local !== '127.0.0.1' && $local !== '0.0.0.0' && $ipv4 === '') {
+ $ipv4 = $local;
+ }
+ } elseif ($family === 'inet6') {
+ // IPv6: Link-Local (fe80::)
+ if (strpos($local, 'fe80') === 0 && $ipv6 === '') {
+ $ipv6 = $local;
+ }
+ // Public IPv6 (global, nicht fe80/fd/fc/::1)
+ if (strpos($local, 'fe80') !== 0
+ && strpos($local, 'fd') !== 0
+ && strpos($local, 'fc') !== 0
+ && $local !== '::1'
+ && $public_ipv6 === '') {
+ $public_ipv6 = $local;
+ }
+ }
+ }
+ }
+
+ return ['ipv4' => $ipv4, 'ipv6' => $ipv6, 'public_ipv6' => $public_ipv6];
+}
+
+/**
+ * TCP-Port probe (IPv4 oder IPv6).
+ */
+function tcp_probe(string $host, int $port, int $family, float $timeout): array {
+ if ($host === '') {
+ return ['reachable' => false, 'error' => 'no host address'];
+ }
+ $addr = $family === AF_INET6 ? "[$host]" : $host;
+ $context = stream_context_create([
+ 'tcp' => ['timeout' => $timeout, 'binary_package' => true],
+ ]);
+ $start = microtime(true);
+ $fp = @fsockopen($addr, $port, $errno, $errstr, $timeout, $family === AF_INET6 ? STREAM_CLIENT_IPPROTO_V6 : 0);
+ $elapsed = (microtime(true) - $start) * 1000;
+ if ($fp) {
+ fclose($fp);
+ return ['reachable' => true, 'latency_ms' => round($elapsed, 1)];
+ }
+ return ['reachable' => false, 'error' => $errstr ?: "errno $errno"];
+}
+
+/**
+ * Sanitize Log-Output (entferne Control-Chars, begrenze Länge).
+ */
+function sanitize_log_output(string $text, int $max_chars = 50000): string {
+ // Entferne ANSI-Escape-Sequenzen
+ $text = preg_replace('/\x1b\[[0-9;]*[a-zA-Z]/', '', $text);
+ // Entferne andere Control-Chars (außer \n, \r, \t)
+ $text = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/', '', $text);
+ // Begrenze Länge
+ if (strlen($text) > $max_chars) {
+ $text = '... [truncated] ...' . substr($text, -$max_chars);
+ }
+ return $text;
+}
+
+/**
+ * Kompakte Container-Inspect-Ausgabe.
+ */
+function compact_container_inspect(string $raw): string {
+ $data = json_decode($raw, true);
+ if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
+ return $raw;
+ }
+ // Docker inspect gibt ein Array mit einem Element zurück
+ if (isset($data[0])) {
+ $data = $data[0];
+ }
+ $compact = [
+ 'Id' => substr($data['Id'] ?? '', 0, 12),
+ 'Name' => $data['Name'] ?? '',
+ 'State' => [
+ 'Status' => $data['State']['Status'] ?? '',
+ 'Running' => $data['State']['Running'] ?? false,
+ 'Pid' => $data['State']['Pid'] ?? 0,
+ 'ExitCode' => $data['State']['ExitCode'] ?? 0,
+ ],
+ 'Image' => $data['Config']['Image'] ?? '',
+ 'NetworkMode' => $data['HostConfig']['NetworkMode'] ?? '',
+ 'Ports' => $data['NetworkSettings']['Ports'] ?? [],
+ 'Env' => $data['Config']['Env'] ?? [],
+ 'Mounts' => array_map(function ($m) {
+ return [
+ 'Type' => $m['Type'] ?? '',
+ 'Source' => $m['Source'] ?? '',
+ 'Destination' => $m['Destination'] ?? '',
+ ];
+ }, $data['Mounts'] ?? []),
+ 'RestartCount' => $data['RestartCount'] ?? 0,
+ 'Created' => $data['Created'] ?? '',
+ ];
+ return json_encode($compact, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+}
+
+/**
+ * Container-Runtime-Zusammenfassung (docker ps + docker stats).
+ */
+function container_runtime_summary(): string {
+ $ps = docker_exec("ps -a --format '{{json .}}'");
+ $containers = json_lines($ps);
+
+ // docker stats für laufende Container
+ $running_ids = array_filter(array_map(function ($c) {
+ return $c['ID'] ?? '';
+ }, $containers));
+
+ $stats = [];
+ if (!empty($running_ids)) {
+ $stats_raw = docker_exec("stats --no-stream --format '{{json .}}'");
+ foreach (json_lines($stats_raw) as $s) {
+ $stats[$s['ID'] ?? ''] = $s;
+ }
+ }
+
+ $result = [];
+ foreach ($containers as $c) {
+ $id = $c['ID'] ?? '';
+ $entry = [
+ 'id' => substr($id, 0, 12),
+ 'name' => $c['Names'] ?? '',
+ 'image' => $c['Image'] ?? '',
+ 'status' => $c['Status'] ?? '',
+ 'state' => $c['State'] ?? '',
+ 'ports' => $c['Ports'] ?? '',
+ ];
+ if (isset($stats[$id])) {
+ $entry['cpu_percent'] = $stats[$id]['CPUPerc'] ?? '';
+ $entry['mem_usage'] = $stats[$id]['MemUsage'] ?? '';
+ $entry['mem_percent'] = $stats[$id]['MemPerc'] ?? '';
+ $entry['net_io'] = $stats[$id]['NetIO'] ?? '';
+ $entry['block_io'] = $stats[$id]['BlockIO'] ?? '';
+ }
+ $result[] = $entry;
+ }
+
+ return json_encode([
+ 'schema_version' => '1.0',
+ 'container_count' => count($result),
+ 'containers' => $result,
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+}
+
+/**
+ * Kompakte Netzwerk-Inventur.
+ */
+function compact_network_inventory(array $args): string {
+ $networks_raw = docker_exec("network ls --no-trunc --format '{{json .}}'");
+ $networks = json_lines($networks_raw);
+
+ $result = [];
+ foreach ($networks as $n) {
+ $entry = [
+ 'name' => $n['Name'] ?? '',
+ 'id' => substr($n['ID'] ?? '', 0, 12),
+ 'driver' => $n['Driver'] ?? '',
+ 'scope' => $n['Scope'] ?? '',
+ ];
+ // Container-Count via inspect
+ $inspect = docker_exec("network inspect --format '{{len .Containers}}' {$n['Name']}");
+ $entry['container_count'] = (int)trim($inspect);
+ $result[] = $entry;
+ }
+
+ return json_encode([
+ 'schema_version' => '1.0',
+ 'network_count' => count($result),
+ 'networks' => $result,
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+}
+
+/**
+ * Container-Logs analysieren (server-seitig).
+ */
+function analyze_container_logs(?string $severity, ?string $container, string $since, int $scan_tail, int $max_results): string {
+ $severity_levels = [
+ 'error' => ['error', 'fatal', 'panic', 'exception', 'traceback', 'critical'],
+ 'warn' => ['warn', 'warning', 'deprecated'],
+ 'info' => ['info', 'started', 'listening', 'ready'],
+ ];
+
+ $patterns = $severity_levels[$severity] ?? $severity_levels['error'];
+ $regex = '/(' . implode('|', array_map('preg_quote', $patterns)) . ')/i';
+
+ // Hole Logs
+ $log_cmd = "logs --timestamps --since $since --tail $scan_tail";
+ if ($container) {
+ $log_cmd .= " $container";
+ }
+ $raw = docker_exec($log_cmd);
+ $lines = explode("\n", $raw);
+
+ $matches = [];
+ $counts = [];
+ foreach ($lines as $line) {
+ if (preg_match($regex, $line, $m)) {
+ $key = strtolower($m[1]);
+ $counts[$key] = ($counts[$key] ?? 0) + 1;
+ if (count($matches) < $max_results) {
+ $matches[] = [
+ 'pattern' => $m[1],
+ 'line' => mb_substr(trim($line), 0, 300),
+ ];
+ }
+ }
+ }
+
+ return json_encode([
+ 'schema_version' => '1.0',
+ 'severity' => $severity,
+ 'container' => $container,
+ 'since' => $since,
+ 'scan_tail' => $scan_tail,
+ 'total_matches' => array_sum($counts),
+ 'pattern_counts' => $counts,
+ 'sample_matches' => $matches,
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+}
+
+/**
+ * Dualstack-LAN-Probe.
+ */
+function probe_dualstack(string $host, int $port, float $timeout): string {
+ $ipv4 = tcp_probe($host, $port, AF_INET, $timeout);
+ $ipv6 = tcp_probe($host, $port, AF_INET6, $timeout);
+ return json_encode([
+ 'host' => $host,
+ 'port' => $port,
+ 'ipv4' => $ipv4,
+ 'ipv6' => $ipv6,
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+}
+
+/**
+ * Alle TCP-Endpunkte auditieren (komplexes Tool).
+ */
+function audit_all_tcp_endpoints(float $timeout, bool $include_all_endpoints = false): string {
+ // Container-Inventur
+ $inventory_cmd = "ids=\$(docker ps -aq); [ -z \"\$ids\" ] || docker inspect --type container --format '{\"ID\":{{json .Id}},\"Name\":{{json .Name}},\"Image\":{{json .Config.Image}},\"Status\":{{json .State.Status}},\"Running\":{{json .State.Running}},\"Health\":{{json (index .State \"Health\")}},\"NetworkMode\":{{json .HostConfig.NetworkMode}},\"ExposedPorts\":{{json (index .Config \"ExposedPorts\")}},\"Ports\":{{json .NetworkSettings.Ports}}}' \$ids";
+ $containers = json_lines(run_local('audit', $inventory_cmd));
+
+ // Host-Netzwerk
+ $host_cmd = "printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup";
+ $host_raw = run_local('audit', $host_cmd);
+ $addrs = host_addresses($host_raw);
+
+ $by_id = [];
+ foreach ($containers as $item) {
+ $by_id[$item['ID'] ?? ''] = $item;
+ }
+
+ $endpoints = [];
+ $inactive = [];
+ $no_tcp = [];
+ $udp = [];
+ $endpoint_keys = [];
+
+ foreach ($containers as $item) {
+ $name = ltrim($item['Name'] ?? '', '/');
+ if (!($item['Running'] ?? false)) {
+ $inactive[] = ['container' => $name, 'status' => $item['Status'] ?? ''];
+ continue;
+ }
+ $mode = $item['NetworkMode'] ?? 'unknown';
+ $found_tcp = false;
+ foreach (($item['Ports'] ?? []) as $container_port => $bindings) {
+ $protocol = substr($container_port, strrpos($container_port, '/') + 1);
+ if ($protocol === 'udp' && $bindings) {
+ $udp[] = ['container' => $name, 'container_port' => $container_port];
+ continue;
+ }
+ if ($protocol !== 'tcp' || !$bindings) continue;
+ foreach ($bindings as $binding) {
+ if (!empty($binding['HostPort'])) {
+ $key = "$name:{$binding['HostPort']}:$container_port";
+ if (!isset($endpoint_keys[$key])) {
+ $endpoint_keys[$key] = true;
+ $endpoints[] = [
+ 'container' => $name,
+ 'mode' => $mode,
+ 'container_port' => $container_port,
+ 'host_port' => (int)$binding['HostPort'],
+ ];
+ }
+ $found_tcp = true;
+ }
+ }
+ }
+ if ($mode !== 'host' && !$found_tcp) {
+ $no_tcp[] = ['container' => $name, 'mode' => $mode];
+ }
+ }
+
+ // Probes
+ $classifications = ['dualstack' => 0, 'ipv4-only' => 0, 'ipv6-only' => 0, 'unreachable' => 0];
+ foreach ($endpoints as &$ep) {
+ $v4 = tcp_probe($addrs['ipv4'], $ep['host_port'], AF_INET, $timeout);
+ $v6 = tcp_probe($addrs['ipv6'], $ep['host_port'], AF_INET6, $timeout);
+ $ep['ipv4_reachable'] = $v4['reachable'];
+ $ep['ipv6_reachable'] = $v6['reachable'];
+ $ep['classification'] = ($v4['reachable'] && $v6['reachable']) ? 'dualstack'
+ : ($v4['reachable'] ? 'ipv4-only' : ($v6['reachable'] ? 'ipv6-only' : 'unreachable'));
+ $classifications[$ep['classification']]++;
+ }
+ unset($ep);
+
+ $issue_endpoints = array_filter($endpoints, function ($e) {
+ return $e['classification'] !== 'dualstack';
+ });
+
+ $result = [
+ 'schema_version' => '2.0',
+ 'targets' => ['ipv4' => $addrs['ipv4'], 'lan_ipv6' => $addrs['ipv6']],
+ 'counts' => [
+ 'containers_total' => count($containers),
+ 'tcp_endpoints_total' => count($endpoints),
+ 'tcp_dualstack' => $classifications['dualstack'],
+ 'tcp_ipv4_only' => $classifications['ipv4-only'],
+ 'tcp_ipv6_only' => $classifications['ipv6-only'],
+ 'tcp_unreachable' => $classifications['unreachable'],
+ 'tcp_problem_endpoints' => count($issue_endpoints),
+ ],
+ 'problem_endpoints_only' => array_values($issue_endpoints),
+ 'inactive_containers' => $inactive,
+ 'running_without_published_tcp' => $no_tcp,
+ 'task_complete' => true,
+ ];
+ if ($include_all_endpoints) {
+ $result['all_tcp_endpoints'] = $endpoints;
+ }
+ return json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+}
diff --git a/mcp/selftest.php b/mcp/selftest.php
new file mode 100644
index 0000000..b398d00
--- /dev/null
+++ b/mcp/selftest.php
@@ -0,0 +1,119 @@
+ $name, "detail" => $detail];
+ echo " ✓ $name" . ($detail ? " ($detail)" : '') . "\n";
+ } else {
+ $failed++;
+ $results[] = ["FAIL" => $name, "detail" => $detail];
+ echo " ✗ $name" . ($detail ? " ($detail)" : '') . "\n";
+ }
+}
+
+echo "=== MUA Selftest ===\n\n";
+
+// ── 1. Tools-Definition ──────────────────────────────────────────────────
+echo "[1] Tools-Definition\n";
+$tools = mua_tools();
+check('21 Tools definiert', count($tools) === 21, count($tools) . ' Tools');
+
+$names = array_column($tools, 'name');
+$expected = [
+ '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',
+ 'unraid_network_inventory', 'unraid_network_list', 'unraid_network_inspect',
+ 'unraid_network_host_state', 'unraid_network_audit_tcp', 'unraid_network_lan_probe',
+ 'unraid_system_connection_test',
+];
+$missing = array_diff($expected, $names);
+check('Alle erwarteten Tools vorhanden', empty($missing), empty($missing) ? '' : 'Fehlt: ' . implode(', ', $missing));
+
+// ── 2. Docker-Verbindung ─────────────────────────────────────────────────
+echo "\n[2] Docker-Verbindung\n";
+try {
+ $info = docker_exec("info --format '{{.ServerVersion}}'");
+ check('Docker erreichbar', $info !== '', 'Version: ' . trim($info));
+} catch (Exception $e) {
+ check('Docker erreichbar', false, $e->getMessage());
+}
+
+// ── 3. Container-Liste ───────────────────────────────────────────────────
+echo "\n[3] Container-Liste\n";
+try {
+ $summary = container_runtime_summary();
+ $decoded = json_decode($summary, true);
+ check('container_runtime_summary() funktioniert', is_array($decoded) && isset($decoded['container_count']));
+ check('Container-Count > 0', ($decoded['container_count'] ?? 0) > 0, $decoded['container_count'] . ' Container');
+} catch (Exception $e) {
+ check('container_runtime_summary() funktioniert', false, $e->getMessage());
+}
+
+// ── 4. Netzwerk-Inventur ─────────────────────────────────────────────────
+echo "\n[4] Netzwerk-Inventur\n";
+try {
+ $inventory = compact_network_inventory([]);
+ $decoded = json_decode($inventory, true);
+ check('compact_network_inventory() funktioniert', is_array($decoded) && isset($decoded['network_count']));
+ check('Netzwerk-Count > 0', ($decoded['network_count'] ?? 0) > 0, $decoded['network_count'] . ' Netzwerke');
+} catch (Exception $e) {
+ check('compact_network_inventory() funktioniert', false, $e->getMessage());
+}
+
+// ── 5. Host-Adressen ─────────────────────────────────────────────────────
+echo "\n[5] Host-Adressen\n";
+try {
+ $raw = run_local('test', "ip -j address show");
+ $addrs = host_addresses($raw);
+ check('IPv4-Adresse gefunden', $addrs['ipv4'] !== '', $addrs['ipv4']);
+ check('IPv6-Adresse gefunden', $addrs['ipv6'] !== '', $addrs['ipv6']);
+} catch (Exception $e) {
+ check('Host-Adressen', false, $e->getMessage());
+}
+
+// ── 6. Validate-Name ─────────────────────────────────────────────────────
+echo "\n[6] Validate-Name\n";
+try {
+ validate_name('test-container', 'container');
+ check('Gültiger Name akzeptiert', true);
+} catch (Exception $e) {
+ check('Gültiger Name akzeptiert', false, $e->getMessage());
+}
+try {
+ validate_name('bad;name', 'container');
+ check('Ungültiger Name abgelehnt', false, 'Sollte Exception werfen');
+} catch (Exception $e) {
+ check('Ungültiger Name abgelehnt', true);
+}
+
+// ── 7. PHP-Helper vorhanden ──────────────────────────────────────────────
+echo "\n[7] PHP-Helper\n";
+$helper = '/usr/local/bin/unraid-docker-mcp-helper.php';
+check('PHP-Helper vorhanden', file_exists($helper), $helper);
+
+// ── Zusammenfassung ──────────────────────────────────────────────────────
+echo "\n=== Zusammenfassung ===\n";
+echo " Bestanden: $passed\n";
+echo " Fehlgeschlagen: $failed\n";
+echo " Gesamt: " . ($passed + $failed) . "\n";
+
+exit($failed > 0 ? 1 : 0);
diff --git a/mcp/server.php b/mcp/server.php
new file mode 100644
index 0000000..1518f09
--- /dev/null
+++ b/mcp/server.php
@@ -0,0 +1,378 @@
+ ['created' => time, 'initialized' => bool]
+
+function session_create(): string {
+ global $sessions;
+ $id = bin2hex(random_bytes(16));
+ $sessions[$id] = ['created' => time(), 'initialized' => false];
+ return $id;
+}
+
+function session_get(?string $id): ?array {
+ global $sessions;
+ if ($id === null || !isset($sessions[$id])) return null;
+ // TTL-Check
+ if (time() - $sessions[$id]['created'] > MUA_SESSION_TTL) {
+ unset($sessions[$id]);
+ return null;
+ }
+ return $sessions[$id];
+}
+
+function session_delete(?string $id): void {
+ global $sessions;
+ if ($id !== null) unset($sessions[$id]);
+}
+
+// ── HTTP-Response-Helfer ─────────────────────────────────────────────────
+function http_response(int $code, string $body, array $headers = []): void {
+ http_response_code($code);
+ foreach ($headers as $k => $v) {
+ header("$k: $v");
+ }
+ echo $body;
+ exit;
+}
+
+function json_response(int $code, array $data, array $extra_headers = []): void {
+ $headers = ['Content-Type' => 'application/json'];
+ $headers = array_merge($headers, $extra_headers);
+ http_response($code, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), $headers);
+}
+
+function sse_response(array $data, array $extra_headers = []): void {
+ $headers = [
+ 'Content-Type' => 'text/event-stream',
+ 'Cache-Control' => 'no-cache',
+ 'Connection' => 'keep-alive',
+ ];
+ $headers = array_merge($headers, $extra_headers);
+ http_response(200, "data: " . json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n\n", $headers);
+}
+
+// ── JSON-RPC-Response ────────────────────────────────────────────────────
+function rpc_result($id, $result): array {
+ return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
+}
+
+function rpc_error($id, int $code, string $message, $data = null): array {
+ $err = ['code' => $code, 'message' => $message];
+ if ($data !== null) $err['data'] = $data;
+ return ['jsonrpc' => '2.0', 'id' => $id, 'error' => $err];
+}
+
+// ── Tool-Dispatch ────────────────────────────────────────────────────────
+function call_tool(string $name, array $args): string {
+ switch ($name) {
+ // ── Docker ──────────────────────────────────────────────────────
+ case 'unraid_docker_list':
+ return container_runtime_summary();
+
+ case 'unraid_docker_inspect':
+ $container = validate_name($args['container'] ?? null, 'container');
+ $raw = docker_exec("inspect --type container -- $container");
+ return compact_container_inspect($raw);
+
+ case 'unraid_docker_logs':
+ $container = validate_name($args['container'] ?? null, 'container');
+ $tail = (int)($args['tail'] ?? 200);
+ if ($tail < 1 || $tail > 2000) throw new InvalidArgumentException('tail must be between 1 and 2000');
+ $raw = docker_exec("logs --timestamps --tail $tail $container");
+ return sanitize_log_output($raw);
+
+ case 'unraid_docker_analyze_logs':
+ $severity = $args['severity'] ?? 'error';
+ $container = $args['container'] ?? null;
+ if ($container !== null && !is_string($container)) throw new InvalidArgumentException('container must be a string');
+ $since = $args['since'] ?? '24h';
+ $scan_tail = (int)($args['scan_tail'] ?? 1000);
+ $max_results = (int)($args['max_results'] ?? 50);
+ return analyze_container_logs($severity, $container, $since, $scan_tail, $max_results);
+
+ case 'unraid_docker_processes':
+ $container = validate_name($args['container'] ?? null, 'container');
+ return docker_exec("top $container -eo pid,ppid,user,stat,lstart,etime,args");
+
+ case 'unraid_docker_stats':
+ return docker_exec("stats --no-stream --format '{{json .}}'");
+
+ case 'unraid_docker_info':
+ return docker_exec("info --format '{{json .}}'");
+
+ case 'unraid_docker_start':
+ $container = validate_name($args['container'] ?? null, 'container');
+ return docker_exec("start $container");
+
+ case 'unraid_docker_stop':
+ $container = validate_name($args['container'] ?? null, 'container');
+ return docker_exec("stop $container");
+
+ case 'unraid_docker_restart':
+ $container = validate_name($args['container'] ?? null, 'container');
+ return docker_exec("restart $container");
+
+ case 'unraid_docker_create':
+ $template = validate_name($args['template_name'] ?? null, 'template_name');
+ return run_php_helper('create', $template);
+
+ case 'unraid_docker_modify':
+ $container = validate_name($args['container'] ?? null, 'container');
+ $field = $args['field'] ?? '';
+ $value = $args['value'] ?? '';
+ if (!in_array($field, ['port', 'env', 'volume', 'network', 'privileged'])) {
+ throw new InvalidArgumentException('field must be one of: port, env, volume, network, privileged');
+ }
+ return run_php_helper('modify', $container, $field, $value);
+
+ case 'unraid_docker_update':
+ $container = validate_name($args['container'] ?? null, 'container');
+ return run_php_helper('update', $container);
+
+ case 'unraid_docker_rebuild':
+ $container = validate_name($args['container'] ?? null, 'container');
+ return run_php_helper('rebuild', $container);
+
+ // ── Netzwerk ────────────────────────────────────────────────────
+ case 'unraid_network_inventory':
+ return compact_network_inventory($args);
+
+ case 'unraid_network_list':
+ return docker_exec("network ls --no-trunc --format '{{json .}}'");
+
+ case 'unraid_network_inspect':
+ $network = validate_name($args['network'] ?? null, 'network');
+ return docker_exec("network inspect -- $network");
+
+ case 'unraid_network_host_state':
+ return run_local('host_state', "printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- IPv4 routes ---'; ip -j -4 route show; printf '%s\\n' '--- IPv6 routes ---'; ip -j -6 route show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup");
+
+ case 'unraid_network_audit_tcp':
+ $timeout = (float)($args['timeout_seconds'] ?? 2);
+ if ($timeout < 0.2 || $timeout > 10) throw new InvalidArgumentException('timeout_seconds must be between 0.2 and 10');
+ $include_all = (bool)($args['include_all_endpoints'] ?? false);
+ return audit_all_tcp_endpoints($timeout, $include_all);
+
+ case 'unraid_network_lan_probe':
+ $host = validate_name($args['host'] ?? null, 'host');
+ $port = (int)($args['port'] ?? 0);
+ $timeout = (float)($args['timeout_seconds'] ?? 3);
+ if ($port < 1 || $port > 65535 || $timeout < 0.2 || $timeout > 10) {
+ throw new InvalidArgumentException('Invalid port or timeout');
+ }
+ return probe_dualstack($host, $port, $timeout);
+
+ // ── System ──────────────────────────────────────────────────────
+ case 'unraid_system_connection_test':
+ return run_local('connection_test', "id; printf 'hostname='; hostname; printf 'kernel='; uname -sr; printf 'unraid='; cat /etc/unraid-version");
+
+ default:
+ throw new InvalidArgumentException("Unknown tool: $name");
+ }
+}
+
+/**
+ * PHP-Helper für Write-Operationen aufrufen.
+ * Der Helper liegt in /usr/local/bin/unraid-docker-mcp-helper.php
+ * (wird vom Plugin installiert).
+ */
+function run_php_helper(string $action, ...$args): string {
+ $helper = '/usr/local/bin/unraid-docker-mcp-helper.php';
+ if (!file_exists($helper)) {
+ throw new RuntimeException("PHP helper not found: $helper");
+ }
+ $cmd = escapeshellarg('/usr/bin/php') . ' ' . escapeshellarg($helper) . ' ' . escapeshellarg($action);
+ foreach ($args as $arg) {
+ $cmd .= ' ' . escapeshellarg($arg);
+ }
+ return run_local("php_helper:$action", $cmd, 300);
+}
+
+// ── MCP-Methoden-Handler ─────────────────────────────────────────────────
+function handle_mcp_request(array $message, ?string $session_id): array {
+ global $_SERVER;
+ $method = $message['method'] ?? '';
+ $id = $message['id'] ?? null;
+ $params = $message['params'] ?? [];
+
+ // ── initialize ──────────────────────────────────────────────────────
+ if ($method === 'initialize') {
+ $protocol_version = $params['protocolVersion'] ?? '2024-11-05';
+ // Session erstellen (oder bestehende übernehmen)
+ if ($session_id === null) {
+ $session_id = session_create();
+ } else {
+ // Bestehende Session als initialisiert markieren
+ global $sessions;
+ if (isset($sessions[$session_id])) {
+ $sessions[$session_id]['initialized'] = true;
+ } else {
+ $session_id = session_create();
+ }
+ }
+ // Session-ID muss im Response-Header zurückgegeben werden
+ $_SERVER['MUA_SESSION_ID'] = $session_id;
+ return rpc_result($id, [
+ 'protocolVersion' => $protocol_version,
+ 'capabilities' => ['tools' => ['listChanged' => false]],
+ 'serverInfo' => ['name' => MUA_SERVER_NAME, 'version' => MUA_VERSION],
+ ]);
+ }
+
+ // ── notifications/initialized (kein Response nötig) ─────────────────
+ if ($method === 'notifications/initialized') {
+ return null; // Notification, kein Response
+ }
+
+ // ── Session-Check für alle anderen Methoden ─────────────────────────
+ if ($session_id === null) {
+ return rpc_error($id, -32000, 'Missing Mcp-Session-Id header');
+ }
+ $session = session_get($session_id);
+ if ($session === null) {
+ return rpc_error($id, -32000, 'Invalid or expired session');
+ }
+ if (!$session['initialized'] && $method !== 'initialize') {
+ return rpc_error($id, -32000, 'Not initialized: send initialize first');
+ }
+
+ // ── tools/list ──────────────────────────────────────────────────────
+ if ($method === 'tools/list') {
+ return rpc_result($id, ['tools' => mua_tools()]);
+ }
+
+ // ── tools/call ──────────────────────────────────────────────────────
+ if ($method === 'tools/call') {
+ $tool_name = $params['name'] ?? '';
+ $tool_args = $params['arguments'] ?? [];
+ try {
+ $text = call_tool($tool_name, $tool_args);
+ $result = [
+ 'content' => [['type' => 'text', 'text' => $text]],
+ 'isError' => false,
+ ];
+ // structuredContent für JSON-Tools
+ if (in_array($tool_name, [
+ 'unraid_docker_list', 'unraid_network_inventory',
+ 'unraid_docker_analyze_logs', 'unraid_network_audit_tcp',
+ ])) {
+ $decoded = json_decode($text, true);
+ if (json_last_error() === JSON_ERROR_NONE) {
+ $result['structuredContent'] = $decoded;
+ }
+ }
+ return rpc_result($id, $result);
+ } catch (Exception $e) {
+ return rpc_result($id, [
+ 'content' => [['type' => 'text', 'text' => 'ERROR: ' . $e->getMessage()]],
+ 'isError' => true,
+ ]);
+ }
+ }
+
+ // ── ping ────────────────────────────────────────────────────────────
+ if ($method === 'ping') {
+ return rpc_result($id, new stdClass());
+ }
+
+ // ── Unknown method ──────────────────────────────────────────────────
+ if ($id !== null) {
+ return rpc_error($id, -32601, "Method not found: $method");
+ }
+ return null;
+}
+
+// ── HTTP-Router ──────────────────────────────────────────────────────────
+$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
+$session_header = $_SERVER['HTTP_MCP_SESSION_ID'] ?? null;
+
+// CORS für lokale Nutzung
+header('Access-Control-Allow-Origin: *');
+header('Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS');
+header('Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id');
+
+if ($method === 'OPTIONS') {
+ http_response(204, '');
+}
+
+// ── Health-Check ─────────────────────────────────────────────────────────
+if ($path === '/health') {
+ json_response(200, [
+ 'status' => 'ok',
+ 'server' => MUA_SERVER_NAME,
+ 'version' => MUA_VERSION,
+ 'port' => MUA_PORT,
+ 'time' => date('c'),
+ ]);
+}
+
+// ── MCP-Endpunkt ─────────────────────────────────────────────────────────
+if ($path === '/mcp' || $path === '/') {
+
+ // ── POST: JSON-RPC Request ──────────────────────────────────────────
+ if ($method === 'POST') {
+ $body = file_get_contents('php://input');
+ $message = json_decode($body, true);
+ if (!is_array($message)) {
+ json_response(400, rpc_error(null, -32700, 'Parse error'));
+ }
+
+ $response = handle_mcp_request($message, $session_header);
+
+ // Notification (kein Response nötig)
+ if ($response === null) {
+ http_response(202, '', [
+ 'Mcp-Session-Id' => $_SERVER['MUA_SESSION_ID'] ?? ($session_header ?? ''),
+ ]);
+ }
+
+ $headers = [];
+ if (!empty($_SERVER['MUA_SESSION_ID'])) {
+ $headers['Mcp-Session-Id'] = $_SERVER['MUA_SESSION_ID'];
+ }
+ json_response(200, $response, $headers);
+ }
+
+ // ── GET: SSE-Stream (nicht implementiert) ───────────────────────────
+ // Laut MCP-Streamable-HTTP-Spec ist die SSE-GET-Endpunkt optional.
+ // Wir nutzen POST-only (request/response). 405 = spec-konform.
+ if ($method === 'GET') {
+ http_response(405, json_encode(['error' => 'SSE not supported. Use POST /mcp for JSON-RPC requests.']), [
+ 'Content-Type' => 'application/json',
+ ]);
+ }
+
+ // ── DELETE: Session-End ─────────────────────────────────────────────
+ if ($method === 'DELETE') {
+ session_delete($session_header);
+ http_response(200, '');
+ }
+}
+
+// ── 404 ──────────────────────────────────────────────────────────────────
+json_response(404, ['error' => 'Not found. Use /mcp or /health']);
diff --git a/mcp/tools.php b/mcp/tools.php
new file mode 100644
index 0000000..f3979f2
--- /dev/null
+++ b/mcp/tools.php
@@ -0,0 +1,218 @@
+_
+ * Kategorien: docker (14), network (6), system (1)
+ *
+ * Identisch zu /opt/mike-ai/unraid-agent/unraid_mcp.py (Python).
+ */
+
+function mua_tools(): array {
+ return [
+ // ── Docker (14) ──────────────────────────────────────────────────
+ [
+ 'name' => 'unraid_docker_list',
+ 'description' => 'List all Docker containers with runtime stats (CPU, memory, network I/O). Returns a compact JSON summary.',
+ 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false],
+ ],
+ [
+ 'name' => 'unraid_docker_inspect',
+ 'description' => 'Inspect a single Docker container in detail (state, image, ports, env, mounts).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container name or ID'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_logs',
+ 'description' => 'Get recent logs from a Docker container (with timestamps).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container name or ID'],
+ 'tail' => ['type' => 'integer', 'description' => 'Number of lines (1-2000, default 200)'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_analyze_logs',
+ 'description' => 'Analyze container logs server-side for errors/warnings. Returns pattern counts and sample matches.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'severity' => ['type' => 'string', 'enum' => ['error', 'warn', 'info'], 'description' => 'Log severity to scan for'],
+ 'container' => ['type' => 'string', 'description' => 'Container name (optional, scans all if omitted)'],
+ 'since' => ['type' => 'string', 'description' => 'Time filter (default 24h)'],
+ 'scan_tail' => ['type' => 'integer', 'description' => 'Max lines to scan (default 1000)'],
+ 'max_results' => ['type' => 'integer', 'description' => 'Max sample matches (default 50)'],
+ ],
+ 'required' => ['severity'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_processes',
+ 'description' => 'List processes running inside a Docker container (docker top).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container name or ID'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_stats',
+ 'description' => 'Get live CPU/memory/network/block I/O stats for all running containers.',
+ 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false],
+ ],
+ [
+ 'name' => 'unraid_docker_info',
+ 'description' => 'Get Docker daemon information (version, storage driver, container counts, etc.).',
+ 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false],
+ ],
+ [
+ 'name' => 'unraid_docker_start',
+ 'description' => 'Start a Docker container.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container name or ID'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_stop',
+ 'description' => 'Stop a Docker container.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container name or ID'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_restart',
+ 'description' => 'Restart a Docker container.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container name or ID'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_create',
+ 'description' => 'Create a Docker container from a template (pulls image, creates container).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'template_name' => ['type' => 'string', 'description' => 'Template name (e.g. "linuxserver/sonarr")'],
+ ],
+ 'required' => ['template_name'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_modify',
+ 'description' => 'Modify a container template (port, env, volume, network, privileged) and rebuild.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container/template name'],
+ 'field' => ['type' => 'string', 'enum' => ['port', 'env', 'volume', 'network', 'privileged']],
+ 'value' => ['type' => 'string', 'description' => 'New value (format depends on field)'],
+ ],
+ 'required' => ['container', 'field', 'value'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_update',
+ 'description' => 'Update a container (pull latest image, rebuild).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container/template name'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_docker_rebuild',
+ 'description' => 'Rebuild a container from its template (without pulling new image).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'container' => ['type' => 'string', 'description' => 'Container/template name'],
+ ],
+ 'required' => ['container'],
+ ],
+ ],
+
+ // ── Netzwerk (6) ─────────────────────────────────────────────────
+ [
+ 'name' => 'unraid_network_inventory',
+ 'description' => 'Compact Docker network inventory (all networks with container counts).',
+ 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false],
+ ],
+ [
+ 'name' => 'unraid_network_list',
+ 'description' => 'List all Docker networks.',
+ 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false],
+ ],
+ [
+ 'name' => 'unraid_network_inspect',
+ 'description' => 'Inspect a Docker network in detail.',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'network' => ['type' => 'string', 'description' => 'Network name or ID'],
+ ],
+ 'required' => ['network'],
+ ],
+ ],
+ [
+ 'name' => 'unraid_network_host_state',
+ 'description' => 'Get host network state (IPv4/IPv6 addresses, routes, listening sockets).',
+ 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false],
+ ],
+ [
+ 'name' => 'unraid_network_audit_tcp',
+ 'description' => 'Audit all TCP endpoints: probe IPv4/IPv6 reachability for every published port. Returns classification (dualstack/ipv4-only/ipv6-only/unreachable).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'timeout_seconds' => ['type' => 'number', 'description' => 'Probe timeout (0.2-10, default 2)'],
+ 'include_all_endpoints' => ['type' => 'boolean', 'description' => 'Include all endpoints (default false, only problems)'],
+ ],
+ ],
+ ],
+ [
+ 'name' => 'unraid_network_lan_probe',
+ 'description' => 'Probe a specific host:port for IPv4 and IPv6 reachability (dualstack test).',
+ 'inputSchema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'host' => ['type' => 'string', 'description' => 'Hostname or IP'],
+ 'port' => ['type' => 'integer', 'description' => 'Port (1-65535)'],
+ 'timeout_seconds' => ['type' => 'number', 'description' => 'Timeout (0.2-10, default 3)'],
+ ],
+ 'required' => ['host', 'port'],
+ ],
+ ],
+
+ // ── System (1) ───────────────────────────────────────────────────
+ [
+ 'name' => 'unraid_system_connection_test',
+ 'description' => 'Test connection to the Unraid host (hostname, kernel, Unraid version).',
+ 'inputSchema' => ['type' => 'object', 'properties' => new stdClass(), 'additionalProperties' => false],
+ ],
+ ];
+}
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100644
index 0000000..b0dbc41
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# MUA - Mikes Unraid Agent
+# Install-Script
+# Wird vom Unraid-Plugin-Installer aufgerufen.
+
+set -e
+
+PLUGIN_DIR="/usr/local/plugins/mua"
+SERVICE_FILE="$PLUGIN_DIR/scripts/mua.service"
+HELPER_SRC="$PLUGIN_DIR/scripts/unraid-docker-mcp-helper.php"
+HELPER_DST="/usr/local/bin/unraid-docker-mcp-helper.php"
+
+echo "=== MUA Install ==="
+
+# 1. Verzeichnisse sicherstellen
+mkdir -p "$PLUGIN_DIR/mcp"
+mkdir -p "$PLUGIN_DIR/scripts"
+
+# 2. PHP-Helper installieren (Write-Operationen)
+if [ -f "$HELPER_SRC" ]; then
+ cp "$HELPER_SRC" "$HELPER_DST"
+ chmod 755 "$HELPER_DST"
+ echo " Helper installiert: $HELPER_DST"
+fi
+
+# 3. systemd-Service installieren
+if [ -f "$SERVICE_FILE" ]; then
+ cp "$SERVICE_FILE" /etc/systemd/system/mua.service
+ chmod 644 /etc/systemd/system/mua.service
+ systemctl daemon-reload
+ echo " Service installiert: mua.service"
+fi
+
+# 4. Service starten + enable
+systemctl enable mua.service
+systemctl restart mua.service
+
+# 5. Health-Check
+sleep 2
+if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then
+ echo " Health-Check: OK"
+ echo "=== MUA installiert und läuft auf Port 3002 ==="
+ echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp"
+else
+ echo " WARNUNG: Health-Check fehlgeschlagen"
+ echo " Service-Status:"
+ systemctl status mua.service --no-pager || true
+ exit 1
+fi
diff --git a/scripts/mua.service b/scripts/mua.service
new file mode 100644
index 0000000..ea91023
--- /dev/null
+++ b/scripts/mua.service
@@ -0,0 +1,20 @@
+[Unit]
+Description=MUA - Mikes Unraid Agent (MCP Server)
+After=network.target docker.service
+Wants=docker.service
+
+[Service]
+Type=simple
+# PHP Built-in Server auf Port 3002
+ExecStart=/usr/bin/php -S 0.0.0.0:3002 /usr/local/plugins/mua/mcp/server.php
+Restart=on-failure
+RestartSec=5
+# Arbeitsverzeichnis
+WorkingDirectory=/usr/local/plugins/mua
+# Sicherheits-Härtung
+NoNewPrivileges=true
+ProtectHome=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target
diff --git a/scripts/remove.sh b/scripts/remove.sh
new file mode 100644
index 0000000..9ac80fd
--- /dev/null
+++ b/scripts/remove.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+# MUA - Mikes Unraid Agent
+# Remove-Script
+# Wird vom Unraid-Plugin-Installer bei Deinstallation aufgerufen.
+
+set -e
+
+echo "=== MUA Remove ==="
+
+# 1. Service stoppen + disable
+if systemctl list-unit-files | grep -q "^mua.service"; then
+ systemctl stop mua.service 2>/dev/null || true
+ systemctl disable mua.service 2>/dev/null || true
+ rm -f /etc/systemd/system/mua.service
+ systemctl daemon-reload
+ echo " Service entfernt"
+fi
+
+# 2. PHP-Helper entfernen
+if [ -f "/usr/local/bin/unraid-docker-mcp-helper.php" ]; then
+ rm -f /usr/local/bin/unraid-docker-mcp-helper.php
+ echo " Helper entfernt"
+fi
+
+# 3. Plugin-Verzeichnis entfernen
+if [ -d "/usr/local/plugins/mua" ]; then
+ rm -rf /usr/local/plugins/mua
+ echo " Plugin-Verzeichnis entfernt"
+fi
+
+echo "=== MUA entfernt ==="
diff --git a/scripts/unraid-docker-mcp-helper.php b/scripts/unraid-docker-mcp-helper.php
new file mode 100644
index 0000000..ab12902
--- /dev/null
+++ b/scripts/unraid-docker-mcp-helper.php
@@ -0,0 +1,278 @@
+
+ *
+ * Actions:
+ * create
+ * modify
+ * update
+ * rebuild
+ *
+ * modify fields:
+ * port value: newExternalPort (z.B. "8466")
+ * oder newExternalPort:internalPort (z.B. "8466:8465")
+ * env value: VAR_NAME=VAR_VALUE (z.B. "TZ=Europe/Berlin")
+ * volume value: hostPath:containerPath (z.B. "/mnt/user/data:/data")
+ * network value: bridge|host|none
+ * privileged value: true|false
+ */
+
+error_reporting(E_ALL);
+ini_set('display_errors', 1);
+
+// ── Unraid Setup (aus update_container) ──────────────────────────────────
+$docroot = '/usr/local/emhttp';
+require_once "$docroot/webGui/include/Wrappers.php";
+extract(parse_plugin_cfg('dynamix', true));
+
+$_SERVER['REQUEST_URI'] = '';
+$login_locale = _var($display, 'locale');
+require_once "$docroot/plugins/dynamix.docker.manager/include/DockerClient.php";
+
+$var = parse_ini_file('/var/local/emhttp/var.ini');
+$DockerClient = new DockerClient();
+$DockerUpdate = new DockerUpdate();
+$DockerTemplates = new DockerTemplates();
+
+$custom = DockerUtil::custom();
+$subnet = DockerUtil::network($custom);
+$cpus = DockerUtil::cpus();
+
+// ── Helper-Funktionen ────────────────────────────────────────────────────
+function out(string $msg): void {
+ echo $msg . "\n";
+}
+
+function fail(string $msg): never {
+ out("ERROR: " . $msg);
+ exit(1);
+}
+
+function docker_exec(string $cmd): string {
+ $proc = popen("/usr/bin/docker $cmd 2>&1", 'r');
+ $output = stream_get_contents($proc);
+ pclose($proc);
+ return trim($output);
+}
+
+function get_template_path(string $name): string {
+ $dir = '/boot/config/plugins/dockerMan/templates-user';
+ $file = "$dir/my-$name.xml";
+ if (!file_exists($file)) {
+ fail("Template not found: $file");
+ }
+ return $file;
+}
+
+function rebuild_container(string $name, bool $pull_image = false, bool $force_start = false): void {
+ global $DockerClient;
+ $tmpl = get_template_path($name);
+ $xml = file_get_contents($tmpl);
+ [$cmd, $Name, $Repository] = xmlToCommand($tmpl);
+
+ // Pull image if requested
+ if ($pull_image) {
+ out("Pulling image: $Repository");
+ $pull_out = docker_exec("pull $Repository");
+ if (strpos($pull_out, 'Error') !== false || strpos($pull_out, 'error') !== false) {
+ fail("Image pull failed: $pull_out");
+ }
+ out("Image pulled: $Repository");
+ }
+
+ // Check if container is running
+ $oldContainerInfo = $DockerClient->getContainerDetails($Name);
+ $startContainer = $force_start;
+ if (!empty($oldContainerInfo) && !empty($oldContainerInfo['State']) && !empty($oldContainerInfo['State']['Running'])) {
+ $startContainer = true;
+ out("Stopping container: $Name");
+ $DockerClient->stopContainer($Name);
+ }
+
+ // Convert create to run if we need to start
+ if ($startContainer) {
+ $cmd = str_replace('/docker create ', '/docker run -d ', $cmd);
+ }
+
+ // Remove old container
+ out("Removing old container: $Name");
+ $DockerClient->removeContainer($Name);
+
+ // Execute the docker command
+ out("Creating container: $Name");
+ $proc = popen("$cmd 2>&1", 'r');
+ $output = stream_get_contents($proc);
+ $rc = pclose($proc);
+ if ($rc !== 0) {
+ fail("Container creation failed (exit $rc): $output");
+ }
+ out("Container created: $Name");
+
+ // Flush caches
+ $DockerClient->flushCaches();
+ out("Done: $Name");
+}
+
+/**
+ * Set the text content of a DOM element.
+ * This is the actual value Unraid uses (not the Default attribute).
+ */
+function set_config_value(DOMElement $config, string $value): void {
+ // Remove existing text children
+ while ($config->firstChild) {
+ $config->removeChild($config->firstChild);
+ }
+ $config->appendChild(new DOMText($value));
+ // Also update Default attribute for consistency
+ $config->setAttribute('Default', $value);
+}
+
+function modify_template(string $name, string $field, string $value): void {
+ $tmpl = get_template_path($name);
+ $xml = file_get_contents($tmpl);
+ $dom = new DOMDocument();
+ $dom->loadXML($xml);
+
+ switch ($field) {
+ case 'port':
+ // value: newExternalPort or newExternalPort:internalPort
+ $parts = explode(':', $value, 2);
+ $newExternal = $parts[0];
+ $newInternal = $parts[1] ?? null;
+
+ $found = false;
+ foreach ($dom->getElementsByTagName('Config') as $config) {
+ if (strcasecmp($config->getAttribute('Type'), 'Port') === 0) {
+ if ($newInternal !== null) {
+ // Match by internal port (Target attribute)
+ if ($config->getAttribute('Target') === $newInternal) {
+ $config->setAttribute('Target', $newInternal);
+ set_config_value($config, $newExternal);
+ $found = true;
+ break;
+ }
+ } else {
+ // No internal port specified — change first Port config
+ set_config_value($config, $newExternal);
+ $found = true;
+ break;
+ }
+ }
+ }
+ if (!$found) {
+ fail("Port config not found in template" . ($newInternal !== null ? " (Target=$newInternal)" : ""));
+ }
+ break;
+
+ case 'env':
+ // value: VAR_NAME=VAR_VALUE
+ $eqPos = strpos($value, '=');
+ if ($eqPos === false) {
+ fail("Env value must be VAR_NAME=VAR_VALUE, got: $value");
+ }
+ $varName = substr($value, 0, $eqPos);
+ $varValue = substr($value, $eqPos + 1);
+
+ $found = false;
+ foreach ($dom->getElementsByTagName('Config') as $config) {
+ if (strcasecmp($config->getAttribute('Type'), 'Variable') === 0) {
+ if ($config->getAttribute('Target') === $varName) {
+ set_config_value($config, $varValue);
+ $found = true;
+ break;
+ }
+ }
+ }
+ if (!$found) {
+ fail("Env var $varName not found in template");
+ }
+ break;
+
+ case 'volume':
+ // value: hostPath:containerPath
+ $colonPos = strpos($value, ':');
+ if ($colonPos === false) {
+ fail("Volume value must be hostPath:containerPath, got: $value");
+ }
+ $hostPath = substr($value, 0, $colonPos);
+ $containerPath = substr($value, $colonPos + 1);
+
+ $found = false;
+ foreach ($dom->getElementsByTagName('Config') as $config) {
+ if (strcasecmp($config->getAttribute('Type'), 'Path') === 0) {
+ if ($config->getAttribute('Target') === $containerPath) {
+ set_config_value($config, $hostPath);
+ $found = true;
+ break;
+ }
+ }
+ }
+ if (!$found) {
+ fail("Volume target $containerPath not found in template");
+ }
+ break;
+
+ case 'network':
+ $networks = $dom->getElementsByTagName('Network');
+ if ($networks->length === 0) {
+ fail("No Network element in template");
+ }
+ $networks->item(0)->nodeValue = $value;
+ break;
+
+ case 'privileged':
+ $privs = $dom->getElementsByTagName('Privileged');
+ if ($privs->length === 0) {
+ fail("No Privileged element in template");
+ }
+ $privs->item(0)->nodeValue = $value;
+ break;
+
+ default:
+ fail("Unknown field: $field (use: port|env|volume|network|privileged)");
+ }
+
+ // Write modified template
+ $dom->save($tmpl);
+ out("Template modified: $name ($field = $value)");
+
+ // Rebuild container (force start so it's running after modify)
+ rebuild_container($name, false, true);
+}
+
+// ── Main ─────────────────────────────────────────────────────────────────
+$argv = $_SERVER['argv'];
+$action = $argv[1] ?? '';
+$arg1 = $argv[2] ?? '';
+$arg2 = $argv[3] ?? '';
+$arg3 = $argv[4] ?? '';
+
+switch ($action) {
+ case 'create':
+ if (!$arg1) fail("Missing template name");
+ out("Creating container from template: $arg1");
+ rebuild_container($arg1, true);
+ break;
+
+ case 'modify':
+ if (!$arg1 || !$arg2 || !$arg3) fail("Usage: modify ");
+ modify_template($arg1, $arg2, $arg3);
+ break;
+
+ case 'update':
+ if (!$arg1) fail("Missing container name");
+ out("Updating container: $arg1");
+ rebuild_container($arg1, true);
+ break;
+
+ case 'rebuild':
+ if (!$arg1) fail("Missing container name");
+ out("Rebuilding container: $arg1");
+ rebuild_container($arg1, false);
+ break;
+
+ default:
+ fail("Unknown action: $action. Usage: create|modify|update|rebuild");
+}
diff --git a/scripts/update.sh b/scripts/update.sh
new file mode 100644
index 0000000..912f491
--- /dev/null
+++ b/scripts/update.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+# MUA - Mikes Unraid Agent
+# Update-Script
+# Wird vom Unraid-Plugin-Installer bei Updates aufgerufen.
+
+set -e
+
+PLUGIN_DIR="/usr/local/plugins/mua"
+SERVICE_FILE="$PLUGIN_DIR/scripts/mua.service"
+HELPER_SRC="$PLUGIN_DIR/scripts/unraid-docker-mcp-helper.php"
+HELPER_DST="/usr/local/bin/unraid-docker-mcp-helper.php"
+
+echo "=== MUA Update ==="
+
+# 1. PHP-Helper aktualisieren
+if [ -f "$HELPER_SRC" ]; then
+ cp "$HELPER_SRC" "$HELPER_DST"
+ chmod 755 "$HELPER_DST"
+ echo " Helper aktualisiert"
+fi
+
+# 2. systemd-Service aktualisieren
+if [ -f "$SERVICE_FILE" ]; then
+ cp "$SERVICE_FILE" /etc/systemd/system/mua.service
+ chmod 644 /etc/systemd/system/mua.service
+ systemctl daemon-reload
+ echo " Service aktualisiert"
+fi
+
+# 3. Service neu starten
+systemctl restart mua.service
+
+# 4. Health-Check
+sleep 2
+if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then
+ echo " Health-Check: OK"
+ echo "=== MUA aktualisiert ==="
+else
+ echo " WARNUNG: Health-Check fehlgeschlagen"
+ systemctl status mua.service --no-pager || true
+ exit 1
+fi