Refactor: PHP-Built-in-Server durch TypeScript-Binary (Bun Runtime) ersetzt
- src/*.ts: Port von mcp/*.php (helpers, tools, server) - Bun.serve() statt php -S — production-grade HTTP, POST funktioniert - dist/mua-1.0.0-x86_64-1.txz: Slackware-Paket (Binary + rc.mua + mua.page + PHP-Helper) - plugin/mua.plg: .txz-Installation via upgradepkg - scripts/rc.mua: Binary statt php -S - scripts/mua.page: Binary-Info statt PHP-Version - scripts/package.sh: .txz-Builder Fix: MCP-POST-Endpunkt lieferte leeren Body (php -S Limitation). Bun.serve() löst das Problem.
This commit is contained in:
+278
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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<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);
|
||||
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<string, unknown>) ?? {};
|
||||
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<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 ────────────────────────────────────────────────────
|
||||
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<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 },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user