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:
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
+9
-1
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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 }) };
|
||||
}
|
||||
};
|
||||
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user