['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($args); case 'unraid_docker_inspect': $container = validate_name($args['container'] ?? null, 'container'); $detail = $args['detail'] ?? 'summary'; if (!in_array($detail, ['summary', 'full'], true)) { throw new InvalidArgumentException('detail must be summary or full'); } $raw = docker_exec("inspect --type container -- $container"); return compact_container_inspect($raw, $detail); 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']);