Files
MUA-Mikes-Unraid-Agent/src/tools.ts
T

642 lines
25 KiB
TypeScript

/**
* MUA — Mikes Unraid Agent
* tools.ts — MCP Tool-Definitionen
*
* Portiert von mcp/tools.php. Schema: unraid_<kategorie>_<aktion>
* Kategorien: docker (14), network (6), system (2).
*/
import {
containerRuntimeSummary,
compactContainerInspect,
dockerExec,
sanitizeLogOutput,
analyzeContainerLogs,
runPhpHelper,
compactNetworkInventory,
hostState,
auditAllTcpEndpoints,
probeDualstack,
connectionTest,
validateName,
runShell,
runReadOnlyCommand,
runStatusHelper,
searchCommunityApps,
previewCommunityAppInstall,
installCommunityApp,
} from "./helpers";
export interface ToolDef {
name: string;
description: string;
inputSchema: object;
handler: (args: Record<string, unknown>) => Promise<string>;
}
export type ToolRisk = "read" | "active" | "write" | "critical";
const CRITICAL_TOOLS = new Set([
"unraid_docker_create",
"unraid_docker_modify",
"unraid_docker_update",
"unraid_docker_update_verified_batch",
"unraid_docker_rebuild",
"unraid_system_shell",
"unraid_ca_install",
]);
const WRITE_TOOLS = new Set([
"unraid_docker_start",
"unraid_docker_stop",
"unraid_docker_restart",
]);
const ACTIVE_TOOLS = new Set([
"unraid_network_audit_tcp",
"unraid_network_lan_probe",
"unraid_ca_search",
"unraid_ca_install_preview",
]);
export function getToolRisk(name: string): ToolRisk {
if (CRITICAL_TOOLS.has(name)) return "critical";
if (WRITE_TOOLS.has(name)) return "write";
if (ACTIVE_TOOLS.has(name)) return "active";
return "read";
}
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 Docker containers and return authoritative server-side counts. For questions about active/currently running containers, ALWAYS use state='running'. Defaults to a compact response without performance statistics to avoid large tool output. Use include_stats=true only when CPU, RAM or I/O values are explicitly needed.",
inputSchema: {
type: "object",
properties: {
state: {
type: "string",
enum: ["all", "running", "stopped", "created", "exited"],
description: "Filter by container state. Use 'running' for active/running containers; 'stopped' means every non-running container. Default: all.",
},
include_stats: bool("Include live CPU, RAM, network and block-I/O statistics. Expensive and verbose; default false."),
detail: {
type: "string",
enum: ["compact", "full"],
description: "compact returns name/status/state; full additionally returns ID, image and ports. Default: compact.",
},
},
additionalProperties: false,
},
handler: (a) => containerRuntimeSummary({
state: (a["state"] as "all" | "running" | "stopped" | "created" | "exited" | undefined),
includeStats: a["include_stats"] === true,
detail: (a["detail"] as "compact" | "full" | undefined),
}),
},
{
name: "unraid_docker_inspect",
description:
"Inspect a single Docker container in detail (state, image, ports, redacted environment variable names, mounts). Secret values are never returned.",
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 in one bounded call. Use optional focus_terms to restrict results to components such as hass_mcp, yaml or zigbee instead of issuing multiple grep/shell calls. 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 running containers 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)"),
focus_terms: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 80 },
maxItems: 12,
description:
"Optional literal component/topic terms. A log line must contain at least one term, case-insensitively. Bundle related terms in one call.",
},
},
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);
const focusTerms = Array.isArray(a["focus_terms"])
? (a["focus_terms"] as unknown[]).filter(
(item): item is string => typeof item === "string",
)
: [];
return analyzeContainerLogs(
severity,
container ?? null,
since,
scanTail,
maxResults,
focusTerms,
);
},
},
{
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_update_status",
description:
"Read Unraid's cached Docker image update state and return a compact per-container summary. Does not pull or update images. Treat updates_available as exhaustive only when status_complete is true; otherwise it is a known minimum and every unknown container must be disclosed.",
inputSchema: empty,
handler: () => runStatusHelper("docker-update-status"),
},
{
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 one explicitly named container (pull latest image and rebuild). For two or more containers, or when update status may be stale, use unraid_docker_update_verified_batch instead.",
inputSchema: {
type: "object",
properties: { container: str("Container/template name") },
required: ["container"],
},
handler: (a) => runPhpHelper("update", validateName(a["container"], "container")),
},
{
name: "unraid_docker_update_verified_batch",
description:
"Authoritative one-call Docker image update workflow for 1-25 explicitly named Unraid containers. Pulls each configured image, compares immutable image IDs, skips already-current containers even if Unraid's cached status is stale, rebuilds only when the image actually changed, preserves every original running/stopped state, and returns compact post-verification (container ID change, state, restart count and health). Use this after read-only update discovery when the user explicitly requested the updates.",
inputSchema: {
type: "object",
properties: {
containers: str(
"One to 25 exact container names separated by |, as returned by Unraid inventory/update status",
),
},
required: ["containers"],
additionalProperties: false,
},
handler: (a) => {
const raw = String(a["containers"] ?? "");
const containers = raw.split("|").map((name) => name.trim()).filter(Boolean);
if (containers.length < 1 || containers.length > 25) {
throw new Error("containers must contain 1-25 names separated by |");
}
const unique = [...new Set(containers.map((name) => validateName(name, "container")))];
return runPhpHelper("update-verified-batch", unique.join("|"));
},
},
{
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")),
},
// ── Community Applications ──────────────────────────────────────────
{
name: "unraid_ca_search",
description:
"Search the official Unraid Community Applications feed in one bounded pass. Returns compact app metadata and a stable app_id; does not install anything. Put up to five useful name/spelling/purpose variants in one query separated by | instead of making repeated synonym calls.",
inputSchema: {
type: "object",
properties: {
query: str("One to five application names, images, repositories or purposes separated by |"),
limit: int("Maximum results (1-25, default 10)"),
},
required: ["query"],
additionalProperties: false,
},
handler: (a) => searchCommunityApps(String(a["query"] ?? ""), Number(a["limit"] ?? 10)),
},
{
name: "unraid_ca_install_preview",
description:
"Prepare and preview installation of an exact Community Applications entry as an Unraid GUI-managed container. Returns a short-lived approval ticket and performs no writes.",
inputSchema: {
type: "object",
properties: {
app_id: str("Exact app_id returned by unraid_ca_search"),
container_name: str("New unique Unraid container name"),
overrides: {
type: "object",
additionalProperties: { type: "string" },
description: "Optional Config Target to value overrides, e.g. {\"8080\":\"18080\"}",
},
start_after_install: bool("Start the container after installation (default false)"),
},
required: ["app_id", "container_name"],
additionalProperties: false,
},
handler: (a) => previewCommunityAppInstall(
String(a["app_id"] ?? ""),
String(a["container_name"] ?? ""),
a["overrides"] ?? {},
Boolean(a["start_after_install"] ?? false),
),
},
{
name: "unraid_ca_install",
description:
"CRITICAL: Install a previously previewed Community Applications entry. Writes an Unraid user template, pulls the image and creates the container so it remains manageable through the Unraid GUI. Requires an exact, unexpired approval ticket and confirm=true.",
inputSchema: {
type: "object",
properties: {
app_id: str("Exact app_id returned by unraid_ca_search"),
container_name: str("New unique Unraid container name"),
overrides: { type: "object", additionalProperties: { type: "string" } },
start_after_install: bool("Start the container after installation"),
confirm: bool("Must be true after explicit user approval"),
approval_ticket: str("Ticket returned by unraid_ca_install_preview"),
},
required: ["app_id", "container_name", "confirm", "approval_ticket"],
additionalProperties: false,
},
handler: (a) => installCommunityApp(
String(a["app_id"] ?? ""),
String(a["container_name"] ?? ""),
a["overrides"] ?? {},
Boolean(a["start_after_install"] ?? false),
Boolean(a["confirm"] ?? false),
String(a["approval_ticket"] ?? ""),
),
},
// ── 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 (3) ────────────────────────────────────────────────────────
{
name: "unraid_system_health",
description:
"Get compact host health: uptime, load averages, logical CPUs, memory use and available temperature sensors.",
inputSchema: empty,
handler: () => runStatusHelper("system-health"),
},
{
name: "unraid_storage_status",
description:
"Get compact Unraid array, parity, pool and disk status including disabled, missing or invalid disk alerts. Serial numbers are omitted.",
inputSchema: empty,
handler: () => runStatusHelper("storage-status"),
},
{
name: "unraid_disk_health",
description:
"Get compact cached SMART health and important error/wear attributes for one disk or all disks. Does not start a SMART test or spin up disks explicitly.",
inputSchema: {
type: "object",
properties: { disk: str("Optional Unraid disk or pool member name, e.g. disk1") },
additionalProperties: false,
},
handler: (a) => runStatusHelper("disk-health", typeof a["disk"] === "string" ? a["disk"] : ""),
},
{
name: "unraid_notifications_list",
description:
"List recent Unraid notifications with compact, secret-redacted subjects and descriptions.",
inputSchema: {
type: "object",
properties: {
limit: int("Number of notifications (1-50, default 20)"),
importance: { type: "string", enum: ["all", "normal", "warning", "alert"] },
},
additionalProperties: false,
},
handler: (a) => runStatusHelper(
"notifications",
String(Math.max(1, Math.min(50, Number(a["limit"] ?? 20)))),
String(a["importance"] ?? "all"),
),
},
{
name: "unraid_shares_list",
description:
"List Unraid shares with pool placement and compact capacity information. Does not list files or file contents.",
inputSchema: empty,
handler: () => runStatusHelper("shares-list"),
},
{
name: "unraid_share_inspect",
description:
"Inspect one Unraid share's allocation, pool/cache placement and capacity. Does not list or read files.",
inputSchema: {
type: "object",
properties: { share: str("Exact Unraid share name") },
required: ["share"],
additionalProperties: false,
},
handler: (a) => runStatusHelper("share-inspect", String(a["share"] ?? "")),
},
{
name: "unraid_files_inventory",
description:
"Inventory file and directory names below one exact Unraid share in a bounded read-only call. Use this instead of repeated ls/find calls for media-library audits and missing-episode checks. It never reads file contents. To locate a collection, first search directories with name_contains; then call this tool once more with the returned directory as relative_path and an empty name_contains to inventory that collection.",
inputSchema: {
type: "object",
properties: {
share: str("Exact Unraid share name, for example Audiobooks"),
relative_path: str("Optional path below the share; never use /mnt/user or an absolute path"),
name_contains: str("Optional case-insensitive path fragment, for example 'drei'. It filters returned entries; use a second call on a matched directory to list its descendants"),
max_depth: int("Maximum directory depth below relative_path (1-20, default 10)"),
max_entries: int("Maximum returned entries (1-5000, default 2000)"),
include_files: { type: "boolean", description: "Include files (default true)" },
include_directories: { type: "boolean", description: "Include directories (default true)" },
},
required: ["share"],
additionalProperties: false,
},
handler: (a) => runStatusHelper(
"files-inventory",
String(a["share"] ?? ""),
String(a["relative_path"] ?? ""),
String(a["name_contains"] ?? ""),
String(Math.max(1, Math.min(20, Number(a["max_depth"] ?? 10)))),
String(Math.max(1, Math.min(5000, Number(a["max_entries"] ?? 2000)))),
a["include_files"] === false ? "0" : "1",
a["include_directories"] === false ? "0" : "1",
),
},
{
name: "unraid_system_connection_test",
description:
"Test connection to the Unraid host (hostname, kernel, Unraid version).",
inputSchema: empty,
handler: () => connectionTest(),
},
{
name: "unraid_system_shell_readonly",
description:
"Run a strictly allowlisted read-only command without a shell interpreter. Supports diagnostics such as ls, tail, head, cat, grep, stat, find, ps, df, du, ss and read-only ip show/list operations. Pipes, redirects, command chaining and mutating options are impossible or rejected.",
inputSchema: {
type: "object",
properties: {
program: {
type: "string",
enum: [
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
"ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname",
"uptime", "wc", "whoami",
],
description: "Allowlisted read-only program",
},
args: {
type: "array",
items: { type: "string", maxLength: 4096 },
maxItems: 64,
description: "Argument vector; passed directly without /bin/sh",
},
timeout_seconds: int("Timeout in seconds (1-120, default 30)"),
},
required: ["program"],
additionalProperties: false,
},
handler: (a) => {
const program = (a["program"] as string) ?? "";
const rawArgs = a["args"] ?? [];
if (!Array.isArray(rawArgs) || rawArgs.some((arg) => typeof arg !== "string")) {
throw new Error("args must be an array of strings");
}
const timeout = Number(a["timeout_seconds"] ?? 30);
if (timeout < 1 || timeout > 120) {
throw new Error("timeout_seconds must be between 1 and 120");
}
return runReadOnlyCommand(program, rawArgs as string[], timeout);
},
},
{
name: "unraid_system_shell",
description:
"CRITICAL: Execute an unrestricted shell command on the Unraid host as root. Keep this tool disabled unless explicitly needed for a supervised maintenance session.",
inputSchema: {
type: "object",
properties: {
command: str(
"Shell command to execute on the Unraid host (run via /bin/sh -c)",
),
timeout_seconds: int("Timeout in seconds (1-300, default 60)"),
},
required: ["command"],
},
handler: (a) => {
const command = (a["command"] as string) ?? "";
if (command.trim() === "") throw new Error("command is required");
const timeout = Number(a["timeout_seconds"] ?? 60);
if (timeout < 1 || timeout > 300) {
throw new Error("timeout_seconds must be between 1 and 300");
}
return runShell(command, timeout);
},
},
];
export function toolByName(name: string): ToolDef | undefined {
return TOOLS.find((t) => t.name === name);
}