Give bots tools and MCP servers per bot and workspace, with status and tests

- Bots and workspaces get tools and MCP servers from their settings
  pages; bots also from chips under the chat composer, removable there
- "Every bot" now gives one to every bot now and every new bot; each can
  still drop it (attach-every-bot-extensions.ts moves old installs over)
- Status lines: MCP servers record whether they connect, tools whether
  their last run worked
- Test connection in the MCP form and a Test panel in the tool form check
  the form as it is, without saving
- Hide Google sign-in on the login form

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 15:35:13 +01:00
co-authored by Claude Opus 5.5
parent c6ec7d77a2
commit 6294b911e6
69 changed files with 2026 additions and 271 deletions
@@ -24,8 +24,9 @@ type Props<T> = {
};
/**
* 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
* Who gets an MCP server or tool: the bots and workspaces picked here.
* "Every bot" (super admins only) gives it to every bot now and every new
* one; each can still drop it. A workspace gives it to every bot in
* it, there.
*/
export default function ExtensionAccessModal<T extends { [k: string]: any }>({
@@ -77,47 +78,43 @@ export default function ExtensionAccessModal<T extends { [k: string]: any }>({
{targets.can_edit ? (
<Checkbox
title="Every bot"
label="Every bot, the coordinator included"
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
checked={all_bots}
changeHandler={setAllBots}
/>
) : access.all_bots ? (
<Checkbox title="Every bot" label="Every bot (only a super admin changes this)" checked readOnly />
<Checkbox title="Every bot" label="Every bot by default (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))}
/>
</>
)}
<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,37 @@
import IconTile from "@/src/components/general/icon-tile";
import extensionIcon from "@/src/components/general/extension-icon";
import type { BotExtension } from "@/src/types";
import SettingsListRow from "./settings-list-row";
import SettingsRowAction from "./settings-row-action";
type Props = {
item: BotExtension;
busy?: boolean;
onRemove?: () => void;
};
/**
* One MCP server or tool a bot or workspace has, and whether it's working.
*/
export default function ExtensionRow({ item, busy, onRemove }: Props) {
const state = !item.enabled
? "Off for every bot"
: item.state == "error"
? "Not working"
: item.state == "untested"
? "Not checked yet"
: "";
return (
<SettingsListRow
avatar={<IconTile Icon={extensionIcon({ kind: item.kind })} />}
title={item.name}
href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"}
subtitle={[item.detail, state].filter(Boolean).join(" · ")}
>
{onRemove ? (
<SettingsRowAction title={`Remove ${item.name}`} label="Remove" disabled={busy} onClick={onRemove} />
) : null}
</SettingsListRow>
);
}
@@ -0,0 +1,41 @@
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Check, CircleAlert, CircleDashed } from "lucide-react";
import Row from "@moduletrace/twui/layout/Row";
type Props = {
state: "ok" | "error" | "untested";
/**
* The status in words, e.g. "Connected · 12 tools · checked 5m ago".
*/
text: string;
/**
* What went wrong, under it.
*/
error?: string;
};
/**
* Whether an MCP server connects or a tool works, under its row. Said in
* words with an icon, never with color alone.
*/
export default function ExtensionStatusLine({ state, text, error }: Props) {
const Icon = state == "ok" ? Check : state == "error" ? CircleAlert : CircleDashed;
return (
<Stack role="status" className="w-full gap-0.5! items-stretch pl-14 pr-3 pb-2.5 -mt-1">
<Row className="gap-1.5! flex-nowrap items-center">
<Icon size={14} aria-hidden className="shrink-0" />
<Span className="text-[13px] leading-[18px] text-ink">{text}</Span>
</Row>
{error ? (
<Span
title={error}
className="text-[13px] leading-[18px] text-ink-secondary whitespace-pre-wrap break-words line-clamp-3"
>
{error}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,80 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import { Plug, Wrench } from "lucide-react";
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
import useExtensions from "@/src/hooks/use-extensions";
import type { BotExtensionsPayload } from "@/src/types";
import ExtensionRow from "./extension-row";
import SettingsGroup from "./settings-group";
import SettingsList from "./settings-list";
import SettingsStatus from "./settings-status";
type Props = {
owner: { bot_id: number } | { workspace_id: number };
owner_name: string;
description: string;
initial?: BotExtensionsPayload | null;
};
/**
* The MCP servers and tools a bot or a workspace has, removable, and a
* picker to add more. On the bot and workspace settings pages.
*/
export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) {
const extensions = useExtensions({ owner, owner_name, initial });
const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`;
return (
<SettingsGroup id="tools" title="Tools and MCP servers" description={description}>
<SettingsList is_empty={!extensions.attached.length} empty="No tools or MCP servers yet.">
{extensions.attached.map((item) => (
<ExtensionRow
key={`${item.kind}:${item.id}`}
item={item}
busy={extensions.busy == `${item.kind}:${item.id}`}
onRemove={
extensions.can_manage
? () => extensions.remove({ kind: item.kind, id: item.id, name: item.name })
: undefined
}
/>
))}
</SettingsList>
{extensions.can_manage ? (
<ExtensionAddSelect
id={`${id}-add-extension`}
addable={extensions.addable}
loading={Boolean(extensions.busy)}
onAdd={extensions.add}
/>
) : null}
<Row className="gap-1!">
<Button
title="Manage MCP servers"
type="button"
variant="ghost"
color="gray"
size="small"
href="/admin/settings/mcps"
beforeIcon={<Plug size={14} aria-hidden />}
className="gap-1!"
>
MCP servers
</Button>
<Button
title="Manage tools"
type="button"
variant="ghost"
color="gray"
size="small"
href="/admin/settings/tools"
beforeIcon={<Wrench size={14} aria-hidden />}
className="gap-1!"
>
Tools
</Button>
</Row>
<SettingsStatus status={extensions.status} />
</SettingsGroup>
);
}