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,125 @@
import { useState } from "react";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Form from "@moduletrace/twui/form/Form";
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { ExtensionAccess, ExtensionTargets } from "@/src/types";
type Props<T> = {
/**
* The MCP server's or tool's id and name.
*/
id: number;
name: string;
access: ExtensionAccess;
targets: ExtensionTargets;
/**
* Under `/api/admin/`.
*/
path: "mcps/set-mcp-access" | "tools/set-tool-access";
onSaved: (overview: T, msg: string) => void;
onCancel: () => void;
};
/**
* Who gets an MCP server or tool: every bot (super admins only), or the
* bots and workspaces picked here. A workspace gives it to every bot in
* it, there.
*/
export default function ExtensionAccessModal<T extends { [k: string]: any }>({
id,
name,
access,
targets,
path,
onSaved,
onCancel,
}: Props<T>) {
const [all_bots, setAllBots] = useState(access.all_bots);
const [bot_ids, setBotIds] = useState(access.bot_ids);
const [workspace_ids, setWorkspaceIds] = useState(access.workspace_ids);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading || !window.confirm(`Save who gets ${name}?`)) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi<T>({
path,
body: { id, bot_ids, workspace_ids, all_bots: targets.can_edit ? all_bots : undefined },
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save who gets it.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
const ids = (value: unknown) => (Array.isArray(value) ? value : value ? [value] : []).map(Number);
return (
<AddModalShell
open
setOpen={(next) => !next && onCancel()}
title={`Who gets ${name}`}
description={"A bot gets it everywhere it works; a workspace gives it to every bot in it, there."}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
{targets.can_edit ? (
<Checkbox
title="Every bot"
label="Every bot, the coordinator included"
checked={all_bots}
changeHandler={setAllBots}
/>
) : access.all_bots ? (
<Checkbox title="Every bot" label="Every bot (only a super admin changes this)" checked readOnly />
) : null}
{all_bots ? null : (
<>
<SearchSelect
id={`${path}-${id}-bots`}
title="Bots"
label="Bots"
showLabel
multiple
placeholder="Pick the bots that get it"
options={targets.bots.map((bot) => ({
value: String(bot.id),
title: bot.role ? `${bot.name} (${bot.role})` : bot.name,
}))}
defaultValues={bot_ids.map(String)}
info="They get it everywhere they work."
changeHandler={(value) => setBotIds(ids(value))}
/>
<SearchSelect
id={`${path}-${id}-workspaces`}
title="Workspaces"
label="Workspaces"
showLabel
multiple
placeholder="Pick the workspaces that get it"
options={targets.workspaces.map((workspace) => ({
value: String(workspace.id),
title: workspace.name,
}))}
defaultValues={workspace_ids.map(String)}
info="Every bot in these gets it, there."
changeHandler={(value) => setWorkspaceIds(ids(value))}
/>
</>
)}
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
</Form>
</AddModalShell>
);
}
@@ -0,0 +1,22 @@
import type { ExtensionAccess } from "@/src/types";
type Params = {
access: ExtensionAccess;
};
/**
* Who gets an MCP server or tool, in a few words: "Every bot",
* "2 bots · 1 workspace", or "No bots yet".
*/
export default function extensionAccessLabel({ access }: Params) {
if (access.all_bots) return "Every bot";
const parts = [
access.bot_ids.length ? `${access.bot_ids.length} ${access.bot_ids.length == 1 ? "bot" : "bots"}` : "",
access.workspace_ids.length
? `${access.workspace_ids.length} ${access.workspace_ids.length == 1 ? "workspace" : "workspaces"}`
: "",
].filter(Boolean);
return parts.join(" · ") || "No bots yet";
}
@@ -0,0 +1,232 @@
import { useState } from "react";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import CodeEditor from "@/src/components/general/code-editor";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
import McpToolToggle from "./mcp-tool-toggle";
type Props = {
/**
* Edits this server; adds one when not given.
*/
server?: PublicMcpServer;
busy?: boolean;
/**
* Runs a stdio server's setup again.
*/
onReinstall?: () => void;
onSaved: (overview: McpServersOverviewPayload, msg: string) => void;
onCancel: () => void;
};
/**
* Adds or edits an MCP server (super admins): a remote one by URL and
* headers, or a stdio one by the command that starts it on this server,
* the setup that installs what it needs, and the env it gets. Values can
* use `{{env.KEY}}`. Saving connects and lists its tools; each can be
* left out.
*/
export default function McpServerModal({ server, busy, onReinstall, onSaved, onCancel }: Props) {
const [name, setName] = useState(server?.name || "");
const [description, setDescription] = useState(server?.description || "");
const [transport, setTransport] = useState<McpTransport>(server?.transport || "remote");
const [url, setUrl] = useState(server?.url || "");
const [headers, setHeaders] = useState(templateLines({ map: server?.headers, separator: ":" }));
const [command, setCommand] = useState(server?.command || "");
const [setup_command, setSetupCommand] = useState(server?.setup_command || "");
const [env, setEnv] = useState(templateLines({ map: server?.env, separator: "=" }));
const [excluded, setExcluded] = useState(server?.excluded_tools || []);
const [enabled, setEnabled] = useState(server?.enabled ?? true);
const [all_bots, setAllBots] = useState(server?.access.all_bots ?? false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const id = `mcp-${server?.id || "new"}`;
const submit = async () => {
if (loading) return;
if (!window.confirm(server ? `Save ${server.name}?` : `Add ${name || "this MCP server"}?`)) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi<McpServersOverviewPayload>({
path: "mcps/save-mcp",
body: {
mcp_server_id: server?.id,
name,
description,
transport,
url,
headers: parseTemplateLines({ text: headers, separator: ":" }),
command,
setup_command,
env: parseTemplateLines({ text: env, separator: "=" }),
excluded_tools: excluded,
enabled,
all_bots,
},
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save the MCP server.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
return (
<AddModalShell
open
setOpen={(next) => !next && onCancel()}
title={server ? `Edit ${server.name}` : "Add an MCP server"}
description={"Bots get its tools once it's attached to them. Saving connects and lists them."}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Input
id={`${id}-name`}
title="Name"
label="Name"
showLabel
placeholder="GitHub"
defaultValue={name}
autoComplete="off"
onChange={(e) => setName(e.target.value)}
/>
<Input
id={`${id}-description`}
title="Description"
label="Description"
showLabel
placeholder="Issues and pull requests in the company's repositories"
defaultValue={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Select
id={`${id}-transport`}
title="How to reach it"
label="How to reach it"
showLabel
options={McpTransports.map((t) => ({ value: t.value, title: t.title, default: t.value == transport }))}
changeHandler={(value) => setTransport(String(value) as McpTransport)}
/>
{transport == "remote" ? (
<>
<Input
id={`${id}-url`}
title="URL"
label="URL"
showLabel
placeholder="https://mcp.example.com/mcp"
defaultValue={url}
autoComplete="off"
info="Streamable HTTP, or SSE for older servers."
onChange={(e) => setUrl(e.target.value)}
/>
<CodeEditor
mode="properties"
className="h-24"
title="Headers"
placeholder={"Authorization: Bearer {{env.GITHUB_TOKEN}}"}
content={headers}
info="One per line. Put secrets in Settings, under Environment, and use them as {{env.KEY}}."
onChange={(value) => setHeaders(value)}
/>
</>
) : (
<>
<CodeEditor
title="Command"
mode="sh"
className="h-24"
placeholder="bunx -y @modelcontextprotocol/server-github"
content={command}
info="Runs on this server with its shell, in the server's own folder, as the app's user. Only add commands you trust."
onChange={setCommand}
/>
<CodeEditor
title="Setup command"
mode="sh"
className="h-32"
placeholder="bun add @modelcontextprotocol/server-github"
content={setup_command}
info="Installs what the command needs, in the same folder (kept across updates). Runs before the first start and whenever it changes. Prefer installs into the folder: system packages go when the app is updated."
onChange={setSetupCommand}
/>
<CodeEditor
mode="properties"
className="h-24"
title="Env"
placeholder={"GITHUB_PERSONAL_ACCESS_TOKEN={{env.GITHUB_TOKEN}}"}
content={env}
info="One KEY=value per line: the only envs it gets, besides PATH and HOME."
onChange={(value) => setEnv(value)}
/>
{server?.id && onReinstall ? (
<Button
title="Run the setup command again"
type="button"
variant="outlined"
color="gray"
size="small"
className="self-start"
disabled={busy || loading}
onClick={onReinstall}
>
Reinstall
</Button>
) : null}
</>
)}
{server?.status == "error" && server.last_error ? (
<Span className="text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words">
{server.last_error}
</Span>
) : null}
{server?.tools.length ? (
<Stack className="gap-2! items-stretch">
<Span className="text-[13px] font-bold leading-5 text-ink">Tools bots get</Span>
{server.tools.map((tool) => (
<McpToolToggle
key={tool.name}
tool={tool}
on={!excluded.includes(tool.name)}
onChange={(on) =>
setExcluded((prev) =>
on ? prev.filter((name) => name != tool.name) : [...prev, tool.name],
)
}
/>
))}
</Stack>
) : null}
<Checkbox title="On" label="On: bots get its tools" checked={enabled} changeHandler={setEnabled} />
<Checkbox
title="Every bot"
label="Every bot, the coordinator included"
checked={all_bots}
changeHandler={setAllBots}
/>
<AddFormActions
submit_label={server ? "Save and connect" : "Add and connect"}
loading={loading}
error={error}
onCancel={onCancel}
/>
</Form>
</AddModalShell>
);
}
@@ -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>
);
}
@@ -0,0 +1,22 @@
import Checkbox from "@moduletrace/twui/form/Checkbox";
import type { McpToolInfo } from "@/src/types";
type Props = {
tool: McpToolInfo;
on: boolean;
onChange: (on: boolean) => void;
};
/**
* One of a server's tools, ticked when bots get it.
*/
export default function McpToolToggle({ tool, on, onChange }: Props) {
return (
<Checkbox
title={tool.description}
label={`${tool.name}: ${tool.description.split("\n")[0]?.slice(0, 120) || ""}`}
checked={on}
changeHandler={onChange}
/>
);
}
@@ -0,0 +1,79 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { McpServersOverviewPayload } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import McpServerModal from "../(partials)/mcp-server-modal";
import McpServerRow from "../(partials)/mcp-server-row";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Every MCP server with who gets it. Super admins add, edit, refresh and
* delete them; admins choose which bots and workspaces get each one.
*/
export default function McpsSettingsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState(pageProps?.mcps_overview);
const [adding, setAdding] = useState(false);
const [status, setStatus] = useState<Status>();
/**
* Remounts the rows when the data changes under them.
*/
const [version, setVersion] = useState(0);
if (!overview) return null;
const changed = (next: McpServersOverviewPayload, msg: string) => {
setOverview(next);
setStatus({ success: true, msg });
setVersion((prev) => prev + 1);
};
const failed = (msg: string) => setStatus({ error: true, msg });
return (
<SettingsGroup
title="MCP servers"
description="A server's tools go to the bots and workspaces it's attached to, or to every bot. Bots only connect when they call one."
>
{adding ? (
<McpServerModal
onSaved={(next, msg) => {
setAdding(false);
changed(next, msg);
}}
onCancel={() => setAdding(false)}
/>
) : null}
<SettingsList is_empty={!overview.servers.length} empty="No MCP servers yet.">
{overview.servers.map((server) => (
<McpServerRow
key={`${server.id}-${version}`}
server={server}
targets={overview}
onChanged={changed}
onError={failed}
/>
))}
</SettingsList>
{overview.can_edit && !adding ? (
<Button
title="Add an MCP server"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setAdding(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New MCP server
</Button>
) : null}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,20 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
/**
* Super admins and admins: admins only choose who gets each server.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
mcps_overview: await grabMcpsOverview({ user_id: settings.props.person.id }),
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import McpsSettingsSection from "./(sections)/mcps-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsMcpsPage() {
return (
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
<McpsSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "MCPs" });
@@ -0,0 +1,32 @@
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
/**
* What a new tool's arguments schema starts as.
*/
export const DefaultToolParameters = JSON.stringify(
{
type: "object",
properties: {
city: { type: "string", description: "The city to look up" },
},
required: ["city"],
},
null,
2,
);
/**
* What a tool's config starts as for each kind.
*/
export const DefaultToolConfigs: {
http: CustomToolHttpConfig;
script: CustomToolScriptConfig;
prompt: CustomToolPromptConfig;
} = {
http: { method: "GET", url: "", headers: {}, body: "" },
script: {
language: "python",
code: `import json, os\n\nargs = json.loads(os.environ["COBBLE_ARGS"])\nprint(f"Hello from {args.get('city')}")\n`,
},
prompt: { prompt: "" },
};
@@ -0,0 +1,37 @@
import type { CustomToolKind } from "@/src/dict/custom-tool-dict";
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
import HttpToolFields from "./http-tool-fields";
import PromptToolFields from "./prompt-tool-fields";
import ScriptToolFields from "./script-tool-fields";
export type ToolConfigs = {
http: CustomToolHttpConfig;
script: CustomToolScriptConfig;
prompt: CustomToolPromptConfig;
};
type Props = {
id: string;
kind: CustomToolKind;
configs: ToolConfigs;
onChange: (configs: ToolConfigs) => void;
};
/**
* The fields of the tool's kind. Each kind keeps its own config, so
* switching kinds back and forth loses nothing.
*/
export default function CustomToolKindFields({ id, kind, configs, onChange }: Props) {
switch (kind) {
case "http":
return <HttpToolFields id={id} config={configs.http} onChange={(http) => onChange({ ...configs, http })} />;
case "script":
return (
<ScriptToolFields id={id} config={configs.script} onChange={(script) => onChange({ ...configs, script })} />
);
case "prompt":
return (
<PromptToolFields id={id} config={configs.prompt} onChange={(prompt) => onChange({ ...configs, prompt })} />
);
}
}
@@ -0,0 +1,147 @@
import { useState } from "react";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import Textarea from "@moduletrace/twui/form/Textarea";
import CodeEditor from "@/src/components/general/code-editor";
import { CustomToolKinds, type CustomToolKind } from "@/src/dict/custom-tool-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
import { DefaultToolConfigs, DefaultToolParameters } from "../(data)/tool-defaults";
import CustomToolKindFields, { type ToolConfigs } from "./custom-tool-kind-fields";
type Props = {
/**
* Edits this tool; adds one when not given.
*/
tool?: PublicCustomTool;
onSaved: (overview: CustomToolsOverviewPayload, msg: string) => void;
onCancel: () => void;
};
/**
* Adds or edits a custom tool (super admins): the name and description
* the model reads, its arguments as a JSON schema, and what it does (an
* HTTP request, a script, or a prompt).
*/
export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
const [name, setName] = useState(tool?.name || "");
const [description, setDescription] = useState(tool?.description || "");
const [kind, setKind] = useState<CustomToolKind>(tool?.kind || "http");
const [parameters, setParameters] = useState(
tool?.parameters ? JSON.stringify(tool.parameters, null, 2) : DefaultToolParameters,
);
const [configs, setConfigs] = useState<ToolConfigs>({
...DefaultToolConfigs,
...(tool?.config ? { [tool.kind]: tool.config } : {}),
});
const [enabled, setEnabled] = useState(tool?.enabled ?? true);
const [all_bots, setAllBots] = useState(tool?.access.all_bots ?? false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const id = `tool-${tool?.id || "new"}`;
const submit = async () => {
if (loading) return;
let parsed: unknown;
try {
parsed = JSON.parse(parameters);
} catch {
setError("The arguments schema isn't valid JSON.");
return;
}
if (!window.confirm(tool ? `Save ${tool.name}?` : `Add ${name || "this tool"}?`)) return;
setLoading(true);
setError(undefined);
const res = await postAdminApi<CustomToolsOverviewPayload>({
path: "tools/save-tool",
body: {
custom_tool_id: tool?.id,
name,
description,
kind,
parameters: parsed,
config: configs[kind],
enabled,
all_bots,
},
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't save the tool.");
return;
}
onSaved(res.singleRes, res.msg || "Saved.");
};
return (
<AddModalShell
open
setOpen={(next) => !next && onCancel()}
title={tool ? `Edit ${tool.name}` : "Add a tool"}
description={"Bots get it once it's attached to them."}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Input
id={`${id}-name`}
title="Name"
label="Name"
showLabel
placeholder="get_weather"
defaultValue={name}
autoComplete="off"
info="What bots call it: lowercase letters, numbers and underscores."
onChange={(e) => setName(e.target.value)}
/>
<Textarea
id={`${id}-description`}
title="Description"
label="Description"
showLabel
rows={3}
placeholder="Gets the current weather for a city. Use it when the user asks about the weather."
defaultValue={description}
info="Bots read this to decide when to call it, so say what it does and when to use it."
onChange={(e) => setDescription(e.target.value)}
/>
<CodeEditor
mode="json"
className="h-52"
title="Arguments"
content={parameters}
info='An object schema. Describe each argument: bots fill them in from it. {"type": "object", "properties": {}} takes none.'
onChange={(value) => setParameters(value)}
/>
<Select
id={`${id}-kind`}
title="What it does"
label="What it does"
showLabel
options={CustomToolKinds.map((k) => ({ value: k.value, title: k.title, default: k.value == kind }))}
changeHandler={(value) => setKind(String(value) as CustomToolKind)}
/>
<CustomToolKindFields key={kind} id={id} kind={kind} configs={configs} onChange={setConfigs} />
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
<Checkbox
title="Every bot"
label="Every bot, the coordinator included"
checked={all_bots}
changeHandler={setAllBots}
/>
<AddFormActions submit_label={tool ? "Save" : "Add tool"} loading={loading} error={error} onCancel={onCancel} />
</Form>
</AddModalShell>
);
}
@@ -0,0 +1,98 @@
import { useState } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { Wrench } from "lucide-react";
import IconTile from "@/src/components/general/icon-tile";
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { CustomToolsOverviewPayload, ExtensionTargets, PublicCustomTool } 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 CustomToolModal from "./custom-tool-modal";
import CustomToolTryModal from "./custom-tool-try-modal";
type Props = {
tool: PublicCustomTool;
targets: ExtensionTargets;
onChanged: (overview: CustomToolsOverviewPayload, msg: string) => void;
onError: (msg: string) => void;
};
/**
* One custom tool: its name, kind and who gets it. Edit, Access and Try
* open in a modal.
*/
export default function CustomToolRow({ tool, targets, onChanged, onError }: Props) {
const [open, setOpen] = useState<"edit" | "access" | "try">();
const [busy, setBusy] = useState(false);
const toggle = (panel: "edit" | "access" | "try") => setOpen(open == panel ? undefined : panel);
const saved = (overview: CustomToolsOverviewPayload, msg: string) => {
setOpen(undefined);
onChanged(overview, msg);
};
const remove = async () => {
if (busy || !window.confirm(`Delete ${tool.name}? Bots lose it at once.`)) return;
setBusy(true);
const res = await postAdminApi<CustomToolsOverviewPayload>({
path: "tools/delete-tool",
body: { custom_tool_id: tool.id },
});
setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
else onError(res.msg || "Couldn't delete the tool.");
};
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={Wrench} />}
title={tool.name}
badge={tool.enabled ? undefined : "Off"}
subtitle={[
CustomToolKinds.find((k) => k.value == tool.kind)?.title,
extensionAccessLabel({ access: tool.access }),
tool.description,
]
.filter(Boolean)
.join(" · ")}
>
{targets.can_edit ? (
<SettingsRowAction title={`Edit ${tool.name}`} label="Edit" disabled={busy} onClick={() => toggle("edit")} />
) : null}
<SettingsRowAction
title={`Choose who gets ${tool.name}`}
label="Access"
disabled={busy}
onClick={() => toggle("access")}
/>
{targets.can_edit ? (
<SettingsRowAction title={`Try ${tool.name}`} label="Try" disabled={busy} onClick={() => toggle("try")} />
) : null}
{targets.can_edit ? (
<SettingsRowAction title={`Delete ${tool.name}`} label="Delete" disabled={busy} onClick={remove} />
) : null}
</SettingsListRow>
{open == "edit" ? (
<CustomToolModal tool={tool} onSaved={saved} onCancel={() => setOpen(undefined)} />
) : null}
{open == "access" ? (
<ExtensionAccessModal<CustomToolsOverviewPayload>
id={tool.id}
name={tool.name}
access={tool.access}
targets={targets}
path="tools/set-tool-access"
onSaved={saved}
onCancel={() => setOpen(undefined)}
/>
) : null}
{open == "try" ? <CustomToolTryModal tool={tool} onClose={() => setOpen(undefined)} /> : null}
</Stack>
);
}
@@ -0,0 +1,80 @@
import { useState } from "react";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
import Form from "@moduletrace/twui/form/Form";
import Span from "@moduletrace/twui/layout/Span";
import CodeEditor from "@/src/components/general/code-editor";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { CustomToolTestPayload, PublicCustomTool } from "@/src/types";
type Props = {
tool: PublicCustomTool;
onClose: () => void;
};
/**
* Runs a saved tool once with sample arguments and shows what a bot would
* read. A script runs on the main computer, with general envs.
*/
export default function CustomToolTryModal({ tool, onClose }: Props) {
const [args, setArgs] = useState("{}");
const [output, setOutput] = useState<string>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const submit = async () => {
if (loading) return;
let parsed: unknown;
try {
parsed = JSON.parse(args || "{}");
} catch {
setError("The arguments aren't valid JSON.");
return;
}
setLoading(true);
setError(undefined);
const res = await postAdminApi<CustomToolTestPayload>({
path: "tools/test-tool",
body: { custom_tool_id: tool.id, args: parsed },
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't run the tool.");
return;
}
setOutput(res.singleRes.output);
};
return (
<AddModalShell
open
setOpen={(next) => !next && onClose()}
title={`Try ${tool.name}`}
description={"Runs it once and shows what a bot would read. A script runs on the main computer, with general envs."}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<CodeEditor
mode="json"
className="h-28"
title="Arguments"
content={args}
info="Saved changes only: save the tool first."
onChange={(value) => setArgs(value)}
/>
{output !== undefined ? (
<Span className="block font-mono text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words max-h-96 overflow-auto rounded-default-sm border border-solid border-hairline bg-paper p-3">
{output}
</Span>
) : null}
<AddFormActions submit_label="Run it" loading={loading} error={error} onCancel={onClose} />
</Form>
</AddModalShell>
);
}
@@ -0,0 +1,69 @@
import Input from "@moduletrace/twui/form/Input";
import Select from "@moduletrace/twui/form/Select";
import CodeEditor from "@/src/components/general/code-editor";
import { CustomToolHttpMethods, type CustomToolHttpMethod } from "@/src/dict/custom-tool-dict";
import type { CustomToolHttpConfig } from "@/src/types";
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
import TimeoutField from "./timeout-field";
type Props = {
id: string;
config: CustomToolHttpConfig;
onChange: (config: CustomToolHttpConfig) => void;
};
/**
* An HTTP tool's request: method, URL, headers and body, all templates.
*/
export default function HttpToolFields({ id, config, onChange }: Props) {
return (
<>
<Select
id={`${id}-method`}
title="Method"
label="Method"
showLabel
options={CustomToolHttpMethods.map((method) => ({
value: method,
title: method,
default: method == config.method,
}))}
changeHandler={(value) => onChange({ ...config, method: String(value) as CustomToolHttpMethod })}
/>
<Input
id={`${id}-url`}
title="URL"
label="URL"
showLabel
placeholder="https://api.example.com/weather?city={{args.city}}"
defaultValue={config.url}
autoComplete="off"
info="{{args.name}} fills in an argument (URL-encoded), {{env.KEY}} an env."
onChange={(e) => onChange({ ...config, url: e.target.value })}
/>
<CodeEditor
mode="properties"
className="h-24"
title="Headers"
placeholder={"Authorization: Bearer {{env.WEATHER_KEY}}"}
content={templateLines({ map: config.headers, separator: ":" })}
info="One per line. Put secrets in Settings, under Environment: bots never see them."
onChange={(value) =>
onChange({ ...config, headers: parseTemplateLines({ text: value, separator: ":" }) })
}
/>
{config.method == "GET" ? null : (
<CodeEditor
mode="json"
className="h-28"
title="Body"
placeholder={'{"city": "{{args.city}}"}'}
content={config.body || ""}
info="Empty sends the arguments as JSON."
onChange={(value) => onChange({ ...config, body: value })}
/>
)}
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
</>
);
}
@@ -0,0 +1,25 @@
import CodeEditor from "@/src/components/general/code-editor";
import type { CustomToolPromptConfig } from "@/src/types";
type Props = {
id: string;
config: CustomToolPromptConfig;
onChange: (config: CustomToolPromptConfig) => void;
};
/**
* A prompt tool's prompt, answered by the bot's text model.
*/
export default function PromptToolFields({ id, config, onChange }: Props) {
return (
<CodeEditor
mode="markdown"
className="h-52"
title="Prompt"
placeholder={"Summarise this contract for a busy founder. List the risks first.\n\n{{args.text}}"}
content={config.prompt}
info="{{args.name}} fills in an argument. The bot's text model answers it in one turn, without tools."
onChange={(value) => onChange({ ...config, prompt: value })}
/>
);
}
@@ -0,0 +1,45 @@
import Select from "@moduletrace/twui/form/Select";
import CodeEditor from "@/src/components/general/code-editor";
import { CustomToolScriptLanguages, type CustomToolScriptLanguage } from "@/src/dict/custom-tool-dict";
import type { CustomToolScriptConfig } from "@/src/types";
import TimeoutField from "./timeout-field";
type Props = {
id: string;
config: CustomToolScriptConfig;
onChange: (config: CustomToolScriptConfig) => void;
};
/**
* A script tool's code and language. It runs on the bot's computer.
*/
export default function ScriptToolFields({ id, config, onChange }: Props) {
const language =
CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0];
return (
<>
<Select
id={`${id}-language`}
title="Language"
label="Language"
showLabel
options={CustomToolScriptLanguages.map((language) => ({
value: language.value,
title: language.title,
default: language.value == config.language,
}))}
changeHandler={(value) => onChange({ ...config, language: String(value) as CustomToolScriptLanguage })}
/>
<CodeEditor
title="Code"
mode={language.editor_mode}
className="h-72"
content={config.code}
info="Runs on the bot's current computer (the task's in a task) with its envs set. The arguments come as JSON in $COBBLE_ARGS and on stdin; what it prints goes back to the bot."
onChange={(code) => onChange({ ...config, code })}
/>
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
</>
);
}
@@ -0,0 +1,31 @@
import Input from "@moduletrace/twui/form/Input";
import { AppData } from "@/src/data/app-data";
type Props = {
id: string;
timeout_ms?: number;
onChange: (timeout_ms?: number) => void;
};
/**
* How long a tool may run, in seconds. Empty uses the default.
*/
export default function TimeoutField({ id, timeout_ms, onChange }: Props) {
return (
<Input
id={`${id}-timeout`}
title="Timeout (seconds)"
label="Timeout (seconds)"
showLabel
type="number"
min={1}
max={AppData.CustomToolMaxTimeoutMs / 1000}
placeholder={String(AppData.CustomToolDefaultTimeoutMs / 1000)}
defaultValue={timeout_ms ? String(timeout_ms / 1000) : ""}
onChange={(e) => {
const seconds = Number(e.target.value);
onChange(seconds > 0 ? Math.round(seconds * 1000) : undefined);
}}
/>
);
}
@@ -0,0 +1,79 @@
import { useContext, useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import { Plus } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { CustomToolsOverviewPayload } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group";
import SettingsList from "../../(partials)/settings-list";
import SettingsStatus from "../../(partials)/settings-status";
import CustomToolModal from "../(partials)/custom-tool-modal";
import CustomToolRow from "../(partials)/custom-tool-row";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* Every custom tool with who gets it. Super admins add, edit, try and
* delete them; admins choose which bots and workspaces get each one.
*/
export default function ToolsSettingsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState(pageProps?.tools_overview);
const [adding, setAdding] = useState(false);
const [status, setStatus] = useState<Status>();
/**
* Remounts the rows when the data changes under them.
*/
const [version, setVersion] = useState(0);
if (!overview) return null;
const changed = (next: CustomToolsOverviewPayload, msg: string) => {
setOverview(next);
setStatus({ success: true, msg });
setVersion((prev) => prev + 1);
};
const failed = (msg: string) => setStatus({ error: true, msg });
return (
<SettingsGroup
title="Tools"
description="An API call, a script on the bot's computer, or a prompt. A tool goes to the bots and workspaces it's attached to, or to every bot."
>
{adding ? (
<CustomToolModal
onSaved={(next, msg) => {
setAdding(false);
changed(next, msg);
}}
onCancel={() => setAdding(false)}
/>
) : null}
<SettingsList is_empty={!overview.tools.length} empty="No tools yet.">
{overview.tools.map((tool) => (
<CustomToolRow
key={`${tool.id}-${version}`}
tool={tool}
targets={overview}
onChanged={changed}
onError={failed}
/>
))}
</SettingsList>
{overview.can_edit && !adding ? (
<Button
title="Add a tool"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => setAdding(true)}
className="self-start gap-1!"
>
<Plus size={14} aria-hidden />
New tool
</Button>
) : null}
<SettingsStatus status={status} />
</SettingsGroup>
);
}
@@ -0,0 +1,20 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
/**
* Super admins and admins: admins only choose who gets each tool.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
return {
props: {
...settings.props,
tools_overview: await grabToolsOverview({ user_id: settings.props.person.id }),
},
};
};
+13
View File
@@ -0,0 +1,13 @@
import ToolsSettingsSection from "./(sections)/tools-settings-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsToolsPage() {
return (
<SettingsShell title="Tools" description="Tools of your own your bots can call.">
<ToolsSettingsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Tools" });
@@ -1,6 +1,6 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Textarea from "@moduletrace/twui/form/Textarea";
import CodeEditor from "@/src/components/general/code-editor";
import type { WorkspaceBot } from "@/src/types";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
@@ -57,25 +57,21 @@ export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, o
description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Textarea
id="workspace-bot-instructions"
<CodeEditor
mode="markdown"
className="h-44"
title="Instructions here"
label="Instructions here"
showLabel
rows={7}
defaultValue={system_prompt}
content={system_prompt}
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
onChange={(e) => setSystemPrompt(e.target.value)}
onChange={(value) => setSystemPrompt(value)}
/>
<Textarea
id="workspace-bot-memory"
<CodeEditor
mode="markdown"
className="h-32"
title="Memory here"
label="Memory here"
showLabel
rows={5}
defaultValue={memory}
content={memory}
info="What it must remember in this workspace."
onChange={(e) => setMemory(e.target.value)}
onChange={(value) => setMemory(value)}
/>
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onClose} />
</Form>
@@ -1,9 +1,9 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import CodeEditor from "@/src/components/general/code-editor";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
@@ -80,15 +80,13 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
defaultValue={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Textarea
id="workspace-context"
<CodeEditor
mode="markdown"
className="h-52"
title="Context"
label="Context"
showLabel
rows={8}
defaultValue={system_prompt}
content={system_prompt}
info="Markdown. Goals, people, links, rules: whatever every bot here should know."
onChange={(e) => setSystemPrompt(e.target.value)}
onChange={(value) => setSystemPrompt(value)}
/>
<Row className="w-full gap-3! flex-nowrap items-center justify-between">
<SettingsStatus status={status} />