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:
Mikei386
2026-08-18 08:02:39 +02:00
parent 3de8cb055a
commit 7e925153fc
12 changed files with 1305 additions and 69 deletions
+11
View File
@@ -18,3 +18,14 @@ Thumbs.db
# Temp # Temp
/tmp/ /tmp/
*.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
+21
View File
@@ -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=="],
}
}
BIN
View File
Binary file not shown.
+16
View File
@@ -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
View File
@@ -7,6 +7,8 @@
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg"> <!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 pluginLOC "/boot/config/plugins/&name;">
<!ENTITY emhttpLOC "/usr/local/emhttp/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;" <PLUGIN name="&name;"
@@ -23,6 +25,7 @@
<CHANGES> <CHANGES>
### 1.0.0 ### 1.0.0
- Initial release: 21 Docker, network, and system tools as MCP server over HTTP (Streamable HTTP, port 3002). - 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> </CHANGES>
<!-- <!--
@@ -42,8 +45,7 @@ echo "===================================================="
echo "" echo ""
# Verzeichnisse # Verzeichnisse
mkdir -p &emhttpLOC;/mcp mkdir -p &emhttpLOC;
mkdir -p &emhttpLOC;/scripts
mkdir -p &pluginLOC; mkdir -p &pluginLOC;
mkdir -p /var/log/plugins mkdir -p /var/log/plugins
touch /var/log/plugins/mua.log 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"> <FILE Name="mua-1.0.0-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<INLINE> <URL>&txzURL;</URL>
set -e <SHA256>&txzSHA256;</SHA256>
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&gt;&amp;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> </FILE>
<!-- <!--
=========================================== ===========================================
POST-INSTALL: Selftest + Service starten POST-INSTALL: Health-Check
=========================================== ===========================================
--> -->
<FILE Run="/bin/bash"> <FILE Run="/bin/bash">
<INLINE> <INLINE>
set +e set +e
echo " Führe Selftest aus..." echo ""
php &emhttpLOC;/mcp/selftest.php 2&gt;&amp;1 | sed 's/^/ /' echo " Führe Health-Check aus..."
echo " Starte MCP-Server..."
/etc/rc.d/rc.mua start
# Health-Check
sleep 2 sleep 2
if curl -sf "http://127.0.0.1:3002/health" &gt; /dev/null 2&gt;&amp;1; then if curl -sf "http://127.0.0.1:3002/health" > /dev/null 2&gt;&amp;1; then
echo "" echo ""
echo " Health-Check: OK" echo " Health-Check: OK"
echo "" echo ""
echo "====================================================" echo "===================================================="
echo " MUA installiert und läuft auf Port 3002" echo " MUA installiert und läuft auf Port 3002"
echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp" echo " Endpunkt: http://$(hostname -I | awk '{print $1}'):3002/mcp"
echo " WebGUI: Settings &gt; MUA" echo " WebGUI: Settings > MUA"
echo "====================================================" echo "===================================================="
else else
echo "" echo ""
@@ -140,10 +110,12 @@ UPDATE: Alte Dateien bereinigen
--> -->
<FILE Run="/bin/bash" Method="update"> <FILE Run="/bin/bash" Method="update">
<INLINE> <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;/mcp
rm -rf &emhttpLOC;/scripts rm -rf &emhttpLOC;/scripts
rm -f &emhttpLOC;/mua.page
rm -f &emhttpLOC;/plugin.php rm -f &emhttpLOC;/plugin.php
</INLINE> </INLINE>
@@ -169,16 +141,19 @@ echo ""
# 2. Service-Datei entfernen # 2. Service-Datei entfernen
rm -f /etc/rc.d/rc.mua 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 rm -f /usr/local/bin/unraid-docker-mcp-helper.php
# 4. Plugin-Verzeichnis entfernen # 5. Plugin-Verzeichnis entfernen
rm -rf &emhttpLOC; rm -rf &emhttpLOC;
# 5. Config entfernen # 6. Config entfernen
rm -rf &pluginLOC; rm -rf &pluginLOC;
# 6. Logs entfernen # 7. Logs entfernen
rm -f /var/log/plugins/mua.log rm -f /var/log/plugins/mua.log
rm -f /var/run/mua.pid rm -f /var/run/mua.pid
+4 -2
View File
@@ -14,6 +14,7 @@ Icon="cubes"
$plugin_dir = '/usr/local/emhttp/plugins/mua'; $plugin_dir = '/usr/local/emhttp/plugins/mua';
$port = 3002; $port = 3002;
$pidfile = '/var/run/mua.pid'; $pidfile = '/var/run/mua.pid';
$binary = '/usr/local/bin/mua';
$endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp'; $endpoint = 'http://' . ($_SERVER['SERVER_ADDR'] ?? '127.0.0.1') . ':' . $port . '/mcp';
// Status bestimmen // Status bestimmen
@@ -40,7 +41,7 @@ $health_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch); curl_close($ch);
$health_ok = ($health_code === 200); $health_ok = ($health_code === 200);
$php_version = PHP_VERSION; $binary_exists = file_exists($binary);
$tool_count = 21; $tool_count = 21;
$host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1'; $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
?> ?>
@@ -74,7 +75,8 @@ $host_ip = $_SERVER['SERVER_ADDR'] ?? '127.0.0.1';
<table> <table>
<tr><th>Plugin</th><td>MUA (Mikes Unraid Agent)</td></tr> <tr><th>Plugin</th><td>MUA (Mikes Unraid Agent)</td></tr>
<tr><th>Version</th><td>1.0.0</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>Port</th><td><?= $port ?></td></tr>
<tr><th>Host-IP</th><td><?= $host_ip ?></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> <tr><th>Tools</th><td><?= $tool_count ?> (Docker: 14, Netzwerk: 6, System: 1)</td></tr>
+117
View File
@@ -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
View File
@@ -3,30 +3,26 @@
# SysVinit-Service-Script (rc.mua) # SysVinit-Service-Script (rc.mua)
# Unraid nutzt SysVinit, kein systemd. # Unraid nutzt SysVinit, kein systemd.
# Wird installiert nach /etc/rc.d/rc.mua # 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" DAEMON="/usr/local/bin/mua"
SERVER="$PLUGIN_DIR/mcp/server.php"
PIDFILE="/var/run/mua.pid" PIDFILE="/var/run/mua.pid"
LOGFILE="/var/log/plugins/mua.log" LOGFILE="/var/log/plugins/mua.log"
PORT=3002 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() { start() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo " MUA läuft bereits (PID $(cat "$PIDFILE"))" echo " MUA läuft bereits (PID $(cat "$PIDFILE"))"
return 0 return 0
fi fi
if [ ! -f "$SERVER" ]; then if [ ! -x "$DAEMON" ]; then
echo " FEHLER: Server-Datei nicht gefunden: $SERVER" echo " FEHLER: Binary nicht gefunden: $DAEMON"
return 1 return 1
fi fi
# Server starten (detached) # 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" echo $! > "$PIDFILE"
sleep 1 sleep 1
if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
+497
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+14
View File
@@ -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"]
}