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:
+11
@@ -18,3 +18,14 @@ Thumbs.db
|
||||
# Temp
|
||||
/tmp/
|
||||
*.tmp
|
||||
|
||||
# Build artifacts
|
||||
node_modules/
|
||||
build/
|
||||
|
||||
# Raw compiled binary (90MB) — NOT committed, only the .txz package
|
||||
dist/mua
|
||||
dist/mua-mac
|
||||
|
||||
# Allow the .txz package (35MB, committed for plugin install)
|
||||
!dist/*.txz
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "mua",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "mua",
|
||||
"version": "1.0.0",
|
||||
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "bun build ./src/index.ts --compile --target=bun-linux-x64 --outfile dist/mua",
|
||||
"build:mac": "bun build ./src/index.ts --compile --target=bun-darwin-arm64 --outfile dist/mua-mac",
|
||||
"start": "bun run src/index.ts",
|
||||
"selftest": "bun run src/selftest.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
+31
-56
@@ -7,6 +7,8 @@
|
||||
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
|
||||
<!ENTITY pluginLOC "/boot/config/plugins/&name;">
|
||||
<!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;">
|
||||
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-1.0.0-x86_64-1.txz">
|
||||
<!ENTITY txzSHA256 "5ba2080852e7decc7f9ffd147773ca294826b5279d8a3ffa79182f1292dc85d3">
|
||||
]>
|
||||
|
||||
<PLUGIN name="&name;"
|
||||
@@ -23,6 +25,7 @@
|
||||
<CHANGES>
|
||||
### 1.0.0
|
||||
- Initial release: 21 Docker, network, and system tools as MCP server over HTTP (Streamable HTTP, port 3002).
|
||||
- Runtime: TypeScript (Bun Runtime, compiled binary) — no PHP built-in server.
|
||||
</CHANGES>
|
||||
|
||||
<!--
|
||||
@@ -42,8 +45,7 @@ echo "===================================================="
|
||||
echo ""
|
||||
|
||||
# Verzeichnisse
|
||||
mkdir -p &emhttpLOC;/mcp
|
||||
mkdir -p &emhttpLOC;/scripts
|
||||
mkdir -p &emhttpLOC;
|
||||
mkdir -p &pluginLOC;
|
||||
mkdir -p /var/log/plugins
|
||||
touch /var/log/plugins/mua.log
|
||||
@@ -53,73 +55,41 @@ touch /var/log/plugins/mua.log
|
||||
|
||||
<!--
|
||||
===========================================
|
||||
GIT-CLONE + DATEIEN INSTALLIEREN
|
||||
SLACKWARE-PACKET INSTALLIEREN
|
||||
===========================================
|
||||
Das .txz enthält:
|
||||
usr/local/bin/mua (kompiliertes Binary)
|
||||
usr/local/bin/unraid-docker-mcp-helper.php (PHP-Helper)
|
||||
etc/rc.d/rc.mua (SysVinit-Service)
|
||||
usr/local/emhttp/plugins/mua/mua.page (WebGUI-Tab)
|
||||
install/doinst.sh (läuft nach Installation)
|
||||
===========================================
|
||||
-->
|
||||
<FILE Run="/bin/bash">
|
||||
<INLINE>
|
||||
set -e
|
||||
|
||||
GIT_REPO="http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent.git"
|
||||
TMP_DIR="/tmp/mua-install-$$"
|
||||
|
||||
echo " Kloniere Repo..."
|
||||
git clone --depth 1 "$GIT_REPO" "$TMP_DIR" 2>&1 | sed 's/^/ /'
|
||||
|
||||
# MCP-Server (PHP)
|
||||
echo " Installiere MCP-Server..."
|
||||
cp "$TMP_DIR/mcp/server.php" &emhttpLOC;/mcp/
|
||||
cp "$TMP_DIR/mcp/helpers.php" &emhttpLOC;/mcp/
|
||||
cp "$TMP_DIR/mcp/tools.php" &emhttpLOC;/mcp/
|
||||
cp "$TMP_DIR/mcp/selftest.php" &emhttpLOC;/mcp/
|
||||
chmod 644 &emhttpLOC;/mcp/*.php
|
||||
|
||||
# PHP-Helper (Write-Operationen)
|
||||
echo " Installiere PHP-Helper..."
|
||||
cp "$TMP_DIR/scripts/unraid-docker-mcp-helper.php" /usr/local/bin/
|
||||
chmod 755 /usr/local/bin/unraid-docker-mcp-helper.php
|
||||
|
||||
# WebGUI-Tab (.page-Format)
|
||||
echo " Installiere WebGUI-Tab..."
|
||||
cp "$TMP_DIR/scripts/mua.page" &emhttpLOC;/mua.page
|
||||
chmod 644 &emhttpLOC;/mua.page
|
||||
|
||||
# SysVinit-Service
|
||||
echo " Installiere Service..."
|
||||
cp "$TMP_DIR/scripts/rc.mua" /etc/rc.d/rc.mua
|
||||
chmod 755 /etc/rc.d/rc.mua
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TMP_DIR"
|
||||
|
||||
</INLINE>
|
||||
<FILE Name="mua-1.0.0-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
||||
<URL>&txzURL;</URL>
|
||||
<SHA256>&txzSHA256;</SHA256>
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
===========================================
|
||||
POST-INSTALL: Selftest + Service starten
|
||||
POST-INSTALL: Health-Check
|
||||
===========================================
|
||||
-->
|
||||
<FILE Run="/bin/bash">
|
||||
<INLINE>
|
||||
set +e
|
||||
|
||||
echo " Führe Selftest aus..."
|
||||
php &emhttpLOC;/mcp/selftest.php 2>&1 | sed 's/^/ /'
|
||||
|
||||
echo " Starte MCP-Server..."
|
||||
/etc/rc.d/rc.mua start
|
||||
|
||||
# Health-Check
|
||||
echo ""
|
||||
echo " Führe Health-Check aus..."
|
||||
sleep 2
|
||||
if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then
|
||||
if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo " Health-Check: OK"
|
||||
echo ""
|
||||
echo "===================================================="
|
||||
echo " MUA installiert und läuft auf Port 3002"
|
||||
echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp"
|
||||
echo " WebGUI: Settings > MUA"
|
||||
echo " WebGUI: Settings > MUA"
|
||||
echo "===================================================="
|
||||
else
|
||||
echo ""
|
||||
@@ -140,10 +110,12 @@ UPDATE: Alte Dateien bereinigen
|
||||
-->
|
||||
<FILE Run="/bin/bash" Method="update">
|
||||
<INLINE>
|
||||
# Alte Dateien entfernen vor Reinstall
|
||||
# Service stoppen vor Update
|
||||
/etc/rc.d/rc.mua stop 2>/dev/null || true
|
||||
|
||||
# Alte PHP-Dateien entfernen (falls vorhanden)
|
||||
rm -rf &emhttpLOC;/mcp
|
||||
rm -rf &emhttpLOC;/scripts
|
||||
rm -f &emhttpLOC;/mua.page
|
||||
rm -f &emhttpLOC;/plugin.php
|
||||
|
||||
</INLINE>
|
||||
@@ -169,16 +141,19 @@ echo ""
|
||||
# 2. Service-Datei entfernen
|
||||
rm -f /etc/rc.d/rc.mua
|
||||
|
||||
# 3. PHP-Helper entfernen
|
||||
# 3. Binary entfernen
|
||||
rm -f /usr/local/bin/mua
|
||||
|
||||
# 4. PHP-Helper entfernen
|
||||
rm -f /usr/local/bin/unraid-docker-mcp-helper.php
|
||||
|
||||
# 4. Plugin-Verzeichnis entfernen
|
||||
# 5. Plugin-Verzeichnis entfernen
|
||||
rm -rf &emhttpLOC;
|
||||
|
||||
# 5. Config entfernen
|
||||
# 6. Config entfernen
|
||||
rm -rf &pluginLOC;
|
||||
|
||||
# 6. Logs entfernen
|
||||
# 7. Logs entfernen
|
||||
rm -f /var/log/plugins/mua.log
|
||||
rm -f /var/run/mua.pid
|
||||
|
||||
|
||||
+4
-2
@@ -14,6 +14,7 @@ Icon="cubes"
|
||||
$plugin_dir = '/usr/local/emhttp/plugins/mua';
|
||||
$port = 3002;
|
||||
$pidfile = '/var/run/mua.pid';
|
||||
$binary = '/usr/local/bin/mua';
|
||||
$endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp';
|
||||
|
||||
// Status bestimmen
|
||||
@@ -40,7 +41,7 @@ $health_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$health_ok = ($health_code === 200);
|
||||
|
||||
$php_version = PHP_VERSION;
|
||||
$binary_exists = file_exists($binary);
|
||||
$tool_count = 21;
|
||||
$host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
|
||||
?>
|
||||
@@ -74,7 +75,8 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
|
||||
<table>
|
||||
<tr><th>Plugin</th><td>MUA (Mikes Unraid Agent)</td></tr>
|
||||
<tr><th>Version</th><td>1.0.0</td></tr>
|
||||
<tr><th>PHP</th><td><?= $php_version ?></td></tr>
|
||||
<tr><th>Runtime</th><td>TypeScript (Bun Runtime, kompiliertes Binary)</td></tr>
|
||||
<tr><th>Binary</th><td><code><?= $binary ?></code> <?= $binary_exists ? '✅' : '❌ fehlt' ?></td></tr>
|
||||
<tr><th>Port</th><td><?= $port ?></td></tr>
|
||||
<tr><th>Host-IP</th><td><?= $host_ip ?></td></tr>
|
||||
<tr><th>Tools</th><td><?= $tool_count ?> (Docker: 14, Netzwerk: 6, System: 1)</td></tr>
|
||||
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/bin/bash
|
||||
# MUA - Mikes Unraid Agent
|
||||
# package.sh — Baut das Slackware .txz-Paket
|
||||
#
|
||||
# Struktur des .txz:
|
||||
# install/doinst.sh
|
||||
# install/slack-desc
|
||||
# usr/local/bin/mua (kompiliertes Binary)
|
||||
# etc/rc.d/rc.mua (SysVinit-Service)
|
||||
# usr/local/emhttp/plugins/mua/mua.page (WebGUI-Tab)
|
||||
#
|
||||
# Cross-Platform: .txz ist ein gzip-tar, funktioniert auf macOS + Linux.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-1.0.0}"
|
||||
PKG_NAME="mua"
|
||||
ARCH="x86_64"
|
||||
BUILD_NUM="1"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_DIR="${ROOT}/build/pkg"
|
||||
DIST_DIR="${ROOT}/dist"
|
||||
|
||||
echo "===================================================="
|
||||
echo " MUA - Paket-Builder"
|
||||
echo " Version: ${VERSION}"
|
||||
echo "===================================================="
|
||||
|
||||
# 1. Binary bauen (falls nicht vorhanden)
|
||||
if [ ! -f "${DIST_DIR}/mua" ]; then
|
||||
echo " Kompiliere Binary..."
|
||||
cd "${ROOT}"
|
||||
bun build src/index.ts --compile --target=bun-linux-x64 --outfile "${DIST_DIR}/mua"
|
||||
fi
|
||||
chmod +x "${DIST_DIR}/mua"
|
||||
echo " Binary: $(du -h "${DIST_DIR}/mua" | cut -f1)"
|
||||
|
||||
# 2. Paket-Struktur aufbauen
|
||||
rm -rf "${BUILD_DIR}"
|
||||
mkdir -p "${BUILD_DIR}/install"
|
||||
mkdir -p "${BUILD_DIR}/usr/local/bin"
|
||||
mkdir -p "${BUILD_DIR}/etc/rc.d"
|
||||
mkdir -p "${BUILD_DIR}/usr/local/emhttp/plugins/mua"
|
||||
|
||||
# Binary
|
||||
cp "${DIST_DIR}/mua" "${BUILD_DIR}/usr/local/bin/mua"
|
||||
chmod 755 "${BUILD_DIR}/usr/local/bin/mua"
|
||||
|
||||
# SysVinit-Service
|
||||
cp "${ROOT}/scripts/rc.mua" "${BUILD_DIR}/etc/rc.d/rc.mua"
|
||||
chmod 755 "${BUILD_DIR}/etc/rc.d/rc.mua"
|
||||
|
||||
# WebGUI-Tab
|
||||
cp "${ROOT}/scripts/mua.page" "${BUILD_DIR}/usr/local/emhttp/plugins/mua/mua.page"
|
||||
chmod 644 "${BUILD_DIR}/usr/local/emhttp/plugins/mua/mua.page"
|
||||
|
||||
# PHP-Helper (Write-Operationen: create/modify/update/rebuild)
|
||||
cp "${ROOT}/scripts/unraid-docker-mcp-helper.php" "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
|
||||
chmod 755 "${BUILD_DIR}/usr/local/bin/unraid-docker-mcp-helper.php"
|
||||
|
||||
# 3. doinst.sh (läuft nach Installation)
|
||||
cat > "${BUILD_DIR}/install/doinst.sh" << 'DOEOF'
|
||||
#!/bin/bash
|
||||
# MUA doinst.sh — läuft nach Installation des .txz-Pakets
|
||||
|
||||
# Verzeichnisse
|
||||
mkdir -p /var/log/plugins
|
||||
touch /var/log/plugins/mua.log
|
||||
mkdir -p /boot/config/plugins/mua
|
||||
|
||||
# Service starten
|
||||
/etc/rc.d/rc.mua start 2>/dev/null || true
|
||||
|
||||
# Health-Check
|
||||
sleep 2
|
||||
if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2>&1; then
|
||||
echo ""
|
||||
echo " Health-Check: OK"
|
||||
echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp"
|
||||
else
|
||||
echo ""
|
||||
echo " WARNUNG: Health-Check fehlgeschlagen"
|
||||
echo " Logs: tail -f /var/log/plugins/mua.log"
|
||||
fi
|
||||
DOEOF
|
||||
chmod 755 "${BUILD_DIR}/install/doinst.sh"
|
||||
|
||||
# 4. slack-desc (Paket-Beschreibung)
|
||||
cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
||||
|${PKG_NAME} (${PKG_NAME})
|
||||
|
|
||||
|${PKG_NAME} - Mikes Unraid Agent
|
||||
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|
||||
|21 Tools: Docker (14), Netzwerk (6), System (1).
|
||||
|Port: 3002, Endpunkt: /mcp
|
||||
|
|
||||
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
||||
|Service: /etc/rc.d/rc.mua (SysVinit)
|
||||
|WebGUI: Settings > MUA
|
||||
|
|
||||
|Version: ${VERSION}
|
||||
|Arch: ${ARCH}
|
||||
|Build: ${BUILD_NUM}
|
||||
DESCEOF
|
||||
|
||||
# 5. .txz erstellen (gzip-tar)
|
||||
mkdir -p "${DIST_DIR}"
|
||||
TXZ_NAME="${PKG_NAME}-${VERSION}-${ARCH}-${BUILD_NUM}.txz"
|
||||
echo " Erstelle ${TXZ_NAME}..."
|
||||
( cd "${BUILD_DIR}" && tar czf "${DIST_DIR}/${TXZ_NAME}" . )
|
||||
|
||||
echo ""
|
||||
echo "===================================================="
|
||||
echo " Paket erstellt: dist/${TXZ_NAME}"
|
||||
echo " Größe: $(du -h "${DIST_DIR}/${TXZ_NAME}" | cut -f1)"
|
||||
echo " SHA256: $(shasum -a 256 "${DIST_DIR}/${TXZ_NAME}" | cut -d' ' -f1)"
|
||||
echo "===================================================="
|
||||
+7
-11
@@ -3,30 +3,26 @@
|
||||
# SysVinit-Service-Script (rc.mua)
|
||||
# Unraid nutzt SysVinit, kein systemd.
|
||||
# Wird installiert nach /etc/rc.d/rc.mua
|
||||
#
|
||||
# Läuft das kompilierte TypeScript-Binary (Bun Runtime) —
|
||||
# kein PHP-Built-in-Server mehr.
|
||||
|
||||
PLUGIN_DIR="/usr/local/emhttp/plugins/mua"
|
||||
SERVER="$PLUGIN_DIR/mcp/server.php"
|
||||
DAEMON="/usr/local/bin/mua"
|
||||
PIDFILE="/var/run/mua.pid"
|
||||
LOGFILE="/var/log/plugins/mua.log"
|
||||
PORT=3002
|
||||
PHP_BIN="/usr/bin/php"
|
||||
|
||||
# PHP-Binary finden (Fallback)
|
||||
if [ ! -x "$PHP_BIN" ]; then
|
||||
PHP_BIN=$(command -v php 2>/dev/null)
|
||||
fi
|
||||
|
||||
start() {
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo " MUA läuft bereits (PID $(cat "$PIDFILE"))"
|
||||
return 0
|
||||
fi
|
||||
if [ ! -f "$SERVER" ]; then
|
||||
echo " FEHLER: Server-Datei nicht gefunden: $SERVER"
|
||||
if [ ! -x "$DAEMON" ]; then
|
||||
echo " FEHLER: Binary nicht gefunden: $DAEMON"
|
||||
return 1
|
||||
fi
|
||||
# Server starten (detached)
|
||||
nohup "$PHP_BIN" -S 0.0.0.0:$PORT "$SERVER" >> "$LOGFILE" 2>&1 &
|
||||
MUA_PORT=$PORT nohup "$DAEMON" >> "$LOGFILE" 2>&1 &
|
||||
echo $! > "$PIDFILE"
|
||||
sleep 1
|
||||
if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* helpers.ts — Command execution, JSON-RPC, read operations
|
||||
*
|
||||
* Portiert von mcp/helpers.php. Alle Funktionen laufen LOKAL auf dem
|
||||
* Unraid-Host. Read-Operationen via `docker` CLI. Write-Operationen
|
||||
* werden an den PHP-Helper delegiert (Bun.spawn), da diese Unraids
|
||||
* PHP-Klassen (DockerClient) benötigen.
|
||||
*/
|
||||
|
||||
import { spawn } from "bun";
|
||||
import { createConnection, type Socket } from "net";
|
||||
|
||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||
export const MUA_SERVER_NAME = "mua";
|
||||
export const MUA_VERSION = "1.0.0";
|
||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||
|
||||
// ── Command Execution ───────────────────────────────────────────────────
|
||||
export interface CmdResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führe einen lokalen Shell-Befehl aus (via /bin/sh -c).
|
||||
* Timeout in Sekunden.
|
||||
*/
|
||||
export async function runLocal(
|
||||
label: string,
|
||||
cmd: string,
|
||||
timeoutSec = 60,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const proc = spawn(["/bin/sh", "-c", cmd], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
clearTimeout(timeout);
|
||||
let result = stdout.trim();
|
||||
if (result === "" && stderr.trim() !== "") result = stderr.trim();
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw new Error(`run_local failed for ${label}: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker-Befehl ausführen (kompakt).
|
||||
*/
|
||||
export async function dockerExec(cmd: string, timeoutSec = 60): Promise<string> {
|
||||
return runLocal("docker", `/usr/bin/docker ${cmd} 2>&1`, timeoutSec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegiert eine Write-Operation an den PHP-Helper.
|
||||
*/
|
||||
export async function runPhpHelper(
|
||||
action: string,
|
||||
...args: string[]
|
||||
): Promise<string> {
|
||||
const helper = PHP_HELPER;
|
||||
if (!Bun.file(helper).exists()) {
|
||||
throw new Error(`PHP helper not found: ${helper}`);
|
||||
}
|
||||
const escaped = [action, ...args].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
||||
return runLocal(`php_helper:${action}`, `/usr/bin/php ${helper} ${escaped}`, 300);
|
||||
}
|
||||
|
||||
// ── Validierung ─────────────────────────────────────────────────────────
|
||||
export function validateName(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value === "") {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(value)) {
|
||||
throw new Error(`Invalid ${field}: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── JSON-Lines ──────────────────────────────────────────────────────────
|
||||
export function jsonLines(text: string): Record<string, unknown>[] {
|
||||
const result: Record<string, unknown>[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") continue;
|
||||
try {
|
||||
result.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Host-Adressen ───────────────────────────────────────────────────────
|
||||
export interface HostAddresses {
|
||||
ipv4: string;
|
||||
ipv6: string;
|
||||
public_ipv6: string;
|
||||
}
|
||||
|
||||
export function hostAddresses(raw: string): HostAddresses {
|
||||
const result: HostAddresses = { ipv4: "", ipv6: "", public_ipv6: "" };
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
if (!Array.isArray(data)) return result;
|
||||
|
||||
for (const iface of data as Record<string, unknown>[]) {
|
||||
const ifname = (iface["ifname"] as string) ?? "";
|
||||
if (ifname === "lo") continue;
|
||||
const addrInfo = (iface["addr_info"] as Record<string, unknown>[]) ?? [];
|
||||
for (const addr of addrInfo) {
|
||||
const local = (addr["local"] as string) ?? "";
|
||||
const family = (addr["family"] as string) ?? "";
|
||||
if (family === "inet") {
|
||||
if (local !== "127.0.0.1" && local !== "0.0.0.0" && result.ipv4 === "") {
|
||||
result.ipv4 = local;
|
||||
}
|
||||
} else if (family === "inet6") {
|
||||
if (local.startsWith("fe80") && result.ipv6 === "") {
|
||||
result.ipv6 = local;
|
||||
}
|
||||
if (
|
||||
!local.startsWith("fe80") &&
|
||||
!local.startsWith("fd") &&
|
||||
!local.startsWith("fc") &&
|
||||
local !== "::1" &&
|
||||
result.public_ipv6 === ""
|
||||
) {
|
||||
result.public_ipv6 = local;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── TCP Probe ───────────────────────────────────────────────────────────
|
||||
export interface ProbeResult {
|
||||
reachable: boolean;
|
||||
latency_ms?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function tcpProbe(
|
||||
host: string,
|
||||
port: number,
|
||||
family: 4 | 6,
|
||||
timeoutSec: number,
|
||||
): Promise<ProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
if (host === "") {
|
||||
resolve({ reachable: false, error: "no host address" });
|
||||
return;
|
||||
}
|
||||
const start = Date.now();
|
||||
const socket: Socket = createConnection({
|
||||
host,
|
||||
port,
|
||||
family,
|
||||
timeout: timeoutSec * 1000,
|
||||
});
|
||||
const done = (result: ProbeResult) => {
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
};
|
||||
socket.on("connect", () => {
|
||||
const elapsed = Date.now() - start;
|
||||
done({ reachable: true, latency_ms: Math.round(elapsed * 10) / 10 });
|
||||
});
|
||||
socket.on("timeout", () => {
|
||||
done({ reachable: false, error: "timeout" });
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
done({ reachable: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Log Sanitize ────────────────────────────────────────────────────────
|
||||
export function sanitizeLogOutput(text: string, maxChars = 50000): string {
|
||||
// Entferne ANSI-Escape-Sequenzen
|
||||
let result = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
||||
// Entferne andere Control-Chars (außer \n, \r, \t)
|
||||
result = result.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
||||
// Begrenze Länge
|
||||
if (result.length > maxChars) {
|
||||
result = "... [truncated] ..." + result.slice(-maxChars);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Compact Container Inspect ───────────────────────────────────────────
|
||||
export async function compactContainerInspect(container: string): Promise<string> {
|
||||
const raw = await dockerExec(`inspect --type container -- ${container}`);
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
if (Array.isArray(data)) data = data[0];
|
||||
const d = data as Record<string, any>;
|
||||
const compact = {
|
||||
Id: (d.Id ?? "").slice(0, 12),
|
||||
Name: d.Name ?? "",
|
||||
State: {
|
||||
Status: d.State?.Status ?? "",
|
||||
Running: d.State?.Running ?? false,
|
||||
Pid: d.State?.Pid ?? 0,
|
||||
ExitCode: d.State?.ExitCode ?? 0,
|
||||
},
|
||||
Image: d.Config?.Image ?? "",
|
||||
NetworkMode: d.HostConfig?.NetworkMode ?? "",
|
||||
Ports: d.NetworkSettings?.Ports ?? [],
|
||||
Env: d.Config?.Env ?? [],
|
||||
Mounts: (d.Mounts ?? []).map((m: any) => ({
|
||||
Type: m.Type ?? "",
|
||||
Source: m.Source ?? "",
|
||||
Destination: m.Destination ?? "",
|
||||
})),
|
||||
RestartCount: d.RestartCount ?? 0,
|
||||
Created: d.Created ?? "",
|
||||
};
|
||||
return JSON.stringify(compact);
|
||||
}
|
||||
|
||||
// ── Container Runtime Summary ───────────────────────────────────────────
|
||||
export async function containerRuntimeSummary(): Promise<string> {
|
||||
const ps = await dockerExec(`ps -a --format '{{json .}}'`);
|
||||
const containers = jsonLines(ps);
|
||||
|
||||
const runningIds = containers
|
||||
.map((c) => (c["ID"] as string) ?? "")
|
||||
.filter((id) => id !== "");
|
||||
|
||||
const stats: Record<string, Record<string, unknown>> = {};
|
||||
if (runningIds.length > 0) {
|
||||
const statsRaw = await dockerExec(`stats --no-stream --format '{{json .}}'`);
|
||||
for (const s of jsonLines(statsRaw)) {
|
||||
stats[(s["ID"] as string) ?? ""] = s;
|
||||
}
|
||||
}
|
||||
|
||||
const result = containers.map((c) => {
|
||||
const id = (c["ID"] as string) ?? "";
|
||||
const entry: Record<string, unknown> = {
|
||||
id: id.slice(0, 12),
|
||||
name: c["Names"] ?? "",
|
||||
image: c["Image"] ?? "",
|
||||
status: c["Status"] ?? "",
|
||||
state: c["State"] ?? "",
|
||||
ports: c["Ports"] ?? "",
|
||||
};
|
||||
if (stats[id]) {
|
||||
entry.cpu_percent = stats[id]["CPUPerc"] ?? "";
|
||||
entry.mem_usage = stats[id]["MemUsage"] ?? "";
|
||||
entry.mem_percent = stats[id]["MemPerc"] ?? "";
|
||||
entry.net_io = stats[id]["NetIO"] ?? "";
|
||||
entry.block_io = stats[id]["BlockIO"] ?? "";
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return JSON.stringify({
|
||||
schema_version: "1.0",
|
||||
container_count: result.length,
|
||||
containers: result,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Compact Network Inventory ───────────────────────────────────────────
|
||||
export async function compactNetworkInventory(): Promise<string> {
|
||||
const networksRaw = await dockerExec(`network ls --no-trunc --format '{{json .}}'`);
|
||||
const networks = jsonLines(networksRaw);
|
||||
|
||||
const result = [];
|
||||
for (const n of networks) {
|
||||
const entry: Record<string, unknown> = {
|
||||
name: n["Name"] ?? "",
|
||||
id: ((n["ID"] as string) ?? "").slice(0, 12),
|
||||
driver: n["Driver"] ?? "",
|
||||
scope: n["Scope"] ?? "",
|
||||
};
|
||||
const inspect = await dockerExec(
|
||||
`network inspect --format '{{len .Containers}}' ${n["Name"]}`,
|
||||
);
|
||||
entry.container_count = parseInt(inspect.trim(), 10) || 0;
|
||||
result.push(entry);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
schema_version: "1.0",
|
||||
network_count: result.length,
|
||||
networks: result,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Analyze Container Logs ──────────────────────────────────────────────
|
||||
export async function analyzeContainerLogs(
|
||||
severity: string,
|
||||
container: string | null,
|
||||
since: string,
|
||||
scanTail: number,
|
||||
maxResults: number,
|
||||
): Promise<string> {
|
||||
const severityLevels: Record<string, string[]> = {
|
||||
error: ["error", "fatal", "panic", "exception", "traceback", "critical"],
|
||||
warn: ["warn", "warning", "deprecated"],
|
||||
info: ["info", "started", "listening", "ready"],
|
||||
};
|
||||
const patterns = severityLevels[severity] ?? severityLevels["error"];
|
||||
const regex = new RegExp(`(${patterns.join("|")})`, "i");
|
||||
|
||||
let logCmd = `logs --timestamps --since ${since} --tail ${scanTail}`;
|
||||
if (container) logCmd += ` ${container}`;
|
||||
const raw = await dockerExec(logCmd, 120);
|
||||
const lines = raw.split("\n");
|
||||
|
||||
const matches: { pattern: string; line: string }[] = [];
|
||||
const counts: Record<string, number> = {};
|
||||
for (const line of lines) {
|
||||
const m = line.match(regex);
|
||||
if (m) {
|
||||
const key = m[1].toLowerCase();
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
if (matches.length < maxResults) {
|
||||
matches.push({ pattern: m[1], line: line.trim().slice(0, 300) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
schema_version: "1.0",
|
||||
severity,
|
||||
container,
|
||||
since,
|
||||
scan_tail: scanTail,
|
||||
total_matches: Object.values(counts).reduce((a, b) => a + b, 0),
|
||||
pattern_counts: counts,
|
||||
sample_matches: matches,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dualstack LAN Probe ─────────────────────────────────────────────────
|
||||
export async function probeDualstack(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutSec: number,
|
||||
): Promise<string> {
|
||||
const [ipv4, ipv6] = await Promise.all([
|
||||
tcpProbe(host, port, 4, timeoutSec),
|
||||
tcpProbe(host, port, 6, timeoutSec),
|
||||
]);
|
||||
return JSON.stringify({ host, port, ipv4, ipv6 });
|
||||
}
|
||||
|
||||
// ── Audit All TCP Endpoints ─────────────────────────────────────────────
|
||||
export async function auditAllTcpEndpoints(
|
||||
timeoutSec: number,
|
||||
includeAllEndpoints = false,
|
||||
): Promise<string> {
|
||||
const inventoryCmd =
|
||||
'ids=$(docker ps -aq); [ -z "$ids" ] || docker inspect --type container --format \'{"ID":{{json .Id}},"Name":{{json .Name}},"Image":{{json .Config.Image}},"Status":{{json .State.Status}},"Running":{{json .State.Running}},"Health":{{json (index .State "Health")}},"NetworkMode":{{json .HostConfig.NetworkMode}},"ExposedPorts":{{json (index .Config "ExposedPorts")}},"Ports":{{json .NetworkSettings.Ports}}}\' $ids';
|
||||
const containersRaw = await runLocal("audit", inventoryCmd, 60);
|
||||
const containers = jsonLines(containersRaw);
|
||||
|
||||
const hostCmd =
|
||||
"printf '%s\\n' '--- IPv4/IPv6 addresses ---'; ip -j address show; printf '%s\\n' '--- Listening sockets ---'; ss -H -lntup";
|
||||
const hostRaw = await runLocal("audit", hostCmd, 30);
|
||||
const addrs = hostAddresses(hostRaw);
|
||||
|
||||
const inactive: { container: string; status: string }[] = [];
|
||||
const noTcp: { container: string; mode: string }[] = [];
|
||||
const udp: { container: string; container_port: string }[] = [];
|
||||
const endpoints: {
|
||||
container: string;
|
||||
mode: string;
|
||||
container_port: string;
|
||||
host_port: number;
|
||||
ipv4_reachable?: boolean;
|
||||
ipv6_reachable?: boolean;
|
||||
classification?: string;
|
||||
}[] = [];
|
||||
const endpointKeys = new Set<string>();
|
||||
|
||||
for (const item of containers) {
|
||||
const name = ((item["Name"] as string) ?? "").replace(/^\//, "");
|
||||
if (!(item["Running"] as boolean)) {
|
||||
inactive.push({ container: name, status: (item["Status"] as string) ?? "" });
|
||||
continue;
|
||||
}
|
||||
const mode = (item["NetworkMode"] as string) ?? "unknown";
|
||||
let foundTcp = false;
|
||||
const ports = (item["Ports"] as Record<string, { HostPort?: string }[]>) ?? {};
|
||||
for (const [containerPort, bindings] of Object.entries(ports)) {
|
||||
const protocol = containerPort.split("/").pop() ?? "";
|
||||
if (protocol === "udp" && bindings.length > 0) {
|
||||
udp.push({ container: name, container_port: containerPort });
|
||||
continue;
|
||||
}
|
||||
if (protocol !== "tcp" || bindings.length === 0) continue;
|
||||
for (const binding of bindings) {
|
||||
if (binding.HostPort) {
|
||||
const key = `${name}:${binding.HostPort}:${containerPort}`;
|
||||
if (!endpointKeys.has(key)) {
|
||||
endpointKeys.add(key);
|
||||
endpoints.push({
|
||||
container: name,
|
||||
mode,
|
||||
container_port: containerPort,
|
||||
host_port: parseInt(binding.HostPort, 10),
|
||||
});
|
||||
}
|
||||
foundTcp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mode !== "host" && !foundTcp) {
|
||||
noTcp.push({ container: name, mode });
|
||||
}
|
||||
}
|
||||
|
||||
const classifications = { dualstack: 0, "ipv4-only": 0, "ipv6-only": 0, unreachable: 0 };
|
||||
for (const ep of endpoints) {
|
||||
const [v4, v6] = await Promise.all([
|
||||
tcpProbe(addrs.ipv4, ep.host_port, 4, timeoutSec),
|
||||
tcpProbe(addrs.ipv6, ep.host_port, 6, timeoutSec),
|
||||
]);
|
||||
ep.ipv4_reachable = v4.reachable;
|
||||
ep.ipv6_reachable = v6.reachable;
|
||||
ep.classification =
|
||||
v4.reachable && v6.reachable
|
||||
? "dualstack"
|
||||
: v4.reachable
|
||||
? "ipv4-only"
|
||||
: v6.reachable
|
||||
? "ipv6-only"
|
||||
: "unreachable";
|
||||
classifications[ep.classification as keyof typeof classifications]++;
|
||||
}
|
||||
|
||||
const issueEndpoints = endpoints.filter((e) => e.classification !== "dualstack");
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
schema_version: "2.0",
|
||||
targets: { ipv4: addrs.ipv4, lan_ipv6: addrs.ipv6 },
|
||||
counts: {
|
||||
containers_total: containers.length,
|
||||
tcp_endpoints_total: endpoints.length,
|
||||
tcp_dualstack: classifications.dualstack,
|
||||
tcp_ipv4_only: classifications["ipv4-only"],
|
||||
tcp_ipv6_only: classifications["ipv6-only"],
|
||||
tcp_unreachable: classifications.unreachable,
|
||||
tcp_problem_endpoints: issueEndpoints.length,
|
||||
},
|
||||
problem_endpoints_only: issueEndpoints,
|
||||
inactive_containers: inactive,
|
||||
running_without_published_tcp: noTcp,
|
||||
task_complete: true,
|
||||
};
|
||||
if (includeAllEndpoints) {
|
||||
result.all_tcp_endpoints = endpoints;
|
||||
}
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
// ── Host State ──────────────────────────────────────────────────────────
|
||||
export async function hostState(): Promise<string> {
|
||||
return runLocal(
|
||||
"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",
|
||||
30,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Connection Test ─────────────────────────────────────────────────────
|
||||
export async function connectionTest(): Promise<string> {
|
||||
return runLocal(
|
||||
"connection_test",
|
||||
"id; printf 'hostname='; hostname; printf 'kernel='; uname -sr; printf 'unraid='; cat /etc/unraid-version",
|
||||
15,
|
||||
);
|
||||
}
|
||||
+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);
|
||||
});
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* MUA — Mikes Unraid Agent
|
||||
* tools.ts — MCP Tool-Definitionen (21 Tools)
|
||||
*
|
||||
* Portiert von mcp/tools.php. Schema: unraid_<kategorie>_<aktion>
|
||||
* Kategorien: docker (14), network (6), system (1).
|
||||
*/
|
||||
|
||||
import {
|
||||
containerRuntimeSummary,
|
||||
compactContainerInspect,
|
||||
dockerExec,
|
||||
sanitizeLogOutput,
|
||||
analyzeContainerLogs,
|
||||
runPhpHelper,
|
||||
compactNetworkInventory,
|
||||
hostState,
|
||||
auditAllTcpEndpoints,
|
||||
probeDualstack,
|
||||
connectionTest,
|
||||
validateName,
|
||||
} from "./helpers";
|
||||
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: object;
|
||||
handler: (args: Record<string, unknown>) => Promise<string>;
|
||||
}
|
||||
|
||||
const str = (desc: string) => ({ type: "string", description: desc });
|
||||
const int = (desc: string) => ({ type: "integer", description: desc });
|
||||
const num = (desc: string) => ({ type: "number", description: desc });
|
||||
const bool = (desc: string) => ({ type: "boolean", description: desc });
|
||||
const empty = { type: "object", properties: {}, additionalProperties: false } as const;
|
||||
|
||||
export const TOOLS: ToolDef[] = [
|
||||
// ── Docker (14) ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: "unraid_docker_list",
|
||||
description:
|
||||
"List all Docker containers with runtime stats (CPU, memory, network I/O). Returns a compact JSON summary.",
|
||||
inputSchema: empty,
|
||||
handler: () => containerRuntimeSummary(),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_inspect",
|
||||
description:
|
||||
"Inspect a single Docker container in detail (state, image, ports, env, mounts).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container name or ID") },
|
||||
required: ["container"],
|
||||
},
|
||||
handler: (a) => compactContainerInspect(validateName(a["container"], "container")),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_logs",
|
||||
description: "Get recent logs from a Docker container (with timestamps).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
container: str("Container name or ID"),
|
||||
tail: int("Number of lines (1-2000, default 200)"),
|
||||
},
|
||||
required: ["container"],
|
||||
},
|
||||
handler: async (a) => {
|
||||
const container = validateName(a["container"], "container");
|
||||
const tail = Number(a["tail"] ?? 200);
|
||||
if (tail < 1 || tail > 2000) throw new Error("tail must be between 1 and 2000");
|
||||
const raw = await dockerExec(`logs --timestamps --tail ${tail} ${container}`, 60);
|
||||
return sanitizeLogOutput(raw);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_analyze_logs",
|
||||
description:
|
||||
"Analyze container logs server-side for errors/warnings. Returns pattern counts and sample matches.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
severity: {
|
||||
type: "string",
|
||||
enum: ["error", "warn", "info"],
|
||||
description: "Log severity to scan for",
|
||||
},
|
||||
container: str("Container name (optional, scans all if omitted)"),
|
||||
since: str("Time filter (default 24h)"),
|
||||
scan_tail: int("Max lines to scan (default 1000)"),
|
||||
max_results: int("Max sample matches (default 50)"),
|
||||
},
|
||||
required: ["severity"],
|
||||
},
|
||||
handler: (a) => {
|
||||
const severity = (a["severity"] as string) ?? "error";
|
||||
const container = a["container"] as string | null | undefined;
|
||||
if (container !== undefined && container !== null && typeof container !== "string") {
|
||||
throw new Error("container must be a string");
|
||||
}
|
||||
const since = (a["since"] as string) ?? "24h";
|
||||
const scanTail = Number(a["scan_tail"] ?? 1000);
|
||||
const maxResults = Number(a["max_results"] ?? 50);
|
||||
return analyzeContainerLogs(severity, container ?? null, since, scanTail, maxResults);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_processes",
|
||||
description: "List processes running inside a Docker container (docker top).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container name or ID") },
|
||||
required: ["container"],
|
||||
},
|
||||
handler: (a) =>
|
||||
dockerExec(
|
||||
`top ${validateName(a["container"], "container")} -eo pid,ppid,user,stat,lstart,etime,args`,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_stats",
|
||||
description: "Get live CPU/memory/network/block I/O stats for all running containers.",
|
||||
inputSchema: empty,
|
||||
handler: () => dockerExec(`stats --no-stream --format '{{json .}}'`),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_info",
|
||||
description:
|
||||
"Get Docker daemon information (version, storage driver, container counts, etc.).",
|
||||
inputSchema: empty,
|
||||
handler: () => dockerExec(`info --format '{{json .}}'`),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_start",
|
||||
description: "Start a Docker container.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container name or ID") },
|
||||
required: ["container"],
|
||||
},
|
||||
handler: (a) => dockerExec(`start ${validateName(a["container"], "container")}`),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_stop",
|
||||
description: "Stop a Docker container.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container name or ID") },
|
||||
required: ["container"],
|
||||
},
|
||||
handler: (a) => dockerExec(`stop ${validateName(a["container"], "container")}`),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_restart",
|
||||
description: "Restart a Docker container.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container name or ID") },
|
||||
required: ["container"],
|
||||
},
|
||||
handler: (a) => dockerExec(`restart ${validateName(a["container"], "container")}`),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_create",
|
||||
description: "Create a Docker container from a template (pulls image, creates container).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
template_name: str('Template name (e.g. "linuxserver/sonarr")'),
|
||||
},
|
||||
required: ["template_name"],
|
||||
},
|
||||
handler: (a) => runPhpHelper("create", validateName(a["template_name"], "template_name")),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_modify",
|
||||
description:
|
||||
"Modify a container template (port, env, volume, network, privileged) and rebuild.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
container: str("Container/template name"),
|
||||
field: {
|
||||
type: "string",
|
||||
enum: ["port", "env", "volume", "network", "privileged"],
|
||||
},
|
||||
value: str("New value (format depends on field)"),
|
||||
},
|
||||
required: ["container", "field", "value"],
|
||||
},
|
||||
handler: (a) => {
|
||||
const container = validateName(a["container"], "container");
|
||||
const field = (a["field"] as string) ?? "";
|
||||
const value = (a["value"] as string) ?? "";
|
||||
if (!["port", "env", "volume", "network", "privileged"].includes(field)) {
|
||||
throw new Error("field must be one of: port, env, volume, network, privileged");
|
||||
}
|
||||
return runPhpHelper("modify", container, field, value);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_update",
|
||||
description: "Update a container (pull latest image, rebuild).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container/template name") },
|
||||
required: ["container"],
|
||||
},
|
||||
handler: (a) => runPhpHelper("update", validateName(a["container"], "container")),
|
||||
},
|
||||
{
|
||||
name: "unraid_docker_rebuild",
|
||||
description:
|
||||
"Rebuild a container from its template (without pulling new image).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { container: str("Container/template name") },
|
||||
required: ["container"],
|
||||
},
|
||||
handler: (a) => runPhpHelper("rebuild", validateName(a["container"], "container")),
|
||||
},
|
||||
|
||||
// ── Netzwerk (6) ──────────────────────────────────────────────────────
|
||||
{
|
||||
name: "unraid_network_inventory",
|
||||
description: "Compact Docker network inventory (all networks with container counts).",
|
||||
inputSchema: empty,
|
||||
handler: () => compactNetworkInventory(),
|
||||
},
|
||||
{
|
||||
name: "unraid_network_list",
|
||||
description: "List all Docker networks.",
|
||||
inputSchema: empty,
|
||||
handler: () => dockerExec(`network ls --no-trunc --format '{{json .}}'`),
|
||||
},
|
||||
{
|
||||
name: "unraid_network_inspect",
|
||||
description: "Inspect a Docker network in detail.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { network: str("Network name or ID") },
|
||||
required: ["network"],
|
||||
},
|
||||
handler: (a) => dockerExec(`network inspect -- ${validateName(a["network"], "network")}`),
|
||||
},
|
||||
{
|
||||
name: "unraid_network_host_state",
|
||||
description:
|
||||
"Get host network state (IPv4/IPv6 addresses, routes, listening sockets).",
|
||||
inputSchema: empty,
|
||||
handler: () => hostState(),
|
||||
},
|
||||
{
|
||||
name: "unraid_network_audit_tcp",
|
||||
description:
|
||||
"Audit all TCP endpoints: probe IPv4/IPv6 reachability for every published port. Returns classification (dualstack/ipv4-only/ipv6-only/unreachable).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
timeout_seconds: num("Probe timeout (0.2-10, default 2)"),
|
||||
include_all_endpoints: bool("Include all endpoints (default false, only problems)"),
|
||||
},
|
||||
},
|
||||
handler: (a) => {
|
||||
const timeout = Number(a["timeout_seconds"] ?? 2);
|
||||
if (timeout < 0.2 || timeout > 10) {
|
||||
throw new Error("timeout_seconds must be between 0.2 and 10");
|
||||
}
|
||||
const includeAll = Boolean(a["include_all_endpoints"] ?? false);
|
||||
return auditAllTcpEndpoints(timeout, includeAll);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unraid_network_lan_probe",
|
||||
description:
|
||||
"Probe a specific host:port for IPv4 and IPv6 reachability (dualstack test).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
host: str("Hostname or IP"),
|
||||
port: int("Port (1-65535)"),
|
||||
timeout_seconds: num("Timeout (0.2-10, default 3)"),
|
||||
},
|
||||
required: ["host", "port"],
|
||||
},
|
||||
handler: (a) => {
|
||||
const host = validateName(a["host"], "host");
|
||||
const port = Number(a["port"] ?? 0);
|
||||
const timeout = Number(a["timeout_seconds"] ?? 3);
|
||||
if (port < 1 || port > 65535 || timeout < 0.2 || timeout > 10) {
|
||||
throw new Error("Invalid port or timeout");
|
||||
}
|
||||
return probeDualstack(host, port, timeout);
|
||||
},
|
||||
},
|
||||
|
||||
// ── System (1) ────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "unraid_system_connection_test",
|
||||
description:
|
||||
"Test connection to the Unraid host (hostname, kernel, Unraid version).",
|
||||
inputSchema: empty,
|
||||
handler: () => connectionTest(),
|
||||
},
|
||||
];
|
||||
|
||||
export function toolByName(name: string): ToolDef | undefined {
|
||||
return TOOLS.find((t) => t.name === name);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user