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
This commit is contained in:
+378
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* MCP Server (Streamable HTTP Transport)
|
||||
*
|
||||
* Endpunkt: POST /mcp (JSON-RPC 2.0)
|
||||
* GET /mcp (SSE-Stream, optional)
|
||||
* DELETE /mcp (Session-End)
|
||||
*
|
||||
* Spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
|
||||
*
|
||||
* Läuft lokal auf dem Unraid-Host (kein SSH).
|
||||
* Port: 3002 (3000 ist belegt von theippenguin/unraid-mcp)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
require_once __DIR__ . '/tools.php';
|
||||
|
||||
// ── Konfiguration ────────────────────────────────────────────────────────
|
||||
const MUA_PORT = 3002;
|
||||
const MUA_BIND = '0.0.0.0';
|
||||
const MUA_SESSION_TTL = 3600; // Sekunden
|
||||
const MUA_SERVER_NAME = 'mua';
|
||||
const MUA_VERSION = '1.0.0';
|
||||
|
||||
// ── Session-Management (in-memory) ───────────────────────────────────────
|
||||
$sessions = []; // session_id => ['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']);
|
||||
Reference in New Issue
Block a user