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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user