Add MCP servers and custom tools for bots

Two new settings pages, MCPs and Tools. Super admins define them; super
admins and admins attach them to bots, to workspaces (every bot there),
or to every bot. Bots get them in chat replies and tasks.

- Custom tools: a JSON-schema of arguments plus an HTTP request, a script
  on the bot's computer, or a prompt, templated with {{args.x}} and
  {{env.KEY}}, secrets redacted from results.
- MCP servers: remote (URL and headers) or stdio commands run on the host
  with a setup command and an isolated env, in a per-process pool. Tools
  are listed into a cache and connect on first call.
- Add, edit, access and try open in modals.
- Markdown, JSON and command fields use twui's AceEditor (CodeEditor),
  its scripts loaded on demand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 15:01:39 +01:00
co-authored by Claude Opus 5.5
parent 550aaeabac
commit c6ec7d77a2
95 changed files with 4733 additions and 164 deletions
@@ -0,0 +1,131 @@
import { useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plug } from "lucide-react";
import IconTile from "@/src/components/general/icon-tile";
import { McpTransports } from "@/src/dict/mcp-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ExtensionTargets, McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import McpServerModal from "./mcp-server-modal";
type Props = {
server: PublicMcpServer;
targets: ExtensionTargets;
onChanged: (overview: McpServersOverviewPayload, msg: string) => void;
onError: (msg: string) => void;
};
/**
* One MCP server: how it's reached, whether it connected, and who gets
* it. Edit and Access open in a modal.
*/
export default function McpServerRow({ server, targets, onChanged, onError }: Props) {
const [open, setOpen] = useState<"edit" | "access">();
const [busy, setBusy] = useState(false);
const post = async ({ path, body, confirm }: { path: string; body: Record<string, unknown>; confirm?: string }) => {
if (busy || (confirm && !window.confirm(confirm))) return;
setBusy(true);
const res = await postAdminApi<McpServersOverviewPayload>({ path, body });
setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Done.");
else onError(res.msg || "That didn't work.");
};
const saved = (overview: McpServersOverviewPayload, msg: string) => {
setOpen(undefined);
onChanged(overview, msg);
};
const status =
server.status == "ok"
? `${server.tools.length} ${server.tools.length == 1 ? "tool" : "tools"}`
: server.status == "error"
? `Error: ${server.last_error}`
: "Not connected yet";
return (
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
<SettingsListRow
avatar={<IconTile Icon={Plug} />}
title={server.name}
badge={server.enabled ? undefined : "Off"}
subtitle={[
McpTransports.find((t) => t.value == server.transport)?.title,
status,
extensionAccessLabel({ access: server.access }),
]
.filter(Boolean)
.join(" · ")}
>
{targets.can_edit ? (
<SettingsRowAction
title={`Edit ${server.name}`}
label="Edit"
disabled={busy}
onClick={() => setOpen(open == "edit" ? undefined : "edit")}
/>
) : null}
<SettingsRowAction
title={`Choose who gets ${server.name}`}
label="Access"
disabled={busy}
onClick={() => setOpen(open == "access" ? undefined : "access")}
/>
{targets.can_edit ? (
<SettingsRowAction
title={`List ${server.name}'s tools again`}
label={busy ? "Connecting…" : "Refresh"}
disabled={busy || !server.enabled}
onClick={() => post({ path: "mcps/sync-mcp", body: { mcp_server_id: server.id } })}
/>
) : null}
{targets.can_edit ? (
<SettingsRowAction
title={`Delete ${server.name}`}
label="Delete"
disabled={busy}
onClick={() =>
post({
path: "mcps/delete-mcp",
body: { mcp_server_id: server.id },
confirm: `Delete ${server.name}? Bots lose its tools at once.`,
})
}
/>
) : null}
</SettingsListRow>
{open == "edit" ? (
<McpServerModal
server={server}
busy={busy}
onReinstall={() =>
post({
path: "mcps/sync-mcp",
body: { mcp_server_id: server.id, reinstall: true },
confirm: `Run ${server.name}'s setup command again?`,
})
}
onSaved={saved}
onCancel={() => setOpen(undefined)}
/>
) : null}
{open == "access" ? (
<ExtensionAccessModal<McpServersOverviewPayload>
id={server.id}
name={server.name}
access={server.access}
targets={targets}
path="mcps/set-mcp-access"
onSaved={saved}
onCancel={() => setOpen(undefined)}
/>
) : null}
</Stack>
);
}