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
+4 -4
View File
@@ -132,9 +132,9 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Per-bot models:** in a workspace, its `workspace_harness_settings` row wins (`source: "workspace"`). Otherwise a worker bot inherits the defaults per harness unless it has a `bot_harness_settings` row (same fields as `harness_settings`, unique per bot and harness). `grabHarnessSetting({ harness, bot_id })` returns the bot's row (`source: "bot"`) or the default; the coordinator always gets the default. Every entry point takes `bot_id` and passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved by `saveBotHarnessSetting` (`inherit` deletes the row); the page data is `grabBotHarnessOverview`. A bot's chat header has a ghost model icon (`(partials)/header-icon-button.tsx`; the text model and provider show under the bot's name) that opens a modal with every harness (`(partials)/bot-models-modal.tsx`); the modal and `/admin/bots/[bot_id]/models` both use `(partials)/bot-harness-form.tsx`. On the coordinator the modal edits the defaults themselves (`api/admin/harness/save-settings`, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wraps `settings/models/(partials)/harness-settings-form.tsx`: per harness a provider select first (`harness-provider-select.tsx`: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (`deleteHarnessProvider`) puts default and bot settings that picked it or its models back on any provider (`save` prop; "Same as the default" shows when the overview item has `default`).
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). Each goes to the bots it's attached to (`bot_mcp_servers`, `bot_custom_tools`), to every bot in a workspace there (`workspace_mcp_servers`, `workspace_custom_tools`), or to every bot (`all_bots`, the coordinator included); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. Super admins define them; super admins and admins attach them (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts).
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: chips for everything it can use there, a workspace's not removable, and an Add modal), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
- **API:** `api/admin/harness/` (`save-provider` also syncs, `sync-provider`, `delete-provider`, `save-settings`), super admin only, each returning the fresh `grabHarnessOverview()` page data.
@@ -149,7 +149,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
- Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
+17 -1
View File
@@ -21,7 +21,7 @@ import { AppData } from "@/src/data/app-data";
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
import { EnvParadigms } from "@/src/dict/env-dict";
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-dict";
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
import { CustomToolKinds, CustomToolStatuses } from "@/src/dict/custom-tool-dict";
const schema: BUN_SQLITE_DatabaseSchemaType = {
tables: [
@@ -1827,6 +1827,22 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
options: [0, 1],
defaultValue: 1,
},
{
fieldName: "status",
fieldDescription: `How its last run went (a bot's call or a try). Reset to untested when it's edited`,
dataType: "TEXT",
options: CustomToolStatuses.map((s) => s.value),
defaultValue: "untested",
},
{
fieldName: "last_error",
dataType: "TEXT",
},
{
fieldName: "last_checked_at",
fieldDescription: `Unix ms of its last run`,
dataType: "INTEGER",
},
{
fieldName: "user_id",
fieldDescription: `Who created it. Not a foreign key: it belongs to the install`,
+9
View File
@@ -1132,6 +1132,15 @@ export type BUN_SQLITE_COBBLE_CUSTOM_TOOLS = {
*/
all_bots?: 0 | 1 | "";
enabled?: 0 | 1 | "";
/**
* How its last run went (a bot's call or a try). Reset to untested when it's edited
*/
status?: "untested" | "ok" | "error" | "";
last_error?: string;
/**
* Unix ms of its last run
*/
last_checked_at?: number | "";
/**
* Who created it. Not a foreign key: it belongs to the install
*/
@@ -0,0 +1,31 @@
import type { BotExtensionsPayload } from "@/src/types";
import { extensionOptionValue, parseExtensionOptionValue } from "@/src/utils/extension-option-value";
import InlineAddSelect from "./inline-add-select";
type Props = {
id: string;
addable: BotExtensionsPayload["addable"];
loading?: boolean;
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => void;
};
/**
* Picks an MCP server or tool to give a bot.
*/
export default function ExtensionAddSelect({ id, addable, loading, onAdd }: Props) {
return (
<InlineAddSelect
id={id}
noun="tool or MCP server"
options={addable.map((item) => ({
value: extensionOptionValue(item),
title: `${item.kind == "mcp" ? "MCP" : "Tool"} · ${item.name}`,
}))}
loading={loading}
onAdd={({ value }) => {
const picked = parseExtensionOptionValue({ value });
if (picked) onAdd(picked);
}}
/>
);
}
@@ -0,0 +1,9 @@
import { Plug, Wrench } from "lucide-react";
import type { BotExtension } from "@/src/types";
/**
* The icon of an MCP server (a plug) or a tool (a wrench).
*/
export default function extensionIcon({ kind }: { kind: BotExtension["kind"] }) {
return kind == "mcp" ? Plug : Wrench;
}
+13
View File
@@ -23,6 +23,19 @@ export const CustomToolKinds = [
export type CustomToolKind = (typeof CustomToolKinds)[number]["value"];
/**
* How a tool's last run went (a bot's call or a try from its settings):
* `ok`, `error` (see its `last_error`), or `untested` before its first run
* and after it's edited. Bad arguments from a bot don't count.
*/
export const CustomToolStatuses = [
{ title: "Not run yet", value: "untested" },
{ title: "Worked", value: "ok" },
{ title: "Failed", value: "error" },
] as const;
export type CustomToolStatus = (typeof CustomToolStatuses)[number]["value"];
export const CustomToolHttpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
export type CustomToolHttpMethod = (typeof CustomToolHttpMethods)[number];
@@ -2,6 +2,7 @@ import type { PagePropsType } from "@/src/types";
import grabBots from "../bots/grab-bots";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
import grabBotComputers from "../computers/grab-bot-computers";
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
import grabHarnessOverview from "../harness/grab-harness-overview";
@@ -23,7 +24,8 @@ type Params = {
* The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its messages, its models, computers and active task there.
* read) and its messages, its models, computers, tools and MCP servers
* and active task there.
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationProps({
@@ -62,7 +64,7 @@ export default async function grabConversationProps({
* workspace the workspace's own models come first, so what runs now
* shows its pick.
*/
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
const [bot_messages, bot_harness_overview, bot_computers, bot_task, bot_extensions] = await Promise.all([
grabBotMessages({ session_id: session.id! }),
(bot.kind == "coordinator" && !scope
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
@@ -73,6 +75,7 @@ export default async function grabConversationProps({
).catch(() => null),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
]);
return {
@@ -85,5 +88,6 @@ export default async function grabConversationProps({
bot_harness_overview,
bot_computers,
bot_task,
bot_extensions,
};
}
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import type { BotHue } from "@/src/dict/bot-dict";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
import attachDefaultExtensions from "../extensions/attach-default-extensions";
type Params = {
/**
@@ -53,5 +54,7 @@ export default async function createBot({ user_id, name, role, hue, system_promp
throw new Error(`Couldn't add the bot: ${res.msg}`);
}
await attachDefaultExtensions({ bot_id });
return Number(bot_id);
}
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import { CoordinatorBotName } from "@/src/dict/bot-dict";
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
import attachDefaultExtensions from "../extensions/attach-default-extensions";
type Params = {
/**
@@ -66,5 +67,7 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
throw new Error(`Couldn't create the coordinator: ${res.msg}`);
}
await attachDefaultExtensions({ bot_id });
return bot_id;
}
@@ -0,0 +1,31 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabCrewRole from "../access/grab-crew-role";
type Params = {
user_id: number | string;
bot_id: number | string;
};
/**
* Throws unless the user manages this bot's MCP servers and tools: super
* admins and admins, the super admin alone for the coordinator. Returns
* the bot.
*/
export default async function assertBotExtensionManager({ user_id, bot_id }: Params) {
const role = await grabCrewRole({ user_id });
const bot = (
await BunSQLite.sql<{ id: number; kind: string; name: string }>({
sql: `SELECT id, kind, name FROM bots WHERE id = ?`,
values: [bot_id],
})
).payload?.[0];
if (!bot) throw new AppError(`That bot doesn't exist.`);
if (role == "member" || (bot.kind == "coordinator" && role != "super_admin")) {
throw new AppError(`Only an admin can change a bot's tools.`);
}
return bot;
}
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
};
/**
* Gives a new bot every MCP server and tool set to "every bot". Called
* when a bot is created; it can drop any of them afterwards.
*/
export default async function attachDefaultExtensions({ bot_id }: Params) {
const now = Date.now();
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_mcp_servers (created_at, updated_at, bot_id, mcp_server_id)
SELECT ?, ?, ?, id FROM mcp_servers WHERE all_bots = 1`,
values: [now, now, Number(bot_id)],
});
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO bot_custom_tools (created_at, updated_at, bot_id, custom_tool_id)
SELECT ?, ?, ?, id FROM custom_tools WHERE all_bots = 1`,
values: [now, now, Number(bot_id)],
});
}
@@ -0,0 +1,22 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
type Params = {
kind: ExtensionKind;
id: number;
};
/**
* Gives an MCP server or tool set to "every bot" to every bot there is,
* the coordinator included. Each can still drop it afterwards.
*/
export default async function attachToAllBots({ kind, id }: Params) {
const { bot_link, column } = ExtensionTables[kind];
const now = Date.now();
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO ${bot_link} (created_at, updated_at, bot_id, ${column})
SELECT ?, ?, id, ? FROM bots`,
values: [now, now, id],
});
}
@@ -0,0 +1,39 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotExtensionsPayload } from "@/src/types";
import grabCrewRole from "../access/grab-crew-role";
import grabExtensionsOverview from "./grab-extensions-overview";
type Params = {
/**
* Who's looking: only super admins and admins (the super admin alone
* for the coordinator) get what can be added.
*/
user_id: number | string;
bot_id: number | string;
/**
* The workspace the bot is seen in: its MCP servers and tools show
* too, not removable from the bot.
*/
workspace_id?: number | string | null;
};
/**
* A bot's MCP servers and tools (given to it, and its workspace's there),
* and the rest of them, which can be added.
*/
export default async function grabBotExtensionsOverview({
user_id,
bot_id,
workspace_id,
}: Params): Promise<BotExtensionsPayload> {
const [role, bot] = await Promise.all([
grabCrewRole({ user_id }),
BunSQLite.sql<{ kind: string }>({ sql: `SELECT kind FROM bots WHERE id = ?`, values: [bot_id] }),
]);
return grabExtensionsOverview({
can_manage: role == "super_admin" || (role == "admin" && bot.payload?.[0]?.kind != "coordinator"),
bot_id,
workspace_id,
});
}
@@ -0,0 +1,118 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
import { McpTransports } from "@/src/dict/mcp-dict";
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row";
type Params = {
/**
* Whether the viewer can give and take them: only then is what can be
* added listed.
*/
can_manage: boolean;
/**
* A bot's: given to it, and (with `workspace_id`) that workspace's
* too, not removable from the bot. Without it, only the workspace's.
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
};
type Linked<T> = T & { source: "bot" | "workspace" };
/**
* The MCP servers and tools a bot or a workspace has, and the rest of
* them, which can be added (`grabBotExtensionsOverview`,
* `grabWorkspaceExtensionsOverview`).
*/
export default async function grabExtensionsOverview({
can_manage,
bot_id,
workspace_id,
}: Params): Promise<BotExtensionsPayload> {
const bot = Number(bot_id || 0);
const workspace = Number(workspace_id || 0);
const linked = (column: string, bot_link: string, workspace_link: string) => ({
sql: `SELECT x.*, CASE WHEN x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?) THEN 'bot' ELSE 'workspace' END AS source
FROM %TABLE% x WHERE x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)
ORDER BY x.name COLLATE NOCASE ASC`,
values: [bot, bot, workspace],
});
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
const [servers, tools, all_servers, all_tools] = await Promise.all([
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
values: mcp_query.values,
}),
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>>({
sql: tool_query.sql.replace("%TABLE%", "custom_tools"),
values: tool_query.values,
}),
can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
: null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` })
: null,
]);
const attached: BotExtension[] = [
...(servers.payload || []).map((row) => {
const server = parseMcpServerRow({ row });
const transport = McpTransports.find((t) => t.value == server.transport)?.value == "stdio" ? "Command" : "Remote";
const count = server.tools.filter((tool) => !server.excluded_tools.includes(tool.name)).length;
return {
kind: "mcp" as const,
id: server.id,
name: server.name,
description: server.description,
detail: `MCP · ${transport}${server.status == "ok" ? ` · ${count} ${count == 1 ? "tool" : "tools"}` : ""}`,
state: server.status,
enabled: server.enabled,
source: row.source,
};
}),
...(tools.payload || []).map((row) => {
const tool = parseCustomToolRow({ row });
return {
kind: "tool" as const,
id: tool.id,
name: tool.name,
description: tool.description,
detail: `Tool · ${CustomToolKinds.find((k) => k.value == tool.kind)?.title || tool.kind}`,
state: tool.status,
enabled: tool.enabled,
source: row.source,
};
}),
];
/**
* A bot can be given one its workspace has; a workspace only what it
* hasn't.
*/
const has = (kind: "mcp" | "tool", id: number) =>
attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot"));
return {
attached,
addable: [
...(all_servers?.payload || [])
.filter((row) => !has("mcp", Number(row.id)))
.map((row) => ({ kind: "mcp" as const, id: Number(row.id), name: row.name })),
...(all_tools?.payload || [])
.filter((row) => !has("tool", Number(row.id)))
.map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })),
],
can_manage,
};
}
@@ -34,6 +34,9 @@ export default async function grabToolsOverview({ user_id }: Params): Promise<Cu
description: tool.description,
kind: tool.kind,
enabled: tool.enabled,
status: tool.status,
last_error: tool.last_error,
last_checked_at: tool.last_checked_at,
access: { all_bots: tool.all_bots, ...access(tool.id) },
...(targets.can_edit ? { parameters: tool.parameters, config: tool.config } : {}),
};
@@ -0,0 +1,24 @@
import type { BotExtensionsPayload } from "@/src/types";
import grabCrewRole from "../access/grab-crew-role";
import grabExtensionsOverview from "./grab-extensions-overview";
type Params = {
/**
* Who's looking: only super admins and admins get what can be added.
*/
user_id: number | string;
workspace_id: number | string;
};
/**
* The MCP servers and tools every bot working in a workspace gets there,
* and the rest of them, which can be added.
*/
export default async function grabWorkspaceExtensionsOverview({
user_id,
workspace_id,
}: Params): Promise<BotExtensionsPayload> {
const role = await grabCrewRole({ user_id });
return grabExtensionsOverview({ can_manage: role != "member", workspace_id });
}
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS } from "@/db/types/db";
import type { CustomToolStatus } from "@/src/dict/custom-tool-dict";
import type { CustomToolRecord } from "@/src/types";
import parseJsonColumn from "./parse-json-column";
@@ -20,6 +21,9 @@ export default function parseCustomToolRow({ row }: Params): CustomToolRecord {
}),
all_bots: row.all_bots == 1,
enabled: row.enabled != 0,
status: (row.status || "untested") as CustomToolStatus,
last_error: row.last_error || "",
last_checked_at: typeof row.last_checked_at == "number" ? row.last_checked_at : null,
kind: row.kind || "prompt",
config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }),
} as CustomToolRecord;
@@ -0,0 +1,23 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
custom_tool_id: number;
/**
* Left out when it worked.
*/
error?: string;
};
/**
* Saves how a custom tool's run went (`status`, `last_error`,
* `last_checked_at`), for its row in Settings, under Tools. Never throws:
* a run doesn't fail over its bookkeeping.
*/
export default async function recordCustomToolRun({ custom_tool_id, error }: Params) {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE custom_tools SET status = ?, last_error = ?, last_checked_at = ?, updated_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, custom_tool_id],
}).catch(() => {});
}
@@ -0,0 +1,23 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
mcp_server_id: number;
/**
* Left out when it connected.
*/
error?: string;
};
/**
* Saves whether a bot's run could connect to an MCP server, so its row in
* Settings, under MCPs, shows a server that stopped answering (or came
* back). Its tools listing is left as it was. Never throws.
*/
export default async function recordMcpServerConnection({ mcp_server_id, error }: Params) {
const now = Date.now();
await BunSQLite.sql({
sql: `UPDATE mcp_servers SET status = ?, last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`,
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, mcp_server_id],
}).catch(() => {});
}
@@ -1,4 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import attachToAllBots from "./attach-to-all-bots";
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BunSQLiteTables } from "@/db/types/db";
import type { SaveCustomToolRequest } from "@/src/schemas/extension-schema";
import AppError from "@/src/utils/app-error";
@@ -33,6 +34,11 @@ export default async function saveCustomTool({ user_id, data }: Params) {
config: JSON.stringify(data.config),
enabled: data.enabled ? 1 : 0,
all_bots: data.all_bots ? 1 : 0,
/**
* Whatever its last run showed was about the old version.
*/
status: "untested",
last_error: "",
} as const;
if (data.custom_tool_id) {
@@ -45,6 +51,8 @@ export default async function saveCustomTool({ user_id, data }: Params) {
if (!res.success) throw new Error(`Couldn't save the tool: ${res.msg}`);
if (data.all_bots && !tool.all_bots) await attachToAllBots({ kind: "tool", id: tool.id });
return tool.id;
}
@@ -57,5 +65,7 @@ export default async function saveCustomTool({ user_id, data }: Params) {
if (!res.success || !id) throw new Error(`Couldn't add the tool: ${res.msg}`);
if (data.all_bots) await attachToAllBots({ kind: "tool", id: Number(id) });
return Number(id);
}
@@ -1,4 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import attachToAllBots from "./attach-to-all-bots";
import type { BUN_SQLITE_COBBLE_MCP_SERVERS, BunSQLiteTables } from "@/db/types/db";
import { stopStdioMcpServer } from "@/src/harness/mcp/mcp-stdio-pool";
import type { SaveMcpServerRequest } from "@/src/schemas/extension-schema";
@@ -47,6 +48,8 @@ export default async function saveMcpServer({ user_id, data }: Params) {
if (!res.success) throw new Error(`Couldn't save the MCP server: ${res.msg}`);
if (data.all_bots && !server.all_bots) await attachToAllBots({ kind: "mcp", id: server.id });
return server.id;
}
@@ -67,6 +70,8 @@ export default async function saveMcpServer({ user_id, data }: Params) {
if (!res.success || !id) throw new Error(`Couldn't add the MCP server: ${res.msg}`);
if (data.all_bots) await attachToAllBots({ kind: "mcp", id: Number(id) });
return Number(id);
}
@@ -0,0 +1,46 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertBotExtensionManager from "./assert-bot-extension-manager";
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
type Params = {
user_id: number | string;
bot_id: number | string;
kind: ExtensionKind;
id: number;
/**
* Gives it to the bot, or takes it away.
*/
attached: boolean;
};
/**
* Gives a bot an MCP server or tool, or takes it away, even one set to
* "every bot" (that only decides what bots start with). What its
* workspaces have stays theirs. Returns the bot and the item's name.
*/
export default async function setBotExtension({ user_id, bot_id, kind, id, attached }: Params) {
const bot = await assertBotExtensionManager({ user_id, bot_id });
const { table, bot_link, column, label } = ExtensionTables[kind];
const item = (
await BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM ${table} WHERE id = ?`, values: [id] })
).payload?.[0];
if (!item) throw new AppError(`That ${label} doesn't exist.`);
const now = Date.now();
const res = attached
? await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO ${bot_link} (created_at, updated_at, bot_id, ${column}) VALUES (?, ?, ?, ?)`,
values: [now, now, bot.id, id],
})
: await BunSQLite.sql({
sql: `DELETE FROM ${bot_link} WHERE bot_id = ? AND ${column} = ?`,
values: [bot.id, id],
});
if (!res.success) throw new Error(`Couldn't change the bot's ${label}s: ${res.error}`);
return { bot, name: item.name };
}
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertCrewManager from "../access/assert-crew-manager";
import attachToAllBots from "./attach-to-all-bots";
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
type Params = {
@@ -18,13 +19,18 @@ type Params = {
/**
* Attaches an MCP server or tool to exactly these bots and workspaces, for
* super admins and admins. An admin never sees the coordinator, so its
* attachment is kept as it is; only a super admin sets "all bots".
* attachment is kept as it is; only a super admin sets "every bot", which
* when turned on gives it to every bot (`attachToAllBots`) and every new
* one. Turning it off takes it from no one.
*/
export default async function setExtensionAccess({ user_id, kind, id, bot_ids, workspace_ids, all_bots }: Params) {
const role = await assertCrewManager({ user_id });
const { table, bot_link, workspace_link, column, label } = ExtensionTables[kind];
const found = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM ${table} WHERE id = ?`, values: [id] });
const found = await BunSQLite.sql<{ id: number; all_bots: number }>({
sql: `SELECT id, all_bots FROM ${table} WHERE id = ?`,
values: [id],
});
if (!found.payload?.[0]) throw new AppError(`That ${label} doesn't exist.`);
@@ -55,5 +61,7 @@ export default async function setExtensionAccess({ user_id, kind, id, bot_ids, w
sql: `UPDATE ${table} SET all_bots = ?, updated_at = ? WHERE id = ?`,
values: [all_bots ? 1 : 0, now, id],
});
if (all_bots && found.payload?.[0]?.all_bots != 1) await attachToAllBots({ kind, id });
}
}
@@ -0,0 +1,55 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertCrewManager from "../access/assert-crew-manager";
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
type Params = {
user_id: number | string;
workspace_id: number | string;
kind: ExtensionKind;
id: number;
/**
* Gives it to the workspace, or takes it away.
*/
attached: boolean;
};
/**
* Gives a workspace an MCP server or tool (every bot working there gets
* it, there), or takes it away; for super admins and admins. Returns the
* workspace and the item's name.
*/
export default async function setWorkspaceExtension({ user_id, workspace_id, kind, id, attached }: Params) {
await assertCrewManager({ user_id });
const { table, workspace_link, column, label } = ExtensionTables[kind];
const [workspace, item] = await Promise.all([
BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM workspaces WHERE id = ?`,
values: [workspace_id],
}).then((res) => res.payload?.[0]),
BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM ${table} WHERE id = ?`,
values: [id],
}).then((res) => res.payload?.[0]),
]);
if (!workspace) throw new AppError(`That workspace doesn't exist.`);
if (!item) throw new AppError(`That ${label} doesn't exist.`);
const now = Date.now();
const res = attached
? await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO ${workspace_link} (created_at, updated_at, workspace_id, ${column}) VALUES (?, ?, ?, ?)`,
values: [now, now, workspace.id, id],
})
: await BunSQLite.sql({
sql: `DELETE FROM ${workspace_link} WHERE workspace_id = ? AND ${column} = ?`,
values: [workspace.id, id],
});
if (!res.success) throw new Error(`Couldn't change the workspace's ${label}s: ${res.error}`);
return { workspace, name: item.name };
}
@@ -2,11 +2,20 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import { AppData } from "@/src/data/app-data";
import buildCustomTool from "@/src/harness/tools/custom/build-custom-tool";
import AppError from "@/src/utils/app-error";
import type { DraftCustomTool } from "@/src/schemas/extension-schema";
import type { CustomToolRecord } from "@/src/types";
import grabCustomTool from "./grab-custom-tool";
import grabRunEnvs from "./grab-run-envs";
type Params = {
custom_tool_id: number | string;
/**
* A saved tool: the run counts as its last one.
*/
custom_tool_id?: number | string;
/**
* The tool as its form has it, saved or not: nothing is recorded.
*/
draft?: DraftCustomTool;
args: Record<string, unknown>;
/**
* Where a script tool runs. The main computer when not given.
@@ -17,23 +26,38 @@ type Params = {
/**
* Runs a custom tool once with these arguments, as a bot would outside a
* workspace (general envs, the main computer for a script), and returns
* what the bot would read: its result, or `Error: ...`.
* what the bot would read (its result, or `Error: ...`) and whether it
* worked. A saved tool's run counts as its last one (`buildCustomTool`);
* a draft's isn't recorded.
*/
export default async function testCustomTool({ custom_tool_id, args, computer_id }: Params) {
const tool = await grabCustomTool({ custom_tool_id });
export default async function testCustomTool({ custom_tool_id, draft, args, computer_id }: Params) {
const tool: CustomToolRecord = draft
? ({
...draft,
id: 0,
name: "draft",
description: "",
all_bots: false,
enabled: true,
status: "untested",
last_error: "",
last_checked_at: null,
} as CustomToolRecord)
: await grabCustomTool({ custom_tool_id: custom_tool_id || 0 });
const main =
computer_id ||
(await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM computers WHERE kind = 'main' ORDER BY id LIMIT 1` }))
.payload?.[0]?.id;
const harness_tool = buildCustomTool({ tool, envs: await grabRunEnvs(), computer_id: main });
const harness_tool = buildCustomTool({ tool, envs: await grabRunEnvs(), computer_id: main, record: !draft });
if (!harness_tool) throw new AppError(`There's no computer to run this script on.`);
try {
return await harness_tool.run({ args, signal: AbortSignal.timeout(AppData.CustomToolMaxTimeoutMs) });
const output = await harness_tool.run({ args, signal: AbortSignal.timeout(AppData.CustomToolMaxTimeoutMs) });
return { output, ok: true };
} catch (error: unknown) {
return `Error: ${error instanceof Error ? error.message : String(error)}`;
return { output: `Error: ${error instanceof Error ? error.message : String(error)}`, ok: false };
}
}
@@ -0,0 +1,56 @@
import testMcpServer from "@/src/harness/mcp/test-mcp-server";
import type { TestMcpServerRequest } from "@/src/schemas/extension-schema";
import type { McpToolInfo } from "@/src/types";
import mcpServerSlug from "@/src/utils/mcp-server-slug";
import redactSecrets from "@/src/utils/redact-secrets";
import grabRunEnvs from "./grab-run-envs";
type Params = {
data: TestMcpServerRequest;
};
/**
* Connects to an MCP server as its form has it (saved or not) and lists
* its tools, saving nothing. Never throws for the server's own failure:
* returns why, secrets blanked out.
*/
export default async function testMcpDraft({
data,
}: Params): Promise<{ ok: true; tools: McpToolInfo[] } | { ok: false; error: string }> {
const envs = await grabRunEnvs();
try {
const tools = await testMcpServer({
server: {
id: data.mcp_server_id || 0,
name: data.name || "The MCP server",
slug: mcpServerSlug({ name: data.name }),
description: data.description,
transport: data.transport,
url: data.url,
headers: data.headers,
command: data.command,
setup_command: data.setup_command,
env: data.env,
excluded_tools: data.excluded_tools,
tools: [],
all_bots: data.all_bots,
enabled: data.enabled,
status: "untested",
last_error: "",
last_synced_at: null,
},
envs,
});
return { ok: true, tools };
} catch (error: unknown) {
return {
ok: false,
error: redactSecrets({
text: error instanceof Error ? error.message : String(error),
secrets: envs.filter((env) => env.secret),
}).slice(0, 4000),
};
}
}
+7 -38
View File
@@ -1,12 +1,9 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { AppData } from "@/src/data/app-data";
import { SiteData } from "@/src/data/site-data";
import type { McpServerRecord } from "@/src/types";
import grabMcpDir from "./grab-mcp-dir";
import grabMcpStdioEnv from "./grab-mcp-stdio-env";
import hostShell from "./host-shell";
import runMcpSetup from "./run-mcp-setup";
import startStdioMcpClient from "./start-stdio-mcp-client";
type Entry = {
/**
@@ -31,11 +28,9 @@ type Params = {
};
/**
* A connected client for a stdio MCP server, started on first use (after
* its setup, `runMcpSetup`) with the host's shell in its directory, and
* kept running for later calls until it's idle for `AppData.McpStdioIdleMs`,
* its configuration changes, or it exits. Its stderr's last lines explain
* a failed start.
* A connected client for a stdio MCP server, started on first use
* (`startStdioMcpClient`) and kept running for later calls until it's idle for `AppData.McpStdioIdleMs`,
* its configuration changes, or it exits.
*/
export default async function grabStdioMcpClient({ server, envs }: Params): Promise<Client> {
const env = grabMcpStdioEnv({ server, envs });
@@ -77,25 +72,10 @@ export function stopStdioMcpServer({ server_id }: { server_id: number }) {
}
async function start({ server, env, entry }: { server: McpServerRecord; env: Record<string, string>; entry: Entry }) {
await runMcpSetup({ server, env });
const transport = new StdioClientTransport({
command: hostShell(),
args: ["-c", server.command],
cwd: await grabMcpDir({ server_id: server.id }),
env,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk: Buffer) => {
stderr = `${stderr}${chunk.toString()}`.slice(-4000);
});
const { client, transport } = await startStdioMcpClient({ server, env });
entry.transport = transport;
const client = new Client({ name: SiteData["SiteSlug"], version: "1.0.0" });
client.onclose = () => {
if (pool.get(server.id) == entry) {
pool.delete(server.id);
@@ -103,17 +83,6 @@ async function start({ server, env, entry }: { server: McpServerRecord; env: Rec
}
};
try {
await client.connect(transport, { timeout: AppData.McpConnectTimeoutMs * 4 });
} catch (error) {
await client.close().catch(() => {});
throw new Error(
`Couldn't start ${server.name}: ${error instanceof Error ? error.message : String(error)}${
stderr.trim() ? `\n${stderr.trim().slice(-2000)}` : ""
}`,
);
}
return client;
}
+30 -3
View File
@@ -1,4 +1,5 @@
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import recordMcpServerConnection from "@/src/functions/backend/db/extensions/record-mcp-server-connection";
import type { McpServerRecord } from "@/src/types";
import fillTemplate from "@/src/utils/fill-template";
import connectMcpServer from "./connect-mcp-server";
@@ -17,13 +18,39 @@ type Params = {
* How one run reaches an MCP server, without connecting yet: `client()`
* connects on the first call and is reused after. A remote server gets
* its own connection for the run (closed by `close`); a stdio server is
* shared through the process's pool and keeps running.
* shared through the process's pool and keeps running. Whether it
* connected is saved on the server when that changes
* (`recordMcpServerConnection`).
*/
export default function openMcpServer({ server, envs, signal }: Params) {
let remote: Promise<Client> | undefined;
let recorded = false;
/**
* Once per run: an error every time (its message may change), a
* success only when the server was failing.
*/
const record = <T>(connecting: Promise<T>) =>
connecting.then(
async (client) => {
if (!recorded && server.status != "ok") await recordMcpServerConnection({ mcp_server_id: server.id });
recorded = true;
return client;
},
async (error: unknown) => {
if (!signal?.aborted) {
await recordMcpServerConnection({
mcp_server_id: server.id,
error: error instanceof Error ? error.message : String(error),
});
}
throw error;
},
);
const client = () => {
if (server.transport == "stdio") return grabStdioMcpClient({ server, envs });
if (server.transport == "stdio") return record(grabStdioMcpClient({ server, envs }));
remote ||= connectMcpServer({
url: fillTemplate({ template: server.url, envs }),
@@ -36,7 +63,7 @@ export default function openMcpServer({ server, envs, signal }: Params) {
throw error;
});
return remote;
return record(remote);
};
const close = async () => {
+54
View File
@@ -0,0 +1,54 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { AppData } from "@/src/data/app-data";
import { SiteData } from "@/src/data/site-data";
import type { McpServerRecord } from "@/src/types";
import grabMcpDir from "./grab-mcp-dir";
import hostShell from "./host-shell";
import runMcpSetup from "./run-mcp-setup";
type Params = {
server: McpServerRecord;
/**
* Its environment (`grabMcpStdioEnv`).
*/
env: Record<string, string>;
};
/**
* Starts a stdio MCP server after its setup (`runMcpSetup`), with the
* host's shell in its directory, and connects to it. A failed start
* throws with the last lines it wrote to stderr. Closing the client stops
* it.
*/
export default async function startStdioMcpClient({ server, env }: Params) {
await runMcpSetup({ server, env });
const transport = new StdioClientTransport({
command: hostShell(),
args: ["-c", server.command],
cwd: await grabMcpDir({ server_id: server.id }),
env,
stderr: "pipe",
});
let stderr = "";
transport.stderr?.on("data", (chunk: Buffer) => {
stderr = `${stderr}${chunk.toString()}`.slice(-4000);
});
const client = new Client({ name: SiteData["SiteSlug"], version: "1.0.0" });
try {
await client.connect(transport, { timeout: AppData.McpConnectTimeoutMs * 4 });
} catch (error) {
await client.close().catch(() => {});
throw new Error(
`Couldn't start ${server.name}: ${error instanceof Error ? error.message : String(error)}${
stderr.trim() ? `\n${stderr.trim().slice(-2000)}` : ""
}`,
);
}
return { client, transport };
}
+38
View File
@@ -0,0 +1,38 @@
import type { McpServerRecord } from "@/src/types";
import fillTemplate from "@/src/utils/fill-template";
import connectMcpServer from "./connect-mcp-server";
import grabMcpStdioEnv from "./grab-mcp-stdio-env";
import listMcpTools from "./list-mcp-tools";
import startStdioMcpClient from "./start-stdio-mcp-client";
type Params = {
/**
* As it would be saved; it may not be yet.
*/
server: McpServerRecord;
envs: { key: string; value: string }[];
};
/**
* Connects to an MCP server as configured, lists its tools and
* disconnects, outside the stdio pool, so a form can check a server
* before it's saved. A stdio one runs its setup first, in its own
* directory (a new server's in `mcp/0`). Throws when it can't connect.
*/
export default async function testMcpServer({ server, envs }: Params) {
const client =
server.transport == "stdio"
? (await startStdioMcpClient({ server, env: grabMcpStdioEnv({ server, envs }) })).client
: await connectMcpServer({
url: fillTemplate({ template: server.url, envs }),
headers: Object.fromEntries(
Object.entries(server.headers).map(([key, value]) => [key, fillTemplate({ template: value, envs })]),
),
});
try {
return await listMcpTools({ client });
} finally {
await client.close().catch(() => {});
}
}
+22 -3
View File
@@ -1,3 +1,4 @@
import recordCustomToolRun from "@/src/functions/backend/db/extensions/record-custom-tool-run";
import type { CustomToolRecord } from "@/src/types";
import redactSecrets from "@/src/utils/redact-secrets";
import type { HarnessTool } from "../../types";
@@ -19,12 +20,18 @@ type Params = {
computer_id?: number | string | null;
bot_id?: number | string;
workspace_id?: number | string | null;
/**
* Saves how each run went on the tool. Off for a draft being tried.
*/
record?: boolean;
};
/**
* A custom tool as a harness tool: its name, description and JSON schema
* as the model sees them, and `run` dispatching on its kind (an HTTP
* request, a script on the computer, or a prompt). Null for a script tool
* request, a script on the computer, or a prompt). How each run went is
* saved on the tool (`recordCustomToolRun`); bad arguments and cancelled
* runs aren't the tool's fault and don't count. Null for a script tool
* when the run has no computer.
*/
export default function buildCustomTool({
@@ -33,6 +40,7 @@ export default function buildCustomTool({
computer_id,
bot_id,
workspace_id,
record = true,
}: Params): HarnessTool<Record<string, unknown>> | null {
if (tool.kind == "script" && !computer_id) return null;
@@ -58,11 +66,22 @@ export default function buildCustomTool({
: tool.description,
parameters: tool.parameters,
run: async ({ args, signal }) => {
checkRequiredArgs({ parameters: tool.parameters, args });
try {
checkRequiredArgs({ parameters: tool.parameters, args });
return redact(await call({ args, signal }));
const result = redact(await call({ args, signal }));
if (record) await recordCustomToolRun({ custom_tool_id: tool.id });
return result;
} catch (error: unknown) {
if (error instanceof Error) error.message = redact(error.message);
if (record && !signal.aborted) {
await recordCustomToolRun({
custom_tool_id: tool.id,
error: error instanceof Error ? error.message : redact(String(error)),
});
}
throw error;
}
},
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
type Params = {
/**
* Whose they are: a bot's (`workspace_id` is where it's seen, so that
* workspace's show too) or a workspace's.
*/
owner: { bot_id: number; workspace_id?: number | null } | { workspace_id: number };
/**
* The bot's or workspace's name, for the confirmations.
*/
owner_name: string;
initial?: BotExtensionsPayload | null;
};
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* A bot's or workspace's MCP servers and tools, with adding and removing
* them (each asks first). Shared by the bot and workspace settings pages
* and the chat's composer.
*/
export default function useExtensions({ owner, owner_name, initial }: Params) {
const [data, setData] = useState<BotExtensionsPayload>(
initial || { attached: [], addable: [], can_manage: false },
);
const [busy, setBusy] = useState<string>();
const [status, setStatus] = useState<Status>();
const api = "bot_id" in owner ? "bots" : "workspaces";
const change = async ({
action,
kind,
id,
}: {
action: "add" | "remove";
kind: BotExtension["kind"];
id: number;
}) => {
setBusy(`${kind}:${id}`);
setStatus(undefined);
const res = await postAdminApi<BotExtensionsPayload>({
path: `${api}/${action}-extension`,
body: { ...owner, kind, id },
});
setBusy(undefined);
if (!res.success || !res.singleRes) {
setStatus({ error: true, msg: res.msg || "That didn't work." });
return false;
}
setData(res.singleRes);
setStatus({ success: true, msg: res.msg || "Saved." });
return true;
};
const add = async ({ kind, id }: { kind: BotExtension["kind"]; id: number }) => {
const item = data.addable.find((entry) => entry.kind == kind && entry.id == id);
if (!window.confirm(`Give ${owner_name} ${item?.name || "this"}?`)) return false;
return change({ action: "add", kind, id });
};
const remove = async ({ kind, id, name }: { kind: BotExtension["kind"]; id: number; name: string }) => {
if (!window.confirm(`Remove ${name} from ${owner_name}?`)) return false;
return change({ action: "remove", kind, id });
};
return { ...data, busy, status, add, remove };
}
@@ -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={() => {
+73 -31
View File
@@ -20,30 +20,42 @@ const TemplateMap = z
const TimeoutMs = z.coerce.number().int().min(1000).max(AppData.CustomToolMaxTimeoutMs).optional();
export const SaveMcpServerRequestSchema = z
.object({
mcp_server_id: Id.optional(),
name: z.string().trim().min(1, "Enter a name").max(80),
description: z.string().trim().max(1000).default(""),
transport: z.enum(McpTransports.map((t) => t.value) as [McpTransport, ...McpTransport[]]),
url: z.string().trim().max(2000).default(""),
headers: TemplateMap,
command: z.string().trim().max(4000).default(""),
setup_command: z.string().trim().max(8000).default(""),
env: TemplateMap,
excluded_tools: z.array(z.string().max(200)).max(500).default([]),
enabled: z.boolean().default(true),
all_bots: z.boolean().default(false),
})
.superRefine((value, ctx) => {
if (value.transport == "remote" && !/^(https?:\/\/|\{\{\s*env\.)/.test(value.url)) {
ctx.addIssue({ code: "custom", path: ["url"], message: "Enter the server's http(s) URL." });
}
const McpServerFields = z.object({
mcp_server_id: Id.optional(),
name: z.string().trim().min(1, "Enter a name").max(80),
description: z.string().trim().max(1000).default(""),
transport: z.enum(McpTransports.map((t) => t.value) as [McpTransport, ...McpTransport[]]),
url: z.string().trim().max(2000).default(""),
headers: TemplateMap,
command: z.string().trim().max(4000).default(""),
setup_command: z.string().trim().max(8000).default(""),
env: TemplateMap,
excluded_tools: z.array(z.string().max(200)).max(500).default([]),
enabled: z.boolean().default(true),
all_bots: z.boolean().default(false),
});
if (value.transport == "stdio" && !value.command) {
ctx.addIssue({ code: "custom", path: ["command"], message: "Enter the command that starts the server." });
}
});
function checkMcpServerReach(
value: { transport: McpTransport; url: string; command: string },
ctx: z.RefinementCtx,
) {
if (value.transport == "remote" && !/^(https?:\/\/|\{\{\s*env\.)/.test(value.url)) {
ctx.addIssue({ code: "custom", path: ["url"], message: "Enter the server's http(s) URL." });
}
if (value.transport == "stdio" && !value.command) {
ctx.addIssue({ code: "custom", path: ["command"], message: "Enter the command that starts the server." });
}
}
export const SaveMcpServerRequestSchema = McpServerFields.superRefine(checkMcpServerReach);
/**
* A server as the form has it, saved or not: a name isn't needed yet.
*/
export const TestMcpServerRequestSchema = McpServerFields.extend({
name: z.string().trim().max(80).default(""),
}).superRefine(checkMcpServerReach);
export const McpServerIdRequestSchema = z.object({
mcp_server_id: Id,
@@ -63,6 +75,22 @@ export const SetExtensionAccessRequestSchema = z.object({
all_bots: z.boolean().optional(),
});
export const SetBotExtensionRequestSchema = z.object({
bot_id: Id,
kind: z.enum(["mcp", "tool"]),
id: Id,
/**
* The workspace the bot is seen in, for the list that comes back.
*/
workspace_id: Id.nullable().optional(),
});
export const SetWorkspaceExtensionRequestSchema = z.object({
workspace_id: Id,
kind: z.enum(["mcp", "tool"]),
id: Id,
});
/**
* A tool's arguments: a JSON schema of an object.
*/
@@ -121,14 +149,28 @@ export const CustomToolIdRequestSchema = z.object({
custom_tool_id: Id,
});
export const TestCustomToolRequestSchema = z.object({
custom_tool_id: Id,
args: z.record(z.string(), z.unknown()).default({}),
/**
* Where a script tool runs. The main computer when not given.
*/
computer_id: Id.optional(),
});
/**
* A tool as its form has it, saved or not: only what runs it.
*/
const DraftCustomTool = z.object({ parameters: ToolParameters }).and(ToolConfig);
/**
* Tries a saved tool (`custom_tool_id`, the run counts as its last) or the
* one in the form (`draft`, nothing saved).
*/
export const TestCustomToolRequestSchema = z
.object({
custom_tool_id: Id.optional(),
draft: DraftCustomTool.optional(),
args: z.record(z.string(), z.unknown()).default({}),
/**
* Where a script tool runs. The main computer when not given.
*/
computer_id: Id.optional(),
})
.refine((value) => value.custom_tool_id || value.draft, { message: "Pick a tool to try." });
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
export type TestMcpServerRequest = z.infer<typeof TestMcpServerRequestSchema>;
export type DraftCustomTool = z.infer<typeof DraftCustomTool>;
@@ -0,0 +1,25 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import attachToAllBots from "@/src/functions/backend/db/extensions/attach-to-all-bots";
/**
* One-off, safe to run again: an MCP server or tool set to "every bot" used
* to reach every bot without being given to it. Now "every bot" only
* decides what bots are given, so this gives each of them to every bot
* that exists, keeping what they had.
*
* Run once per install: `bun src/scripts/attach-every-bot-extensions.ts`.
*/
async function attachEveryBotExtensions() {
const servers = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM mcp_servers WHERE all_bots = 1` });
const tools = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM custom_tools WHERE all_bots = 1` });
for (const row of servers.payload || []) await attachToAllBots({ kind: "mcp", id: row.id });
for (const row of tools.payload || []) await attachToAllBots({ kind: "tool", id: row.id });
console.log(
`Gave ${servers.payload?.length || 0} MCP servers and ${tools.payload?.length || 0} tools to every bot.`,
);
}
await attachEveryBotExtensions();
process.exit(0);
+56
View File
@@ -32,6 +32,7 @@ import type {
CustomToolHttpMethod,
CustomToolKind,
CustomToolScriptLanguage,
CustomToolStatus,
} from "../dict/custom-tool-dict";
export type User = {
@@ -421,6 +422,9 @@ export type CustomToolRecord = {
parameters: Record<string, unknown>;
all_bots: boolean;
enabled: boolean;
status: CustomToolStatus;
last_error: string;
last_checked_at: number | null;
} & (
| { kind: "http"; config: CustomToolHttpConfig }
| { kind: "script"; config: CustomToolScriptConfig }
@@ -453,6 +457,9 @@ export type PublicCustomTool = {
description: string;
kind: CustomToolKind;
enabled: boolean;
status: CustomToolStatus;
last_error: string;
last_checked_at: number | null;
access: ExtensionAccess;
parameters?: Record<string, unknown>;
config?: CustomToolHttpConfig | CustomToolScriptConfig | CustomToolPromptConfig;
@@ -471,6 +478,38 @@ export type ExtensionTargets = {
can_edit: boolean;
};
/**
* One MCP server or tool a bot has, as its settings page and its chat's
* composer show it.
*/
export type BotExtension = {
kind: "mcp" | "tool";
id: number;
name: string;
description: string;
/**
* One line on what it is, e.g. "HTTP request" or "Remote · 12 tools".
*/
detail: string;
state: "ok" | "error" | "untested";
enabled: boolean;
/**
* `bot`: given to the bot, removable. `workspace`: the workspace's,
* only while it works there.
*/
source: "bot" | "workspace";
};
/**
* A bot's MCP servers and tools, and what can be added. Only super admins
* and admins (the super admin alone for the coordinator) manage them.
*/
export type BotExtensionsPayload = {
attached: BotExtension[];
addable: { kind: "mcp" | "tool"; id: number; name: string }[];
can_manage: boolean;
};
export type McpServersOverviewPayload = ExtensionTargets & {
servers: PublicMcpServer[];
};
@@ -479,11 +518,26 @@ export type CustomToolsOverviewPayload = ExtensionTargets & {
tools: PublicCustomTool[];
};
/**
* What testing an MCP server's form returned: its tools, or why it didn't
* connect.
*/
export type McpServerTestPayload = {
ok: boolean;
tools: McpToolInfo[];
error?: string;
};
/**
* What trying a custom tool returned, as a bot would read it.
*/
export type CustomToolTestPayload = {
output: string;
ok: boolean;
/**
* The page data, with the tool's new status.
*/
overview: CustomToolsOverviewPayload;
};
export type EnvsOverviewPayload = {
@@ -600,6 +654,8 @@ export type PagePropsType = {
envs_overview?: EnvsOverviewPayload | null;
mcps_overview?: McpServersOverviewPayload | null;
tools_overview?: CustomToolsOverviewPayload | null;
bot_extensions?: BotExtensionsPayload | null;
workspace_extensions?: BotExtensionsPayload | null;
envs?: {
GOOGLE_CLIENT_ID?: string;
R2_PUBLIC_DOMAIN?: string;
+9 -8
View File
@@ -9,11 +9,11 @@ beforeAll(() => {
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`);
/**
* 1: every bot. 2: bot 5. 3: workspace 9. 4: every bot, off.
* 5: nobody.
* 1: every bot (given to 5 and 6). 2: bot 5. 3: workspace 9. 4: off,
* given to 5. 5: nobody. 6: every bot, but 6 dropped it.
*/
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1)`);
db.run(`INSERT INTO bot_custom_tools VALUES (5, 2)`);
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1), (6, 1, 1)`);
db.run(`INSERT INTO bot_custom_tools VALUES (5, 1), (6, 1), (5, 2), (5, 4), (5, 6)`);
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
});
@@ -26,14 +26,15 @@ function ids(params: Parameters<typeof botExtensionsSql>[0]) {
}
describe("botExtensionsSql", () => {
test("a bot gets every-bot ones and its own, never disabled ones", () => {
expect(ids({ kind: "tool", bot_id: 5 })).toEqual([1, 2]);
test("a bot gets what it's given, never disabled ones, nor an every-bot one it dropped", () => {
expect(ids({ kind: "tool", bot_id: 5 })).toEqual([1, 2, 6]);
expect(ids({ kind: "tool", bot_id: 6 })).toEqual([1]);
expect(ids({ kind: "tool", bot_id: 7 })).toEqual([]);
});
test("in a workspace, the workspace's too", () => {
expect(ids({ kind: "tool", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3, 6]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2, 6]);
});
});
+5 -4
View File
@@ -27,16 +27,17 @@ const Tables = {
/**
* The enabled MCP servers or custom tools a bot gets on one run: those
* for every bot, those attached to the bot, and in a workspace those
* attached to the workspace.
* attached to the bot, and in a workspace those attached to the
* workspace. "Every bot" only decides what a bot is given (now and when
* it's created, `attachToAllBots`, `attachDefaultExtensions`); one it
* dropped stays dropped.
*/
export default function botExtensionsSql({ kind, bot_id, workspace_id }: Params) {
const { table, bot_link, workspace_link, column } = Tables[kind];
return {
sql: `SELECT x.* FROM ${table} x WHERE x.enabled = 1 AND (
x.all_bots = 1
OR x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
${workspace_id ? `OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)` : ""}
) ORDER BY x.id ASC`,
values: workspace_id ? [Number(bot_id), Number(workspace_id)] : [Number(bot_id)],
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test";
import { extensionOptionValue, parseExtensionOptionValue } from "./extension-option-value";
describe("extensionOptionValue", () => {
test("round-trips", () => {
expect(parseExtensionOptionValue({ value: extensionOptionValue({ kind: "mcp", id: 3 }) })).toEqual({
kind: "mcp",
id: 3,
});
});
test("refuses anything else", () => {
expect(parseExtensionOptionValue({ value: "" })).toBeNull();
expect(parseExtensionOptionValue({ value: "bot:2" })).toBeNull();
expect(parseExtensionOptionValue({ value: "tool:x" })).toBeNull();
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { BotExtension } from "@/src/types";
/**
* An MCP server or tool as one select value (`mcp:3`), and back.
*/
export function extensionOptionValue({ kind, id }: { kind: BotExtension["kind"]; id: number }) {
return `${kind}:${id}`;
}
export function parseExtensionOptionValue({ value }: { value: string }) {
const [kind, id] = value.split(":");
if ((kind != "mcp" && kind != "tool") || !Number(id)) return null;
return { kind: kind as BotExtension["kind"], id: Number(id) };
}