/** * MUA — Mikes Unraid Agent * index.ts — MCP over HTTP (Streamable HTTP) Server * * Portiert von mcp/server.php. Nutzt Bun.serve() statt php -S — * production-grade HTTP-Server, POST-Body wird korrekt gelesen. * * Transport: MCP Streamable HTTP (POST-only, JSON-RPC 2.0). * Endpunkte: * POST /mcp — JSON-RPC Request * GET /mcp — 405 (SSE nicht implementiert, spec-konform) * DELETE /mcp — Session-End * GET /health — Health-Check */ import { MUA_SERVER_NAME, MUA_VERSION, MUA_PROTOCOL_VERSION, } from "./helpers"; import { TOOLS, toolByName } from "./tools"; const PORT = Number(process.env["MUA_PORT"] ?? 3002); const HOST = process.env["MUA_HOST"] ?? "0.0.0.0"; // ── JSON-RPC Helpers ──────────────────────────────────────────────────── function rpcResult(id: number | string | null, result: unknown) { return { jsonrpc: "2.0", id, result }; } function rpcError(id: number | string | null, code: number, message: string) { return { jsonrpc: "2.0", id, error: { code, message } }; } // ── Session Management ────────────────────────────────────────────────── const sessions = new Map(); function newSessionId(): string { return crypto.randomUUID(); } function touchSession(id: string) { const now = Date.now(); if (sessions.has(id)) { sessions.get(id)!.lastActivity = now; } else { sessions.set(id, { createdAt: now, lastActivity: now }); } } function deleteSession(id: string | null) { if (id) sessions.delete(id); } // ── MCP Request Handler ───────────────────────────────────────────────── async function handleMcpRequest( message: Record, sessionId: string | null, ): Promise<{ response: unknown; sessionId: string } | null> { const method = (message["method"] as string) ?? ""; const id = message["id"] as number | string | null; const params = (message["params"] as Record) ?? {}; // ── initialize ──────────────────────────────────────────────────────── if (method === "initialize") { const sid = newSessionId(); touchSession(sid); return { response: rpcResult(id, { protocolVersion: MUA_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: MUA_SERVER_NAME, version: MUA_VERSION }, }), sessionId: sid, }; } // ── notifications/initialized (kein Response) ───────────────────────── if (method === "notifications/initialized") { if (sessionId) touchSession(sessionId); return null; } // ── tools/list ──────────────────────────────────────────────────────── if (method === "tools/list") { if (sessionId) touchSession(sessionId); return { response: rpcResult(id, { tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, })), }), sessionId: sessionId ?? "", }; } // ── tools/call ──────────────────────────────────────────────────────── if (method === "tools/call") { if (sessionId) touchSession(sessionId); const toolName = (params["name"] as string) ?? ""; const args = (params["arguments"] as Record) ?? {}; const tool = toolByName(toolName); if (!tool) { return { response: rpcResult(id, { content: [{ type: "text", text: `ERROR: Unknown tool: ${toolName}` }], isError: true, }), sessionId: sessionId ?? "", }; } try { const text = await tool.handler(args); const result: Record = { content: [{ type: "text", text }], }; // structuredContent für Tools mit JSON-Output if ( [ "unraid_docker_list", "unraid_network_inventory", "unraid_docker_analyze_logs", "unraid_network_audit_tcp", ].includes(toolName) ) { try { result.structuredContent = JSON.parse(text); } catch { // ignore } } return { response: rpcResult(id, result), sessionId: sessionId ?? "" }; } catch (e) { return { response: rpcResult(id, { content: [{ type: "text", text: `ERROR: ${String(e)}` }], isError: true, }), sessionId: sessionId ?? "", }; } } // ── ping ────────────────────────────────────────────────────────────── if (method === "ping") { if (sessionId) touchSession(sessionId); return { response: rpcResult(id, {}), sessionId: sessionId ?? "" }; } // ── Unknown method ──────────────────────────────────────────────────── if (id !== null && id !== undefined) { return { response: rpcError(id, -32601, `Method not found: ${method}`), sessionId: sessionId ?? "", }; } return null; } // ── HTTP Server (Bun.serve — production-grade) ────────────────────────── const server = Bun.serve({ port: PORT, hostname: HOST, idleTimeout: 120, // Sekunden (für lange docker stats / audit) async fetch(req) { const url = new URL(req.url); const path = url.pathname; const method = req.method; const sessionHeader = req.headers.get("mcp-session-id"); // CORS für lokale Nutzung const corsHeaders: Record = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Mcp-Session-Id", }; // ── OPTIONS (CORS Preflight) ──────────────────────────────────────── if (method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); } // ── Health-Check ──────────────────────────────────────────────────── if (path === "/health") { return Response.json( { status: "ok", server: MUA_SERVER_NAME, version: MUA_VERSION, port: PORT, time: new Date().toISOString(), }, { headers: corsHeaders }, ); } // ── MCP-Endpunkt ──────────────────────────────────────────────────── if (path === "/mcp" || path === "/") { // POST: JSON-RPC Request if (method === "POST") { let body: string; try { body = await req.text(); } catch (e) { return Response.json( rpcError(null, -32700, "Parse error"), { status: 400, headers: corsHeaders }, ); } let message: Record; try { message = JSON.parse(body); } catch { return Response.json( rpcError(null, -32700, "Parse error"), { status: 400, headers: corsHeaders }, ); } const result = await handleMcpRequest(message, sessionHeader); // Notification (kein Response nötig) if (result === null) { return new Response(null, { status: 202, headers: { ...corsHeaders, "Mcp-Session-Id": sessionHeader ?? "", }, }); } return Response.json(result.response, { headers: { ...corsHeaders, "Mcp-Session-Id": result.sessionId, }, }); } // GET: SSE-Stream (nicht implementiert, spec-konform 405) if (method === "GET") { return Response.json( { error: "SSE not supported. Use POST /mcp for JSON-RPC requests." }, { status: 405, headers: corsHeaders }, ); } // DELETE: Session-End if (method === "DELETE") { deleteSession(sessionHeader); return new Response(null, { status: 200, headers: corsHeaders }); } } // ── 404 ───────────────────────────────────────────────────────────── return Response.json( { error: "Not found. Use /mcp or /health" }, { status: 404, headers: corsHeaders }, ); }, }); console.log( `[MUA] ${MUA_SERVER_NAME} v${MUA_VERSION} listening on ${HOST}:${PORT} (MCP Streamable HTTP)`, ); // Graceful shutdown process.on("SIGTERM", () => { console.log("[MUA] SIGTERM received, shutting down"); server.stop(true); process.exit(0); }); process.on("SIGINT", () => { console.log("[MUA] SIGINT received, shutting down"); server.stop(true); process.exit(0); });