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
@@ -0,0 +1,65 @@
import { useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plus } from "lucide-react";
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
import type { BotExtensionsPayload } from "@/src/types";
type Props = {
bot_name: string;
addable: BotExtensionsPayload["addable"];
loading?: boolean;
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise<boolean>;
};
/**
* The "Add" chip under the composer: picks an MCP server or tool to give
* the bot, everywhere it works.
*/
export default function ComposerAddToolModal({ bot_name, addable, loading, onAdd }: Props) {
const [open, setOpen] = useState(false);
if (!addable.length) return null;
return (
<Modal
target={
<Button
title={`Give ${bot_name} a tool or MCP server`}
type="button"
variant="ghost"
color="gray"
size="small"
beforeIcon={<Plus size={12} aria-hidden />}
className="gap-1! px-2! py-0.5! text-[12px] leading-4"
>
Add
</Button>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Stack className="w-full gap-4! items-stretch">
<Stack className="w-full gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">Add a tool</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`${bot_name} can use it everywhere it works, for everyone it works for.`}
</Span>
</Stack>
<ExtensionAddSelect
id="composer-add-extension"
addable={addable}
loading={loading}
onAdd={async (picked) => {
if (await onAdd(picked)) setOpen(false);
}}
/>
</Stack>
</Modal>
);
}
@@ -0,0 +1,53 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { CircleAlert, X } from "lucide-react";
import extensionIcon from "@/src/components/general/extension-icon";
import type { BotExtension } from "@/src/types";
type Props = {
item: BotExtension;
busy?: boolean;
/**
* Takes it from the bot; not given for a workspace's own or when the
* user can't.
*/
onRemove?: () => void;
};
/**
* One MCP server or tool the bot can use here, under the composer. One
* that isn't working shows an alert icon, one that's off is struck out.
*/
export default function ComposerToolChip({ item, busy, onRemove }: Props) {
const Icon = item.state == "error" ? CircleAlert : extensionIcon({ kind: item.kind });
const notes = [
item.detail,
item.source == "workspace" ? "From this workspace" : "",
!item.enabled ? "Off for every bot" : item.state == "error" ? "Not working" : "",
].filter(Boolean);
return (
<Row
title={`${item.name}: ${notes.join(" · ")}${item.description ? `\n${item.description}` : ""}`}
className={`max-w-48 gap-1.5! flex-nowrap items-center rounded-default-lg border border-solid border-hairline bg-surface pl-2 py-0.5 text-[12px] leading-4 ${onRemove ? "pr-0.5" : "pr-2"} ${item.enabled ? "text-ink" : "text-ink-secondary line-through"}`}
>
<Icon size={12} aria-hidden className="shrink-0" />
<Span className="truncate">{item.name}</Span>
{onRemove ? (
<Button
title={`Remove ${item.name}`}
type="button"
variant="ghost"
color="gray"
size="small"
className="shrink-0 p-0.5!"
disabled={busy}
onClick={onRemove}
>
<X size={12} aria-hidden />
</Button>
) : null}
</Row>
);
}
@@ -0,0 +1,58 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import useExtensions from "@/src/hooks/use-extensions";
import type { BotExtensionsPayload, BotWithLastMessage } from "@/src/types";
import ComposerAddToolModal from "./composer-add-tool-modal";
import ComposerToolChip from "./composer-tool-chip";
type Props = {
bot: BotWithLastMessage;
workspace_id?: number | null;
initial?: BotExtensionsPayload | null;
};
/**
* The MCP servers and tools the bot can use here, under the composer.
* Super admins and admins remove any it was given (even one every bot
* started with) and add more; a workspace's own stay.
*/
export default function ComposerTools({ bot, workspace_id, initial }: Props) {
const extensions = useExtensions({
owner: { bot_id: bot.id || 0, workspace_id },
owner_name: bot.name || "this bot",
initial,
});
if (!extensions.attached.length && !(extensions.can_manage && extensions.addable.length)) return null;
return (
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-6 pb-3 -mt-3">
<Span className="text-[12px] leading-4 text-ink-secondary">Tools</Span>
{extensions.attached.map((item) => (
<ComposerToolChip
key={`${item.kind}:${item.id}`}
item={item}
busy={extensions.busy == `${item.kind}:${item.id}`}
onRemove={
extensions.can_manage && item.source == "bot"
? () => extensions.remove({ kind: item.kind, id: item.id, name: item.name })
: undefined
}
/>
))}
{extensions.can_manage ? (
<ComposerAddToolModal
bot_name={bot.name || "This bot"}
addable={extensions.addable}
loading={Boolean(extensions.busy)}
onAdd={extensions.add}
/>
) : null}
{extensions.status?.error ? (
<Span role="alert" className="text-[12px] leading-4 text-ink-secondary">
{extensions.status.msg}
</Span>
) : null}
</Row>
);
}
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea";
import type {
BotExtensionsPayload,
BotMessageWithAttachments,
BotWithLastMessage,
} from "@/src/types";
@@ -12,6 +13,7 @@ import useComposerDraft from "../(hooks)/use-composer-draft";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerSendButton from "./composer-send-button";
import ComposerTools from "./composer-tools";
type Props = {
bot: BotWithLastMessage;
@@ -19,6 +21,10 @@ type Props = {
* The workspace the conversation is in, if any.
*/
workspace_id?: number | null;
/**
* Its MCP servers and tools, listed under the input.
*/
extensions?: BotExtensionsPayload | null;
onSent: (params: { message: BotMessageWithAttachments }) => void;
};
@@ -26,9 +32,10 @@ type Props = {
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
* Its top rule lines up with the rail's user-row rule; picked files sit
* just above it. Files can also be pasted or dropped in. Unsent text is
* kept per conversation across reloads, with its uploaded files.
* kept per conversation across reloads, with its uploaded files. The
* bot's MCP servers and tools are listed under the input.
*/
export default function Composer({ bot, workspace_id, onSent }: Props) {
export default function Composer({ bot, workspace_id, extensions, onSent }: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
@@ -128,6 +135,7 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
onClick={send}
/>
</Row>
<ComposerTools bot={bot} workspace_id={workspace_id} initial={extensions} />
</Stack>
</Stack>
);
@@ -112,6 +112,7 @@ export default function Conversation() {
<Composer
bot={bot}
workspace_id={workspace_id}
extensions={pageProps?.bot_extensions}
onSent={({ message }) => {
if (startsNewSession(message) && message.session_id != session_id) {
setSessionId(Number(message.session_id));
@@ -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>
);
}
@@ -0,0 +1,17 @@
import relativeTime from "@/src/utils/relative-time";
type Params = {
at: number;
};
/**
* When a status was last checked, for a sentence: "just now", "5m ago",
* "on Tue".
*/
export default function checkedAgo({ at }: Params) {
const time = relativeTime({ at });
if (time == "now") return "just now";
return /^\d/.test(time) ? `${time} ago` : `on ${time}`;
}
@@ -5,13 +5,12 @@ type Params = {
};
/**
* Who gets an MCP server or tool, in a few words: "Every bot",
* "2 bots · 1 workspace", or "No bots yet".
* Who gets an MCP server or tool, in a few words: "2 bots · 1 workspace",
* "Every bot by default · 5 bots", or "No bots yet".
*/
export default function extensionAccessLabel({ access }: Params) {
if (access.all_bots) return "Every bot";
const parts = [
access.all_bots ? "Every bot by default" : "",
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"}`
@@ -3,6 +3,7 @@ import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import BotHarnessForm from "../../../(partials)/bot-harness-form";
import ExtensionsGroup from "../../../(partials)/extensions-group";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell";
import BotComputersGroup from "../(partials)/bot-computers-group";
@@ -13,7 +14,7 @@ import BotWorkspacesGroup from "../(partials)/bot-workspaces-group";
/**
* One bot's settings: overview, details, models, workspaces, computers,
* and deleting a worker. The same page shows the bot's information and
* tools and MCP servers, and deleting a worker. The same page shows the bot's information and
* edits it.
*/
export default function BotSettingsSection() {
@@ -70,6 +71,12 @@ export default function BotSettingsSection() {
computers={pageProps?.bot_computers || []}
all_computers={pageProps?.computers || []}
/>
<ExtensionsGroup
owner={{ bot_id: bot.id }}
owner_name={name}
description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there."
initial={pageProps?.bot_extensions}
/>
{is_coordinator ? null : <BotDeleteGroup bot={bot} />}
</SettingsShell>
);
@@ -4,6 +4,7 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
import grabBotWorkspaces from "@/src/functions/backend/db/workspaces/grab-bot-workspaces";
@@ -11,7 +12,8 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
/**
* One bot's settings: its details, models, workspaces and computers. For
* One bot's settings: its details, models, workspaces, computers, tools
* and MCP servers. For
* super admins and admins; the coordinator is the super admin's alone.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
@@ -35,6 +37,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
all_computers,
bot_computer_rows,
bot_harness_overview,
bot_extensions,
] = await Promise.all([
grabBots({ user_id }),
grabUserWorkspaces({ user_id }),
@@ -45,6 +48,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null),
grabBotExtensionsOverview({ user_id, bot_id: bot.id }),
]);
const bot_computers = bot_computer_rows
@@ -65,6 +69,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot_computers,
computers: all_computers,
bot_harness_overview,
bot_extensions,
},
};
};
@@ -7,11 +7,13 @@ 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 Row from "@moduletrace/twui/layout/Row";
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 type { McpServersOverviewPayload, McpServerTestPayload, PublicMcpServer } from "@/src/types";
import SettingsStatus from "../../(partials)/settings-status";
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
import McpToolToggle from "./mcp-tool-toggle";
@@ -33,8 +35,9 @@ type Props = {
* 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.
* use `{{env.KEY}}`. Test connection checks the form as it is, saved or
* not, and lists its tools, so they can be left out before saving; saving
* connects again.
*/
export default function McpServerModal({ server, busy, onReinstall, onSaved, onCancel }: Props) {
const [name, setName] = useState(server?.name || "");
@@ -50,9 +53,50 @@ export default function McpServerModal({ server, busy, onReinstall, onSaved, onC
const [all_bots, setAllBots] = useState(server?.access.all_bots ?? false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const [tools, setTools] = useState(server?.tools || []);
const [testing, setTesting] = useState(false);
const [test, setTest] = useState<{ ok: boolean; msg: string; error?: string }>();
const id = `mcp-${server?.id || "new"}`;
const 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,
});
/**
* Connects as the form is now, saved or not, and shows its tools.
*/
const runTest = async () => {
if (testing) return;
setTesting(true);
setTest(undefined);
const res = await postAdminApi<McpServerTestPayload>({ path: "mcps/test-mcp", body: body() });
setTesting(false);
if (!res.success || !res.singleRes) {
setTest({ ok: false, msg: res.msg || "Couldn't test it." });
return;
}
if (res.singleRes.ok) setTools(res.singleRes.tools);
setTest({ ok: res.singleRes.ok, msg: res.msg || "", error: res.singleRes.error });
};
const submit = async () => {
if (loading) return;
if (!window.confirm(server ? `Save ${server.name}?` : `Add ${name || "this MCP server"}?`)) return;
@@ -62,20 +106,7 @@ export default function McpServerModal({ server, busy, onReinstall, onSaved, onC
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,
},
body: body(),
});
setLoading(false);
@@ -175,31 +206,47 @@ export default function McpServerModal({ server, busy, onReinstall, onSaved, onC
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 ? (
<Row className="gap-2! items-center">
<Button
title="Connect as the form is now, without saving"
type="button"
variant="outlined"
color="gray"
size="small"
loading={testing}
disabled={testing || loading || busy}
onClick={runTest}
>
Test connection
</Button>
{transport == "stdio" && server?.id && onReinstall ? (
<Button
title="Run the setup command again"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={testing || loading || busy}
onClick={onReinstall}
>
Reinstall
</Button>
) : null}
</Row>
{test ? (
<SettingsStatus status={test.ok ? { success: true, msg: test.msg } : { error: true, msg: test.msg }} />
) : null}
{test?.error || (!test && server?.status == "error" && server.last_error) ? (
<Span className="text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words">
{server.last_error}
{test?.error || server?.last_error}
</Span>
) : null}
{server?.tools.length ? (
{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) => (
{tools.map((tool) => (
<McpToolToggle
key={tool.name}
tool={tool}
@@ -216,7 +263,7 @@ export default function McpServerModal({ server, busy, onReinstall, onSaved, onC
<Checkbox title="On" label="On: bots get its tools" checked={enabled} changeHandler={setEnabled} />
<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}
/>
@@ -5,9 +5,11 @@ 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 ExtensionStatusLine from "../../(partials)/extension-status-line";
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
import checkedAgo from "../../(utils)/checked-ago";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import McpServerModal from "./mcp-server-modal";
@@ -19,8 +21,9 @@ type Props = {
};
/**
* One MCP server: how it's reached, whether it connected, and who gets
* it. Edit and Access open in a modal.
* One MCP server: how it's reached and who gets it, and under it whether
* it connected (when it was last checked, and why it failed). Edit and
* Access open in a modal.
*/
export default function McpServerRow({ server, targets, onChanged, onError }: Props) {
const [open, setOpen] = useState<"edit" | "access">();
@@ -42,12 +45,14 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
onChanged(overview, msg);
};
const tools = `${server.tools.length} ${server.tools.length == 1 ? "tool" : "tools"}`;
const when = server.last_synced_at ? ` · checked ${checkedAgo({ at: server.last_synced_at })}` : "";
const status =
server.status == "ok"
? `${server.tools.length} ${server.tools.length == 1 ? "tool" : "tools"}`
? { text: `Connected · ${tools}${when}` }
: server.status == "error"
? `Error: ${server.last_error}`
: "Not connected yet";
? { text: `Couldn't connect${when}`, error: server.last_error }
: { text: server.enabled ? "Not connected yet: Refresh to connect." : "Not connected yet." };
return (
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
@@ -57,7 +62,6 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
badge={server.enabled ? undefined : "Off"}
subtitle={[
McpTransports.find((t) => t.value == server.transport)?.title,
status,
extensionAccessLabel({ access: server.access }),
]
.filter(Boolean)
@@ -100,6 +104,7 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
/>
) : null}
</SettingsListRow>
<ExtensionStatusLine state={server.status} {...status} />
{open == "edit" ? (
<McpServerModal
server={server}
@@ -37,7 +37,7 @@ export default function McpsSettingsSection() {
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."
description="A server's tools go to the bots and workspaces it's attached to (give one to a bot on its settings page or in its chat); one set to every bot goes to every bot by default. Bots only connect when they call one."
>
{adding ? (
<McpServerModal
@@ -12,6 +12,7 @@ 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";
import CustomToolTestPanel from "./custom-tool-test-panel";
type Props = {
/**
@@ -25,7 +26,8 @@ type Props = {
/**
* 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).
* HTTP request, a script, or a prompt). Test runs the form as it is,
* saved or not, with sample arguments.
*/
export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
const [name, setName] = useState(tool?.name || "");
@@ -45,6 +47,17 @@ export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
const id = `tool-${tool?.id || "new"}`;
/**
* The tool as the form has it, for a test that saves nothing.
*/
const draft = () => {
try {
return { draft: { parameters: JSON.parse(parameters), kind, config: configs[kind] } };
} catch {
return "The arguments schema isn't valid JSON.";
}
};
const submit = async () => {
if (loading) return;
@@ -133,10 +146,11 @@ export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
changeHandler={(value) => setKind(String(value) as CustomToolKind)}
/>
<CustomToolKindFields key={kind} id={id} kind={kind} configs={configs} onChange={setConfigs} />
<CustomToolTestPanel target={draft} />
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
<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}
/>
@@ -5,9 +5,11 @@ 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 ExtensionStatusLine from "../../(partials)/extension-status-line";
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
import checkedAgo from "../../(utils)/checked-ago";
import extensionAccessLabel from "../../(utils)/extension-access-label";
import CustomToolModal from "./custom-tool-modal";
import CustomToolTryModal from "./custom-tool-try-modal";
@@ -16,14 +18,19 @@ type Props = {
tool: PublicCustomTool;
targets: ExtensionTargets;
onChanged: (overview: CustomToolsOverviewPayload, msg: string) => void;
/**
* New page data that shouldn't close what's open (a try's result).
*/
onRefreshed: (overview: CustomToolsOverviewPayload) => void;
onError: (msg: string) => void;
};
/**
* One custom tool: its name, kind and who gets it. Edit, Access and Try
* open in a modal.
* One custom tool: its name, kind and who gets it, and under it how its
* last run went (a bot's call or a try). Edit, Access and Try open in a
* modal.
*/
export default function CustomToolRow({ tool, targets, onChanged, onError }: Props) {
export default function CustomToolRow({ tool, targets, onChanged, onRefreshed, onError }: Props) {
const [open, setOpen] = useState<"edit" | "access" | "try">();
const [busy, setBusy] = useState(false);
@@ -48,6 +55,14 @@ export default function CustomToolRow({ tool, targets, onChanged, onError }: Pro
else onError(res.msg || "Couldn't delete the tool.");
};
const when = tool.last_checked_at ? ` · ${checkedAgo({ at: tool.last_checked_at })}` : "";
const status =
tool.status == "ok"
? { text: `Last run worked${when}` }
: tool.status == "error"
? { text: `Last run failed${when}`, error: tool.last_error }
: { text: targets.can_edit ? "Not run yet: Try it to check it works." : "Not run yet." };
return (
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
<SettingsListRow
@@ -78,6 +93,7 @@ export default function CustomToolRow({ tool, targets, onChanged, onError }: Pro
<SettingsRowAction title={`Delete ${tool.name}`} label="Delete" disabled={busy} onClick={remove} />
) : null}
</SettingsListRow>
<ExtensionStatusLine state={tool.status} {...status} />
{open == "edit" ? (
<CustomToolModal tool={tool} onSaved={saved} onCancel={() => setOpen(undefined)} />
) : null}
@@ -92,7 +108,7 @@ export default function CustomToolRow({ tool, targets, onChanged, onError }: Pro
onCancel={() => setOpen(undefined)}
/>
) : null}
{open == "try" ? <CustomToolTryModal tool={tool} onClose={() => setOpen(undefined)} /> : null}
{open == "try" ? <CustomToolTryModal tool={tool} onTried={onRefreshed} onClose={() => setOpen(undefined)} /> : null}
</Stack>
);
}
@@ -0,0 +1,113 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import CodeEditor from "@/src/components/general/code-editor";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { CustomToolsOverviewPayload, CustomToolTestPayload } from "@/src/types";
import SettingsStatus from "../../(partials)/settings-status";
type Props = {
/**
* What to run: `{ custom_tool_id }` for a saved tool, `{ draft }` for
* the form as it is, or why the form can't be run yet.
*/
target: () => Record<string, unknown> | string;
/**
* The page data after a saved tool's run, with its new status.
*/
onTested?: (overview: CustomToolsOverviewPayload) => void;
};
/**
* Runs a tool once with sample arguments and shows whether it worked and
* what a bot would read. A script runs on the main computer, with general
* envs.
*/
export default function CustomToolTestPanel({ target, onTested }: Props) {
const [args, setArgs] = useState("{}");
const [result, setResult] = useState<{ output: string; ok: boolean }>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const run = async () => {
if (loading) return;
const what = target();
if (typeof what == "string") {
setError(what);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(args || "{}");
} catch {
setError("The test arguments aren't valid JSON.");
return;
}
setLoading(true);
setError(undefined);
setResult(undefined);
const res = await postAdminApi<CustomToolTestPayload>({
path: "tools/test-tool",
body: { ...what, args: parsed },
});
setLoading(false);
if (!res.success || !res.singleRes) {
setError(res.msg || "Couldn't run the tool.");
return;
}
setResult({ output: res.singleRes.output, ok: res.singleRes.ok });
onTested?.(res.singleRes.overview);
};
return (
<Stack className="w-full gap-3! items-stretch">
<CodeEditor
mode="json"
className="h-28"
title="Test arguments"
content={args}
info="Sample arguments, as a bot would send them."
onChange={(value) => setArgs(value)}
/>
<Button
title="Run it once with these arguments"
type="button"
variant="outlined"
color="gray"
size="small"
className="self-start"
loading={loading}
disabled={loading}
onClick={run}
>
Test
</Button>
<SettingsStatus
status={
error
? { error: true, msg: error }
: result
? result.ok
? { success: true, msg: "It worked." }
: { error: true, msg: "It failed." }
: undefined
}
/>
{result ? (
<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">
{result.output}
</Span>
) : null}
</Stack>
);
}
@@ -1,80 +1,39 @@
import { useState } from "react";
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
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";
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
import CustomToolTestPanel from "./custom-tool-test-panel";
type Props = {
tool: PublicCustomTool;
/**
* The page data with the tool's new status.
*/
onTried: (overview: CustomToolsOverviewPayload) => void;
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.
* Runs a saved tool once with sample arguments (`CustomToolTestPanel`).
* The run becomes the tool's status.
*/
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);
};
export default function CustomToolTryModal({ tool, onTried, onClose }: Props) {
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."}
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>
<Stack className="w-full gap-4! items-stretch pt-4">
<CustomToolTestPanel target={() => ({ custom_tool_id: tool.id })} onTested={onTried} />
<Row className="justify-end">
<Button title="Close" type="button" variant="ghost" color="gray" size="small" onClick={onClose}>
Close
</Button>
</Row>
</Stack>
</AddModalShell>
);
}
@@ -37,7 +37,7 @@ export default function ToolsSettingsSection() {
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."
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 (give one to a bot on its settings page or in its chat); one set to every bot goes to every bot by default."
>
{adding ? (
<CustomToolModal
@@ -55,6 +55,7 @@ export default function ToolsSettingsSection() {
tool={tool}
targets={overview}
onChanged={changed}
onRefreshed={setOverview}
onError={failed}
/>
))}
@@ -2,6 +2,7 @@ import { useContext, useState } from "react";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import ExtensionsGroup from "../../../(partials)/extensions-group";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell";
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
@@ -12,7 +13,8 @@ import WorkspaceImageGroup from "../(partials)/workspace-image-group";
import WorkspaceModels from "../(partials)/workspace-models";
/**
* A workspace's settings: its picture, context, bots, computers and models,
* A workspace's settings: its picture, context, bots, computers, tools
* and MCP servers, and models,
* then deleting it. Super admins and admins manage it; a member with no
* bots sees an empty note.
*/
@@ -62,6 +64,12 @@ export default function WorkspaceOverview() {
computers={pageProps?.workspace_computers || []}
all_computers={pageProps?.computers || []}
/>
<ExtensionsGroup
owner={{ workspace_id }}
owner_name={name}
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here."
initial={pageProps?.workspace_extensions}
/>
{harnesses?.length ? (
<>
<SettingsSectionHeading
@@ -2,6 +2,7 @@ import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
@@ -9,8 +10,8 @@ import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-w
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
/**
* One workspace's settings: its picture, details, bots, computers and
* models. For super admins and admins.
* One workspace's settings: its picture, details, bots, computers, tools
* and MCP servers, and models. For super admins and admins.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
@@ -41,12 +42,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
workspace_computers,
computers,
workspace_harness_overview,
workspace_extensions,
] = await Promise.all([
grabWorkspaceBots({ user_id, workspace_id }),
grabBots({ user_id }),
grabWorkspaceComputers({ workspace_id }),
grabUserComputers({ user_id }),
grabBotHarnessOverview({ workspace_id }).catch(() => null),
grabWorkspaceExtensionsOverview({ user_id, workspace_id }),
]);
return {
@@ -60,6 +63,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
workspace_computers,
computers,
workspace_harness_overview,
workspace_extensions,
},
};
};
+56
View File
@@ -0,0 +1,56 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview";
import setBotExtension from "@/src/functions/backend/db/extensions/set-bot-extension";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetBotExtensionRequestSchema } from "@/src/schemas/extension-schema";
import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admins and admins (the super admin alone for the coordinator):
* gives a bot an MCP server or tool.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id, kind, id, workspace_id } = parseWithSchema({ schema: SetBotExtensionRequestSchema, data: body });
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: true });
await logEvent({
title: "Bot tool added",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to ${bot.name} (#${bot.id}).`,
metadata: { bot_id, kind, id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Added ${name}.`,
singleRes: await grabBotExtensionsOverview({ user_id: user.id, bot_id, workspace_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,57 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview";
import setBotExtension from "@/src/functions/backend/db/extensions/set-bot-extension";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetBotExtensionRequestSchema } from "@/src/schemas/extension-schema";
import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admins and admins (the super admin alone for the coordinator):
* takes an MCP server or tool from a bot, even one every bot started
* with.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id, kind, id, workspace_id } = parseWithSchema({ schema: SetBotExtensionRequestSchema, data: body });
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: false });
await logEvent({
title: "Bot tool removed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from ${bot.name} (#${bot.id}).`,
metadata: { bot_id, kind, id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Removed ${name}.`,
singleRes: await grabBotExtensionsOverview({ user_id: user.id, bot_id, workspace_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+44
View File
@@ -0,0 +1,44 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import testMcpDraft from "@/src/functions/backend/db/extensions/test-mcp-draft";
import { TestMcpServerRequestSchema } from "@/src/schemas/extension-schema";
import type { McpServerTestPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: connects to an MCP server as the form has it, saved or
* not, and returns its tools or why it didn't connect. Nothing is saved.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<McpServerTestPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const data = parseWithSchema({ schema: TestMcpServerRequestSchema, data: body });
const result = await testMcpDraft({ data });
return {
success: true,
msg: result.ok
? `Connected: ${result.tools.length} ${result.tools.length == 1 ? "tool" : "tools"}.`
: `Couldn't connect.`,
singleRes: result.ok ? { ok: true, tools: result.tools } : { ok: false, tools: [], error: result.error },
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+12 -6
View File
@@ -1,4 +1,5 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
import testCustomTool from "@/src/functions/backend/db/extensions/test-custom-tool";
import { TestCustomToolRequestSchema } from "@/src/schemas/extension-schema";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
@@ -12,8 +13,9 @@ import type {
} from "@moduletrace/bunext/types";
/**
* Super admin only: runs a custom tool once with sample arguments and
* returns what a bot would read.
* Super admin only: runs a custom tool (saved, or as its form has it)
* once with sample arguments and
* returns what a bot would read, with the page data (its new status).
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<CustomToolTestPayload>
@@ -29,19 +31,23 @@ export const handler: BunextAPIRouteHandler<
throw new AppError(`Only a super admin can do that.`);
}
const { custom_tool_id, args, computer_id } = parseWithSchema({ schema: TestCustomToolRequestSchema, data: body });
const output = await testCustomTool({ custom_tool_id, args, computer_id });
const { custom_tool_id, draft, args, computer_id } = parseWithSchema({ schema: TestCustomToolRequestSchema, data: body });
const { output, ok } = await testCustomTool({ custom_tool_id, draft, args, computer_id });
await logEvent({
title: "Tool tried",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} tried the tool #${custom_tool_id}.`,
plain_text: `User #${user.id} tried ${custom_tool_id ? `the tool #${custom_tool_id}` : "a tool's draft"}.`,
metadata: { custom_tool_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: "Ran it.", singleRes: { output } };
return {
success: true,
msg: ok ? "It worked." : "It failed.",
singleRes: { output, ok, overview: await grabToolsOverview({ user_id: user.id }) },
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
@@ -0,0 +1,56 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
import setWorkspaceExtension from "@/src/functions/backend/db/extensions/set-workspace-extension";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetWorkspaceExtensionRequestSchema } from "@/src/schemas/extension-schema";
import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admins and admins: gives a workspace an MCP server or tool, which
* every bot working there gets, there.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { workspace_id, kind, id } = parseWithSchema({ schema: SetWorkspaceExtensionRequestSchema, data: body });
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: true });
await logEvent({
title: "Workspace tool added",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to the workspace ${workspace.name} (#${workspace.id}).`,
metadata: { workspace_id, kind, id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Added ${name}.`,
singleRes: await grabWorkspaceExtensionsOverview({ user_id: user.id, workspace_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,55 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
import setWorkspaceExtension from "@/src/functions/backend/db/extensions/set-workspace-extension";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetWorkspaceExtensionRequestSchema } from "@/src/schemas/extension-schema";
import type { BotExtensionsPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admins and admins: takes an MCP server or tool from a workspace.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { workspace_id, kind, id } = parseWithSchema({ schema: SetWorkspaceExtensionRequestSchema, data: body });
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: false });
await logEvent({
title: "Workspace tool removed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from the workspace ${workspace.name} (#${workspace.id}).`,
metadata: { workspace_id, kind, id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Removed ${name}.`,
singleRes: await grabWorkspaceExtensionsOverview({ user_id: user.id, workspace_id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -15,7 +15,7 @@ export default function LoginForm() {
return (
<LoginShell status={status}>
<GoogleAuthSection {...init} />
{/* <GoogleAuthSection {...init} /> */}
<Form
className="w-full flex items-center justify-center relative gap-4"
onSubmit={() => {