Files
MUA-Mikes-Unraid-Agent/src/index.ts
T
Mikei386 8a3ecaf39f r002: API-Key-Auth + Einstellungs-Maske (API-Key generieren, Tool-Checkboxen)
- src/auth.ts: Config-Management (API-Key + Tool-Filter), timing-safe Auth
- src/index.ts: Auth-Check auf /mcp, Tool-Filter in tools/list + tools/call,
  Config-Server auf 127.0.0.1:3003 (nur localhost)
- scripts/mua.page: Einstellungs-UI (API-Key generieren + Tool-Checkboxen)
- scripts/rc.mua: Config-Dir sicherstellen
- Version: 2026.08.18.r002
2026-08-18 10:53:07 +02:00

372 lines
13 KiB
TypeScript

/**
* 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";
import { checkAuth, isToolEnabled, getApiKey, getEnabledTools, setApiKey, setEnabledTools } from "./auth";
import { randomBytes } from "node:crypto";
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<string, { createdAt: number; lastActivity: number }>();
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<string, unknown>,
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<string, unknown>) ?? {};
// ── 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);
// Tool-Filter: nur aktive Tools anzeigen
const activeTools = TOOLS.filter((t) => isToolEnabled(t.name));
return {
response: rpcResult(id, {
tools: activeTools.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<string, unknown>) ?? {};
const tool = toolByName(toolName);
if (!tool) {
return {
response: rpcResult(id, {
content: [{ type: "text", text: `ERROR: Unknown tool: ${toolName}` }],
isError: true,
}),
sessionId: sessionId ?? "",
};
}
// Tool-Filter: deaktivierte Tools ablehnen
if (!isToolEnabled(toolName)) {
return {
response: rpcResult(id, {
content: [{ type: "text", text: `ERROR: Tool disabled: ${toolName}` }],
isError: true,
}),
sessionId: sessionId ?? "",
};
}
try {
const text = await tool.handler(args);
const result: Record<string, unknown> = {
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<string, string> = {
"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 (ohne Auth) ────────────────────────────────────────
if (path === "/health") {
return Response.json(
{
status: "ok",
server: MUA_SERVER_NAME,
version: MUA_VERSION,
port: PORT,
auth: "required",
time: new Date().toISOString(),
},
{ headers: corsHeaders },
);
}
// ── Auth-Check für /mcp (POST + DELETE) — Bearer-Token ─────────────
if (path === "/mcp" || path === "/") {
if (!checkAuth(req)) {
return Response.json(
{
jsonrpc: "2.0",
id: null,
error: { code: -32001, message: "Unauthorized: missing or invalid API key" },
},
{
status: 401,
headers: {
...corsHeaders,
"Mcp-Session-Id": "00000000-0000-0000-0000-000000000000",
},
},
);
}
}
// ── 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<string, unknown>;
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 },
);
},
});
// ── Config-Server (nur localhost:127.0.0.1:3003) ───────────────────────
// Separater Server, nur von localhost erreichbar (WebGUI auf demselben Host).
// Kein Header-Spoofing, kein File-Permission-Problem.
const CONFIG_PORT = Number(process.env["MUA_CONFIG_PORT"] ?? 3003);
const configServer = Bun.serve({
port: CONFIG_PORT,
hostname: "127.0.0.1", // nur localhost
idleTimeout: 30,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
const method = req.method;
if (path !== "/config") {
return Response.json({ error: "Not found" }, { status: 404 });
}
// GET: Config lesen
if (method === "GET") {
return Response.json({
apiKey: getApiKey(),
enabledTools: getEnabledTools(),
allTools: TOOLS.map((t) => t.name),
});
}
// POST: Config schreiben
if (method === "POST") {
let body: Record<string, unknown>;
try {
body = JSON.parse(await req.text());
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
// "generate": neuen API-Key generieren
if (body["generate"] === true) {
setApiKey(randomBytes(32).toString("hex"));
} else if (typeof body["apiKey"] === "string" && (body["apiKey"] as string).length > 0) {
setApiKey(body["apiKey"] as string);
}
if (Array.isArray(body["enabledTools"])) {
const tools = (body["enabledTools"] as unknown[])
.filter((t): t is string => typeof t === "string");
setEnabledTools(tools);
}
return Response.json({
ok: true,
apiKey: getApiKey(),
enabledTools: getEnabledTools(),
});
}
return Response.json({ error: "Method not allowed" }, { status: 405 });
},
});
console.log(
`[MUA] ${MUA_SERVER_NAME} v${MUA_VERSION} listening on ${HOST}:${PORT} (MCP) + 127.0.0.1:${CONFIG_PORT} (config)`,
);
// 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);
});