Add native toolbox MCP tools

This commit is contained in:
Mikei386 committed 2026-08-29 13:27:28 +02:00
1 parent 63542bb29e
commit 454bb111fe
13 files changed
+211 -35

No files matched your search

+99 -1
View File
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync }
// ── Konstanten ──────────────────────────────────────────────────────────
export const MUA_SERVER_NAME = "mua";
export const MUA_VERSION = "2026.08.29.r026";
export const MUA_VERSION = "2026.08.29.r027";
export const MUA_PROTOCOL_VERSION = "2025-03-26";
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
@@ -348,6 +348,104 @@ export async function discoverToolboxContainers(): Promise<ToolboxContainer[]> {
return rows;
}
function selectRunningToolbox(
containers: ToolboxContainer[],
requestedName?: string,
): ToolboxContainer {
const running = containers.filter((container) => container.state === "running");
if (requestedName) {
const selected = running.find((container) => container.name === requestedName);
if (!selected) throw new Error(`Running toolbox container not found: ${requestedName}`);
return selected;
}
if (running.length === 0) throw new Error("No labeled toolbox container is running");
if (running.length > 1) {
throw new Error(
`Multiple toolbox containers are running; specify container: ${running.map((c) => c.name).join(", ")}`,
);
}
return running[0];
}
/** Return labeled toolbox state and optionally probe specific command names. */
export async function toolboxStatus(programs: string[] = []): Promise<string> {
if (programs.length > 32) throw new Error("programs accepts at most 32 names");
for (const program of programs) {
if (!/^[A-Za-z0-9._+-]{1,128}$/.test(program)) {
throw new Error(`Invalid program name: ${program}`);
}
}
const containers = await discoverToolboxContainers();
const running = containers.filter((container) => container.state === "running");
const result: Record<string, unknown> = {
label: "mike.ai.role=toolbox",
available: containers.length > 0,
running: running.length > 0,
containers,
};
if (programs.length === 0 || running.length === 0) return JSON.stringify(result);
if (running.length > 1) {
result["note"] = "Multiple toolboxes are running; availability is reported for the first one.";
}
const selected = running[0];
const script =
'for p do path=$(command -v "$p" 2>/dev/null || true); ' +
'if [ -n "$path" ]; then printf "%s\\t%s\\n" "$p" "$path"; ' +
'else printf "%s\\t\\n" "$p"; fi; done';
const raw = await runArgv(
"toolbox_status",
["/usr/bin/docker", "exec", selected.name, "/bin/sh", "-c", script, "toolbox-status", ...programs],
30,
30_000,
8_000,
);
const paths = new Map(
raw.split("\n").map((line) => {
const tab = line.indexOf("\t");
return tab >= 0 ? [line.slice(0, tab), line.slice(tab + 1)] : [line, ""];
}),
);
result["selected_container"] = selected.name;
result["programs"] = programs.map((program) => ({
name: program,
available: Boolean(paths.get(program)),
path: paths.get(program) || null,
}));
return JSON.stringify(result);
}
/** Execute an explicitly requested shell command inside one labeled toolbox. */
export async function toolboxExec(
command: string,
timeoutSec = 300,
requestedName?: string,
): Promise<string> {
const selected = selectRunningToolbox(await discoverToolboxContainers(), requestedName);
try {
const proc = spawn(
["/usr/bin/docker", "exec", selected.name, "/bin/sh", "-lc", command],
{ stdout: "pipe", stderr: "pipe", cwd: "/" },
);
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
const [stdout, stderr, code] = await Promise.all([
readStreamLimited(proc.stdout, 100_000),
readStreamLimited(proc.stderr, 20_000),
proc.exited,
]);
clearTimeout(timeout);
return JSON.stringify({
container: selected.name,
exit_code: code,
stdout: sanitizeLogOutput(stdout.text.trim(), 100_000),
stderr: sanitizeLogOutput(stderr.text.trim(), 20_000),
truncated: stdout.truncated || stderr.truncated,
});
} catch (error) {
throw new Error(`toolbox execution failed: ${String(error)}`);
}
}
/**
* Delegiert eine Write-Operation an den PHP-Helper.
*/
+17 -15
View File
@@ -61,13 +61,9 @@ async function toolboxGuidance(): Promise<{
if (enabled) {
const running = containers.filter((c) => c.state === "running");
if (running.length > 0) {
instruction = isToolEnabled("unraid_system_shell")
? `Optional agent toolbox available: ${running.map((c) => c.name).join(", ")}. ` +
"When a command-line utility is missing, use unraid_system_shell and run it with " +
"docker exec inside a labeled toolbox container. Install missing utilities there, " +
"not in the MCP client or Hermes container."
: `Agent toolbox detected (${running.map((c) => c.name).join(", ")}), but ` +
"unraid_system_shell is disabled, so the toolbox cannot currently be used through MUA.";
instruction = `Agent toolbox available: ${running.map((c) => c.name).join(", ")}. ` +
"Use unraid_toolbox_status to check programs and unraid_toolbox_exec to run or install " +
"utilities there. Do not install helper programs in Hermes or the MCP client.";
} else {
instruction =
`Agent toolbox configured but currently stopped: ${containers.map((c) => c.name).join(", ")}. ` +
@@ -77,6 +73,12 @@ async function toolboxGuidance(): Promise<{
return { available, enabled, containers, instruction };
}
const TOOLBOX_TOOL_NAMES = new Set(["unraid_toolbox_status", "unraid_toolbox_exec"]);
function isToolAvailable(name: string, toolboxEnabled: boolean): boolean {
return TOOLBOX_TOOL_NAMES.has(name) ? toolboxEnabled : isToolEnabled(name);
}
// ── JSON-RPC Helpers ────────────────────────────────────────────────────
function rpcResult(id: number | string | null, result: unknown) {
return { jsonrpc: "2.0", id, result };
@@ -164,15 +166,12 @@ async function handleMcpRequest(
if (sessionId) touchSession(sessionId);
const toolbox = await toolboxGuidance();
// Tool-Filter: nur aktive Tools anzeigen
const activeTools = TOOLS.filter((t) => isToolEnabled(t.name));
const activeTools = TOOLS.filter((t) => isToolAvailable(t.name, toolbox.enabled));
return {
response: rpcResult(id, {
tools: activeTools.map((t) => ({
name: t.name,
description:
t.name === "unraid_system_shell" && toolbox.instruction
? `${t.description} ${toolbox.instruction}`
: t.description,
description: t.description,
inputSchema: t.inputSchema,
})),
}),
@@ -196,7 +195,8 @@ async function handleMcpRequest(
};
}
// Tool-Filter: deaktivierte Tools ablehnen
if (!isToolEnabled(toolName)) {
const toolbox = await toolboxGuidance();
if (!isToolAvailable(toolName, toolbox.enabled)) {
return {
response: rpcResult(id, {
content: [{ type: "text", text: `ERROR: Tool disabled: ${toolName}` }],
@@ -470,7 +470,7 @@ try {
apiKeyMasked: getMaskedApiKey(),
enabledTools: getEnabledTools(),
allToolsEnabled: getAllToolsEnabled(),
allTools: TOOLS.map((t) => ({
allTools: TOOLS.filter((t) => !TOOLBOX_TOOL_NAMES.has(t.name)).map((t) => ({
name: t.name,
description: t.description,
risk: getToolRisk(t.name),
@@ -498,7 +498,9 @@ try {
let generatedApiKey: string | undefined;
if (body["generate"] === true) generatedApiKey = generateApiKey();
if (Array.isArray(body["enabledTools"])) {
const known = new Set(TOOLS.map((t) => t.name));
const known = new Set(
TOOLS.filter((t) => !TOOLBOX_TOOL_NAMES.has(t.name)).map((t) => t.name),
);
const tools = (body["enabledTools"] as unknown[])
.filter((t): t is string => typeof t === "string" && known.has(t));
setEnabledTools(tools, body["allToolsEnabled"] === true);
+9
View File
@@ -74,6 +74,8 @@ describe("secret handling", () => {
describe("risk classification", () => {
test("classifies root shell and container changes as critical", () => {
expect(getToolRisk("unraid_system_shell")).toBe("critical");
expect(getToolRisk("unraid_toolbox_exec")).toBe("critical");
expect(getToolRisk("unraid_toolbox_status")).toBe("read");
expect(getToolRisk("unraid_system_job_start")).toBe("critical");
expect(getToolRisk("unraid_system_job_cleanup")).toBe("critical");
expect(getToolRisk("unraid_system_job_status")).toBe("read");
@@ -91,6 +93,13 @@ describe("risk classification", () => {
});
});
describe("agent toolbox tools", () => {
test("publishes explicit status and execution schemas", () => {
expect(toolByName("unraid_toolbox_status")).toBeDefined();
expect(toolByName("unraid_toolbox_exec")).toBeDefined();
});
});
describe("direct Unraid terminal", () => {
test("accepts a normal command with the extended timeout", async () => {
const tool = toolByName("unraid_system_shell");
+55
View File
@@ -28,6 +28,8 @@ import {
searchCommunityApps,
previewCommunityAppInstall,
installCommunityApp,
toolboxStatus,
toolboxExec,
} from "./helpers";
export interface ToolDef {
@@ -46,6 +48,7 @@ const CRITICAL_TOOLS = new Set([
"unraid_docker_update_verified_batch",
"unraid_docker_rebuild",
"unraid_system_shell",
"unraid_toolbox_exec",
"unraid_system_job_start",
"unraid_system_job_cleanup",
"unraid_ca_install",
@@ -92,6 +95,58 @@ function validateShellCommand(value: unknown): string {
}
export const TOOLS: ToolDef[] = [
// These two tools are exposed dynamically only when a labeled toolbox is
// present and the dedicated WebGUI switch is enabled (see index.ts).
{
name: "unraid_toolbox_status",
description:
"Discover the optional labeled agent toolbox on Unraid and check whether command-line programs such as ffmpeg, ffprobe, yt-dlp or mediainfo are installed there. Use this first when a required CLI program may be missing; do not probe the local Hermes or MCP-client container.",
inputSchema: {
type: "object",
properties: {
programs: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._+-]+$" },
maxItems: 32,
description: "Optional command names to check inside the running toolbox",
},
},
additionalProperties: false,
},
handler: (a) => {
const raw = a["programs"] ?? [];
if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string")) {
throw new Error("programs must be an array of strings");
}
return toolboxStatus(raw as string[]);
},
},
{
name: "unraid_toolbox_exec",
description:
"Run a command directly inside the labeled agent toolbox on Unraid. Use it for media and download utilities and to install a missing package inside that toolbox when the user requests the job. Never install helper programs into Hermes or the MCP client. The toolbox container is selected dynamically by Docker label, not by a hard-coded name.",
inputSchema: {
type: "object",
properties: {
command: shellCommand("Command or multi-line shell script to run inside the toolbox"),
timeout_seconds: int("Timeout in seconds (1-1800, default 300)"),
container: str("Optional toolbox container name; required only if multiple labeled toolboxes are running"),
},
required: ["command"],
additionalProperties: false,
},
handler: (a) => {
const command = validateShellCommand(a["command"]);
const timeout = Number(a["timeout_seconds"] ?? 300);
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 1800) {
throw new Error("timeout_seconds must be between 1 and 1800");
}
const container = a["container"] === undefined
? undefined
: validateName(a["container"], "container");
return toolboxExec(command, timeout, container);
},
},
// ── Docker (14) ───────────────────────────────────────────────────────
{
name: "unraid_docker_list",