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
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useRef } from "react";
import AceEditor from "@moduletrace/twui/editors/AceEditor";
import type AceEditorModes from "@moduletrace/twui/editors/ace-editor-modes";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import loadAceScripts from "@/src/utils/load-ace-scripts";
type Props = {
title: string;
/**
* The starting text. Changes come back through `onChange`.
*/
content?: string;
mode: (typeof AceEditorModes)[number];
placeholder?: string;
/**
* One line under it, like a field's info.
*/
info?: string;
/**
* Its height, e.g. `h-24`.
*/
className?: string;
onChange: (value: string) => void;
};
/**
* A code field: twui's `AceEditor` with its label, and a line of info
* under it. Loads the editor's scripts when it first shows
* (`loadAceScripts`).
*/
export default function CodeEditor({ title, content, mode, placeholder, info, className, onChange }: Props) {
/**
* The editor keeps the handler it was created with; this keeps it
* calling the latest one.
*/
const change = useRef(onChange);
change.current = onChange;
useEffect(() => {
loadAceScripts().catch(() => {});
}, []);
return (
<Stack className="w-full gap-1.5! items-stretch">
<AceEditor
title={title}
showLabel
content={content}
mode={mode}
placeholder={placeholder}
fontSize="13px"
delay={0}
wrapperProps={{ className: twMerge("h-40", className) }}
onChange={(value) => change.current(value)}
/>
{info ? <Span className="text-[13px] leading-[18px] text-ink-secondary">{info}</Span> : null}
</Stack>
);
}
@@ -1,13 +1,9 @@
import Span from "@moduletrace/twui/layout/Span";
import { Monitor } from "lucide-react";
import IconTile from "./icon-tile";
/**
* A computer's avatar in lists: the monitor icon on a small tile.
*/
export default function ComputerIconTile() {
return (
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
<Monitor size={16} aria-hidden />
</Span>
);
return <IconTile Icon={Monitor} />;
}
+17
View File
@@ -0,0 +1,17 @@
import Span from "@moduletrace/twui/layout/Span";
import type { LucideIcon } from "lucide-react";
type Props = {
Icon: LucideIcon;
};
/**
* An avatar in lists for what has no picture: its icon on a small tile.
*/
export default function IconTile({ Icon }: Props) {
return (
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
<Icon size={16} aria-hidden />
</Span>
);
}