feat: add asynchronous Unraid jobs
This commit is contained in:
@@ -76,10 +76,10 @@ Oder manuell:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .txz von Gitea laden
|
# .txz von Gitea laden
|
||||||
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r021-x86_64-1.txz
|
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r022-x86_64-1.txz
|
||||||
|
|
||||||
# Installieren
|
# Installieren
|
||||||
upgradepkg --install-new mua-2026.08.24.r021-x86_64-1.txz
|
upgradepkg --install-new mua-2026.08.24.r022-x86_64-1.txz
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Service starten
|
### 2. Service starten
|
||||||
@@ -94,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit).
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://192.168.1.2:3002/health
|
curl http://192.168.1.2:3002/health
|
||||||
# → {"status":"ok","version":"2026.08.24.r021","auth":"required"}
|
# → {"status":"ok","version":"2026.08.24.r022","auth":"required"}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -215,7 +215,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden:
|
|||||||
| **Docker (16)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_update_status`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_update_verified_batch`, `unraid_docker_rebuild` |
|
| **Docker (16)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_update_status`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_update_verified_batch`, `unraid_docker_rebuild` |
|
||||||
| **Community Applications (3)** | `unraid_ca_search`, `unraid_ca_install_preview`, `unraid_ca_install` |
|
| **Community Applications (3)** | `unraid_ca_search`, `unraid_ca_install_preview`, `unraid_ca_install` |
|
||||||
| **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` |
|
| **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` |
|
||||||
| **System (10)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` |
|
| **System (13)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell`, `unraid_system_job_start`, `unraid_system_job_status`, `unraid_system_job_cleanup` |
|
||||||
|
|
||||||
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
|
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
|
||||||
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
|
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
|
||||||
|
|||||||
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mua",
|
"name": "mua",
|
||||||
"version": "2026.08.24.r021",
|
"version": "2026.08.24.r022",
|
||||||
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
+9
-4
@@ -2,13 +2,13 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "mua">
|
<!ENTITY name "mua">
|
||||||
<!ENTITY author "Michael">
|
<!ENTITY author "Michael">
|
||||||
<!ENTITY version "2026.08.24.r021">
|
<!ENTITY version "2026.08.24.r022">
|
||||||
<!ENTITY launch "Settings/mua">
|
<!ENTITY launch "Settings/mua">
|
||||||
<!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-2026.08.24.r021-x86_64-1.txz">
|
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r022-x86_64-1.txz">
|
||||||
<!ENTITY txzSHA256 "f114ef5be9729bb01362c1d9e165c7055ff67325275ac6562aceba28c2a1fcd5">
|
<!ENTITY txzSHA256 "ac981953fe30d468b22f3febc65c7bdae473393824d4dd4e5a015f2fe18e4f86">
|
||||||
]>
|
]>
|
||||||
|
|
||||||
<PLUGIN name="&name;"
|
<PLUGIN name="&name;"
|
||||||
@@ -23,6 +23,11 @@
|
|||||||
>
|
>
|
||||||
|
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.08.24.r022
|
||||||
|
- Allgemeine asynchrone Root-Jobs mit getrennten Werkzeugen für Start, kompakten Status und Aufräumen verhindern HTTP-Timeouts bei langen autorisierten Arbeiten.
|
||||||
|
- Bestehende Freigaben der kritischen Root-Shell erhalten dieselbe Berechtigungsklasse automatisch; Nur-Lese-Profile bleiben unverändert.
|
||||||
|
- Jobstatus enthält nur Zustand, Exit-Code und begrenzte, redigierte Log-Enden. Kommandoargumente und mögliche Secrets werden nicht zurückgegeben.
|
||||||
|
|
||||||
### 2026.08.24.r021
|
### 2026.08.24.r021
|
||||||
- Die gefilterte Sammlungssuche beendet den Abstieg an einem passenden Verzeichnis. Episodenordner fluten dadurch nicht mehr das begrenzte Erkennungsergebnis.
|
- Die gefilterte Sammlungssuche beendet den Abstieg an einem passenden Verzeichnis. Episodenordner fluten dadurch nicht mehr das begrenzte Erkennungsergebnis.
|
||||||
- Der zweite Aufruf auf den gefundenen Sammlungspfad bleibt unverändert und liefert die vollständige gebündelte Bestandsaufnahme.
|
- Der zweite Aufruf auf den gefundenen Sammlungspfad bleibt unverändert und liefert die vollständige gebündelte Bestandsaufnahme.
|
||||||
@@ -140,7 +145,7 @@ Das .txz enthält:
|
|||||||
install/doinst.sh (läuft nach Installation)
|
install/doinst.sh (läuft nach Installation)
|
||||||
===========================================
|
===========================================
|
||||||
-->
|
-->
|
||||||
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r021-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r022-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
|
||||||
<URL>&txzURL;</URL>
|
<URL>&txzURL;</URL>
|
||||||
<SHA256>&txzSHA256;</SHA256>
|
<SHA256>&txzSHA256;</SHA256>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|||||||
+1
-1
@@ -104,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
|||||||
|
|
|
|
||||||
|${PKG_NAME} - Mikes Unraid Agent
|
|${PKG_NAME} - Mikes Unraid Agent
|
||||||
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|
|MCP over HTTP (Streamable HTTP) Server für Unraid.
|
||||||
|35 Tools: Docker, Community Applications, Netzwerk und Unraid-System.
|
|Werkzeuge für Docker, Community Applications, Netzwerk und Unraid-System.
|
||||||
|Port: 3002, Endpunkt: /mcp
|
|Port: 3002, Endpunkt: /mcp
|
||||||
|
|
|
|
||||||
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
||||||
|
|||||||
+12
@@ -116,6 +116,18 @@ export function parseConfig(content: string): MUAConfig {
|
|||||||
) {
|
) {
|
||||||
cfg.enabledTools.push("unraid_files_inventory");
|
cfg.enabledTools.push("unraid_files_inventory");
|
||||||
}
|
}
|
||||||
|
// Async jobs are the bounded long-running equivalent of the already
|
||||||
|
// unrestricted root shell. Existing administrators who explicitly enabled
|
||||||
|
// that critical capability receive no new authority class here.
|
||||||
|
if (!cfg.allToolsEnabled && cfg.enabledTools.includes("unraid_system_shell")) {
|
||||||
|
for (const name of [
|
||||||
|
"unraid_system_job_start",
|
||||||
|
"unraid_system_job_status",
|
||||||
|
"unraid_system_job_cleanup",
|
||||||
|
]) {
|
||||||
|
if (!cfg.enabledTools.includes(name)) cfg.enabledTools.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-1
@@ -11,10 +11,11 @@
|
|||||||
import { spawn } from "bun";
|
import { spawn } from "bun";
|
||||||
import { createConnection, type Socket } from "net";
|
import { createConnection, type Socket } from "net";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
// ── Konstanten ──────────────────────────────────────────────────────────
|
// ── Konstanten ──────────────────────────────────────────────────────────
|
||||||
export const MUA_SERVER_NAME = "mua";
|
export const MUA_SERVER_NAME = "mua";
|
||||||
export const MUA_VERSION = "2026.08.24.r021";
|
export const MUA_VERSION = "2026.08.24.r022";
|
||||||
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
export const MUA_PROTOCOL_VERSION = "2025-03-26";
|
||||||
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
|
||||||
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
|
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
|
||||||
@@ -83,6 +84,87 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ASYNC_JOB_ROOT = "/tmp/mua-jobs";
|
||||||
|
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function jobPath(jobId: string): string {
|
||||||
|
if (!JOB_ID_PATTERN.test(jobId)) throw new Error("invalid job_id");
|
||||||
|
return `${ASYNC_JOB_ROOT}/${jobId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tailJobFile(path: string, maxBytes = 12_000): Promise<string> {
|
||||||
|
if (!existsSync(path)) return "";
|
||||||
|
const size = statSync(path).size;
|
||||||
|
const start = Math.max(0, size - maxBytes);
|
||||||
|
return sanitizeLogOutput(await Bun.file(path).slice(start, size).text(), maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start arbitrary, explicitly authorized host work without tying up MCP HTTP. */
|
||||||
|
export async function startShellJob(cmd: string): Promise<string> {
|
||||||
|
if (cmd.trim() === "") throw new Error("command is required");
|
||||||
|
mkdirSync(ASYNC_JOB_ROOT, { recursive: true, mode: 0o700 });
|
||||||
|
const jobId = randomUUID();
|
||||||
|
const dir = jobPath(jobId);
|
||||||
|
mkdirSync(dir, { mode: 0o700 });
|
||||||
|
const script = `${dir}/run.sh`;
|
||||||
|
// The unrestricted command is already a critical/admin-only capability.
|
||||||
|
// Keep it out of status output and audit responses because it may contain
|
||||||
|
// credentials supplied by the operator.
|
||||||
|
writeFileSync(
|
||||||
|
script,
|
||||||
|
`#!/bin/sh\n${cmd}\ncode=$?\nprintf '%s\\n' "$code" > '${dir}/exit_code'\nexit "$code"\n`,
|
||||||
|
{ mode: 0o700 },
|
||||||
|
);
|
||||||
|
const proc = spawn(["/bin/sh", script], {
|
||||||
|
cwd: "/",
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: Bun.file(`${dir}/stdout.log`),
|
||||||
|
stderr: Bun.file(`${dir}/stderr.log`),
|
||||||
|
});
|
||||||
|
proc.unref();
|
||||||
|
writeFileSync(
|
||||||
|
`${dir}/meta.json`,
|
||||||
|
JSON.stringify({ job_id: jobId, pid: proc.pid, started_at: new Date().toISOString() }),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
return JSON.stringify({ job_id: jobId, state: "running", pid: proc.pid });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll a job with compact tail output; works across independent MCP calls. */
|
||||||
|
export async function shellJobStatus(jobId: string): Promise<string> {
|
||||||
|
const dir = jobPath(jobId);
|
||||||
|
if (!existsSync(`${dir}/meta.json`)) throw new Error("job not found");
|
||||||
|
const meta = JSON.parse(readFileSync(`${dir}/meta.json`, "utf-8"));
|
||||||
|
let running = false;
|
||||||
|
if (!existsSync(`${dir}/exit_code`)) {
|
||||||
|
try {
|
||||||
|
process.kill(Number(meta.pid), 0);
|
||||||
|
running = true;
|
||||||
|
} catch {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const exitCode = existsSync(`${dir}/exit_code`)
|
||||||
|
? Number(readFileSync(`${dir}/exit_code`, "utf-8").trim())
|
||||||
|
: null;
|
||||||
|
return JSON.stringify({
|
||||||
|
job_id: jobId,
|
||||||
|
state: running ? "running" : exitCode === null ? "unknown" : "finished",
|
||||||
|
exit_code: exitCode,
|
||||||
|
started_at: meta.started_at,
|
||||||
|
stdout_tail: await tailJobFile(`${dir}/stdout.log`),
|
||||||
|
stderr_tail: await tailJobFile(`${dir}/stderr.log`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove only one completed MUA job directory. */
|
||||||
|
export async function cleanupShellJob(jobId: string): Promise<string> {
|
||||||
|
const status = JSON.parse(await shellJobStatus(jobId));
|
||||||
|
if (status.state === "running") throw new Error("cannot clean up a running job");
|
||||||
|
rmSync(jobPath(jobId), { recursive: true, force: false });
|
||||||
|
return JSON.stringify({ job_id: jobId, cleaned: true, previous_state: status.state });
|
||||||
|
}
|
||||||
|
|
||||||
async function runArgv(
|
async function runArgv(
|
||||||
label: string,
|
label: string,
|
||||||
argv: string[],
|
argv: string[],
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ describe("secure tool configuration", () => {
|
|||||||
expect(cfg.enabledTools).toContain("unraid_files_inventory");
|
expect(cfg.enabledTools).toContain("unraid_files_inventory");
|
||||||
expect(cfg.enabledTools).not.toContain("unraid_system_shell");
|
expect(cfg.enabledTools).not.toContain("unraid_system_shell");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an enabled critical shell also exposes bounded asynchronous jobs", () => {
|
||||||
|
const cfg = parseConfig(
|
||||||
|
"MUA_API_KEY=test\nMUA_ENABLED_TOOLS=unraid_system_shell\n",
|
||||||
|
);
|
||||||
|
expect(cfg.enabledTools).toContain("unraid_system_job_start");
|
||||||
|
expect(cfg.enabledTools).toContain("unraid_system_job_status");
|
||||||
|
expect(cfg.enabledTools).toContain("unraid_system_job_cleanup");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("secret handling", () => {
|
describe("secret handling", () => {
|
||||||
@@ -55,6 +64,9 @@ describe("secret handling", () => {
|
|||||||
describe("risk classification", () => {
|
describe("risk classification", () => {
|
||||||
test("classifies root shell and container changes as critical", () => {
|
test("classifies root shell and container changes as critical", () => {
|
||||||
expect(getToolRisk("unraid_system_shell")).toBe("critical");
|
expect(getToolRisk("unraid_system_shell")).toBe("critical");
|
||||||
|
expect(getToolRisk("unraid_system_job_start")).toBe("critical");
|
||||||
|
expect(getToolRisk("unraid_system_job_cleanup")).toBe("critical");
|
||||||
|
expect(getToolRisk("unraid_system_job_status")).toBe("read");
|
||||||
expect(getToolRisk("unraid_docker_modify")).toBe("critical");
|
expect(getToolRisk("unraid_docker_modify")).toBe("critical");
|
||||||
expect(getToolRisk("unraid_docker_update_verified_batch")).toBe("critical");
|
expect(getToolRisk("unraid_docker_update_verified_batch")).toBe("critical");
|
||||||
expect(getToolRisk("unraid_docker_restart")).toBe("write");
|
expect(getToolRisk("unraid_docker_restart")).toBe("write");
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
connectionTest,
|
connectionTest,
|
||||||
validateName,
|
validateName,
|
||||||
runShell,
|
runShell,
|
||||||
|
startShellJob,
|
||||||
|
shellJobStatus,
|
||||||
|
cleanupShellJob,
|
||||||
runReadOnlyCommand,
|
runReadOnlyCommand,
|
||||||
runStatusHelper,
|
runStatusHelper,
|
||||||
searchCommunityApps,
|
searchCommunityApps,
|
||||||
@@ -43,6 +46,8 @@ const CRITICAL_TOOLS = new Set([
|
|||||||
"unraid_docker_update_verified_batch",
|
"unraid_docker_update_verified_batch",
|
||||||
"unraid_docker_rebuild",
|
"unraid_docker_rebuild",
|
||||||
"unraid_system_shell",
|
"unraid_system_shell",
|
||||||
|
"unraid_system_job_start",
|
||||||
|
"unraid_system_job_cleanup",
|
||||||
"unraid_ca_install",
|
"unraid_ca_install",
|
||||||
]);
|
]);
|
||||||
const WRITE_TOOLS = new Set([
|
const WRITE_TOOLS = new Set([
|
||||||
@@ -634,6 +639,44 @@ export const TOOLS: ToolDef[] = [
|
|||||||
return runShell(command, timeout);
|
return runShell(command, timeout);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "unraid_system_job_start",
|
||||||
|
description:
|
||||||
|
"CRITICAL: Start one explicitly authorized long-running unrestricted shell command on Unraid as an asynchronous job. Use this instead of unraid_system_shell when work may exceed an HTTP/tool timeout. Returns a job_id immediately; poll only with unraid_system_job_status and reserve calls for verification and cleanup.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: str("Long-running shell command to execute on Unraid via /bin/sh"),
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
handler: (a) => startShellJob(String(a["command"] ?? "")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unraid_system_job_status",
|
||||||
|
description:
|
||||||
|
"Poll one asynchronous Unraid shell job by job_id. Returns running/finished state, exit code and compact stdout/stderr tails. Do not start a duplicate job while this reports running.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { job_id: str("Job identifier returned by unraid_system_job_start") },
|
||||||
|
required: ["job_id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
handler: (a) => shellJobStatus(String(a["job_id"] ?? "")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unraid_system_job_cleanup",
|
||||||
|
description:
|
||||||
|
"CRITICAL: Remove the private MUA metadata/log directory for one completed asynchronous job. This never removes the command's own output files. Refuses cleanup while the job is running.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { job_id: str("Completed job identifier") },
|
||||||
|
required: ["job_id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
handler: (a) => cleanupShellJob(String(a["job_id"] ?? "")),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function toolByName(name: string): ToolDef | undefined {
|
export function toolByName(name: string): ToolDef | undefined {
|
||||||
|
|||||||
Reference in New Issue
Block a user