From 6294b911e617ccc4fa7e74d8235ffad06b3c3005 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 19 Oct 2026 15:35:13 +0100 Subject: [PATCH] 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 --- AGENTS.md | 8 +- db/schema.ts | 18 ++- db/types/db.ts | 9 ++ .../general/extension-add-select.tsx | 31 +++++ src/components/general/extension-icon.tsx | 9 ++ src/dict/custom-tool-dict.ts | 13 ++ .../bot-sessions/grab-conversation-props.ts | 8 +- src/functions/backend/db/bots/create-bot.ts | 3 + .../backend/db/bots/ensure-coordinator-bot.ts | 3 + .../assert-bot-extension-manager.ts | 31 +++++ .../extensions/attach-default-extensions.ts | 25 ++++ .../db/extensions/attach-to-all-bots.ts | 22 ++++ .../grab-bot-extensions-overview.ts | 39 ++++++ .../db/extensions/grab-extensions-overview.ts | 118 +++++++++++++++++ .../db/extensions/grab-tools-overview.ts | 3 + .../grab-workspace-extensions-overview.ts | 24 ++++ .../db/extensions/parse-custom-tool-row.ts | 4 + .../db/extensions/record-custom-tool-run.ts | 23 ++++ .../record-mcp-server-connection.ts | 23 ++++ .../backend/db/extensions/save-custom-tool.ts | 10 ++ .../backend/db/extensions/save-mcp-server.ts | 5 + .../db/extensions/set-bot-extension.ts | 46 +++++++ .../db/extensions/set-extension-access.ts | 12 +- .../db/extensions/set-workspace-extension.ts | 55 ++++++++ .../backend/db/extensions/test-custom-tool.ts | 38 ++++-- .../backend/db/extensions/test-mcp-draft.ts | 56 +++++++++ src/harness/mcp/mcp-stdio-pool.ts | 45 ++----- src/harness/mcp/open-mcp-server.ts | 33 ++++- src/harness/mcp/start-stdio-mcp-client.ts | 54 ++++++++ src/harness/mcp/test-mcp-server.ts | 38 ++++++ src/harness/tools/custom/build-custom-tool.ts | 25 +++- src/hooks/use-extensions.ts | 77 ++++++++++++ .../(partials)/composer-add-tool-modal.tsx | 65 ++++++++++ .../(partials)/composer-tool-chip.tsx | 53 ++++++++ .../[bot_id]/(partials)/composer-tools.tsx | 58 +++++++++ .../bots/[bot_id]/(partials)/composer.tsx | 12 +- .../bots/[bot_id]/(sections)/conversation.tsx | 1 + .../(partials)/extension-access-modal.tsx | 73 ++++++----- .../settings/(partials)/extension-row.tsx | 37 ++++++ .../(partials)/extension-status-line.tsx | 41 ++++++ .../settings/(partials)/extensions-group.tsx | 80 ++++++++++++ .../admin/settings/(utils)/checked-ago.ts | 17 +++ .../(utils)/extension-access-label.ts | 7 +- .../(sections)/bot-settings-section.tsx | 9 +- .../settings/bots/[bot_id]/index.server.ts | 7 +- .../mcps/(partials)/mcp-server-modal.tsx | 119 ++++++++++++------ .../mcps/(partials)/mcp-server-row.tsx | 17 ++- .../mcps/(sections)/mcps-settings-section.tsx | 2 +- .../tools/(partials)/custom-tool-modal.tsx | 18 ++- .../tools/(partials)/custom-tool-row.tsx | 24 +++- .../(partials)/custom-tool-test-panel.tsx | 113 +++++++++++++++++ .../(partials)/custom-tool-try-modal.tsx | 83 ++++-------- .../(sections)/tools-settings-section.tsx | 3 +- .../(sections)/workspace-overview.tsx | 10 +- .../workspaces/[workspace_id]/index.server.ts | 8 +- src/pages/api/admin/bots/add-extension.ts | 56 +++++++++ src/pages/api/admin/bots/remove-extension.ts | 57 +++++++++ src/pages/api/admin/mcps/test-mcp.ts | 44 +++++++ src/pages/api/admin/tools/test-tool.ts | 18 ++- .../api/admin/workspaces/add-extension.ts | 56 +++++++++ .../api/admin/workspaces/remove-extension.ts | 55 ++++++++ .../login/(partials)/login-form/index.tsx | 2 +- src/schemas/extension-schema.ts | 104 ++++++++++----- src/scripts/attach-every-bot-extensions.ts | 25 ++++ src/types/index.ts | 56 +++++++++ src/utils/bot-extensions-sql.test.ts | 17 +-- src/utils/bot-extensions-sql.ts | 9 +- src/utils/extension-option-value.test.ts | 17 +++ src/utils/extension-option-value.ts | 16 +++ 69 files changed, 2026 insertions(+), 271 deletions(-) create mode 100644 src/components/general/extension-add-select.tsx create mode 100644 src/components/general/extension-icon.tsx create mode 100644 src/functions/backend/db/extensions/assert-bot-extension-manager.ts create mode 100644 src/functions/backend/db/extensions/attach-default-extensions.ts create mode 100644 src/functions/backend/db/extensions/attach-to-all-bots.ts create mode 100644 src/functions/backend/db/extensions/grab-bot-extensions-overview.ts create mode 100644 src/functions/backend/db/extensions/grab-extensions-overview.ts create mode 100644 src/functions/backend/db/extensions/grab-workspace-extensions-overview.ts create mode 100644 src/functions/backend/db/extensions/record-custom-tool-run.ts create mode 100644 src/functions/backend/db/extensions/record-mcp-server-connection.ts create mode 100644 src/functions/backend/db/extensions/set-bot-extension.ts create mode 100644 src/functions/backend/db/extensions/set-workspace-extension.ts create mode 100644 src/functions/backend/db/extensions/test-mcp-draft.ts create mode 100644 src/harness/mcp/start-stdio-mcp-client.ts create mode 100644 src/harness/mcp/test-mcp-server.ts create mode 100644 src/hooks/use-extensions.ts create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/composer-add-tool-modal.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/composer-tool-chip.tsx create mode 100644 src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx create mode 100644 src/pages/admin/settings/(partials)/extension-row.tsx create mode 100644 src/pages/admin/settings/(partials)/extension-status-line.tsx create mode 100644 src/pages/admin/settings/(partials)/extensions-group.tsx create mode 100644 src/pages/admin/settings/(utils)/checked-ago.ts create mode 100644 src/pages/admin/settings/tools/(partials)/custom-tool-test-panel.tsx create mode 100644 src/pages/api/admin/bots/add-extension.ts create mode 100644 src/pages/api/admin/bots/remove-extension.ts create mode 100644 src/pages/api/admin/mcps/test-mcp.ts create mode 100644 src/pages/api/admin/workspaces/add-extension.ts create mode 100644 src/pages/api/admin/workspaces/remove-extension.ts create mode 100644 src/scripts/attach-every-bot-extensions.ts create mode 100644 src/utils/extension-option-value.test.ts create mode 100644 src/utils/extension-option-value.ts diff --git a/AGENTS.md b/AGENTS.md index 36036c9..8045fd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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://:/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/` (`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 `__` (`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/` (`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 `__` (`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 ` 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`). diff --git a/db/schema.ts b/db/schema.ts index fe3bc00..590cf36 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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`, diff --git a/db/types/db.ts b/db/types/db.ts index 2ef119d..96c4808 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -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 */ diff --git a/src/components/general/extension-add-select.tsx b/src/components/general/extension-add-select.tsx new file mode 100644 index 0000000..da7b9c9 --- /dev/null +++ b/src/components/general/extension-add-select.tsx @@ -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 ( + ({ + value: extensionOptionValue(item), + title: `${item.kind == "mcp" ? "MCP" : "Tool"} · ${item.name}`, + }))} + loading={loading} + onAdd={({ value }) => { + const picked = parseExtensionOptionValue({ value }); + if (picked) onAdd(picked); + }} + /> + ); +} diff --git a/src/components/general/extension-icon.tsx b/src/components/general/extension-icon.tsx new file mode 100644 index 0000000..40aebd6 --- /dev/null +++ b/src/components/general/extension-icon.tsx @@ -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; +} diff --git a/src/dict/custom-tool-dict.ts b/src/dict/custom-tool-dict.ts index 9b244b9..7fd4d4d 100644 --- a/src/dict/custom-tool-dict.ts +++ b/src/dict/custom-tool-dict.ts @@ -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]; diff --git a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts index 5f8b0f4..2af4dd4 100644 --- a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts +++ b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts @@ -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/` or * `/admin/workspaces//bots/`): 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, }; } diff --git a/src/functions/backend/db/bots/create-bot.ts b/src/functions/backend/db/bots/create-bot.ts index dfaf588..367c498 100644 --- a/src/functions/backend/db/bots/create-bot.ts +++ b/src/functions/backend/db/bots/create-bot.ts @@ -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); } diff --git a/src/functions/backend/db/bots/ensure-coordinator-bot.ts b/src/functions/backend/db/bots/ensure-coordinator-bot.ts index f29b227..a4fc9b9 100644 --- a/src/functions/backend/db/bots/ensure-coordinator-bot.ts +++ b/src/functions/backend/db/bots/ensure-coordinator-bot.ts @@ -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; } diff --git a/src/functions/backend/db/extensions/assert-bot-extension-manager.ts b/src/functions/backend/db/extensions/assert-bot-extension-manager.ts new file mode 100644 index 0000000..4fb24ee --- /dev/null +++ b/src/functions/backend/db/extensions/assert-bot-extension-manager.ts @@ -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; +} diff --git a/src/functions/backend/db/extensions/attach-default-extensions.ts b/src/functions/backend/db/extensions/attach-default-extensions.ts new file mode 100644 index 0000000..c7b4d83 --- /dev/null +++ b/src/functions/backend/db/extensions/attach-default-extensions.ts @@ -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)], + }); +} diff --git a/src/functions/backend/db/extensions/attach-to-all-bots.ts b/src/functions/backend/db/extensions/attach-to-all-bots.ts new file mode 100644 index 0000000..5a5c563 --- /dev/null +++ b/src/functions/backend/db/extensions/attach-to-all-bots.ts @@ -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], + }); +} diff --git a/src/functions/backend/db/extensions/grab-bot-extensions-overview.ts b/src/functions/backend/db/extensions/grab-bot-extensions-overview.ts new file mode 100644 index 0000000..260d856 --- /dev/null +++ b/src/functions/backend/db/extensions/grab-bot-extensions-overview.ts @@ -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 { + 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, + }); +} diff --git a/src/functions/backend/db/extensions/grab-extensions-overview.ts b/src/functions/backend/db/extensions/grab-extensions-overview.ts new file mode 100644 index 0000000..acd6863 --- /dev/null +++ b/src/functions/backend/db/extensions/grab-extensions-overview.ts @@ -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 & { 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 { + 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>({ + sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"), + values: mcp_query.values, + }), + BunSQLite.sql>({ + 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, + }; +} diff --git a/src/functions/backend/db/extensions/grab-tools-overview.ts b/src/functions/backend/db/extensions/grab-tools-overview.ts index de3539d..ef7722d 100644 --- a/src/functions/backend/db/extensions/grab-tools-overview.ts +++ b/src/functions/backend/db/extensions/grab-tools-overview.ts @@ -34,6 +34,9 @@ export default async function grabToolsOverview({ user_id }: Params): Promise { + const role = await grabCrewRole({ user_id }); + + return grabExtensionsOverview({ can_manage: role != "member", workspace_id }); +} diff --git a/src/functions/backend/db/extensions/parse-custom-tool-row.ts b/src/functions/backend/db/extensions/parse-custom-tool-row.ts index ac08636..f836272 100644 --- a/src/functions/backend/db/extensions/parse-custom-tool-row.ts +++ b/src/functions/backend/db/extensions/parse-custom-tool-row.ts @@ -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>({ text: row.config, fallback: {} }), } as CustomToolRecord; diff --git a/src/functions/backend/db/extensions/record-custom-tool-run.ts b/src/functions/backend/db/extensions/record-custom-tool-run.ts new file mode 100644 index 0000000..757a887 --- /dev/null +++ b/src/functions/backend/db/extensions/record-custom-tool-run.ts @@ -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(() => {}); +} diff --git a/src/functions/backend/db/extensions/record-mcp-server-connection.ts b/src/functions/backend/db/extensions/record-mcp-server-connection.ts new file mode 100644 index 0000000..4144ed4 --- /dev/null +++ b/src/functions/backend/db/extensions/record-mcp-server-connection.ts @@ -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(() => {}); +} diff --git a/src/functions/backend/db/extensions/save-custom-tool.ts b/src/functions/backend/db/extensions/save-custom-tool.ts index 727d0de..020c7af 100644 --- a/src/functions/backend/db/extensions/save-custom-tool.ts +++ b/src/functions/backend/db/extensions/save-custom-tool.ts @@ -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); } diff --git a/src/functions/backend/db/extensions/save-mcp-server.ts b/src/functions/backend/db/extensions/save-mcp-server.ts index 65c0d4d..32122f2 100644 --- a/src/functions/backend/db/extensions/save-mcp-server.ts +++ b/src/functions/backend/db/extensions/save-mcp-server.ts @@ -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); } diff --git a/src/functions/backend/db/extensions/set-bot-extension.ts b/src/functions/backend/db/extensions/set-bot-extension.ts new file mode 100644 index 0000000..f65b752 --- /dev/null +++ b/src/functions/backend/db/extensions/set-bot-extension.ts @@ -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 }; +} diff --git a/src/functions/backend/db/extensions/set-extension-access.ts b/src/functions/backend/db/extensions/set-extension-access.ts index d14e2fd..bd966db 100644 --- a/src/functions/backend/db/extensions/set-extension-access.ts +++ b/src/functions/backend/db/extensions/set-extension-access.ts @@ -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 }); } } diff --git a/src/functions/backend/db/extensions/set-workspace-extension.ts b/src/functions/backend/db/extensions/set-workspace-extension.ts new file mode 100644 index 0000000..9c4343f --- /dev/null +++ b/src/functions/backend/db/extensions/set-workspace-extension.ts @@ -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 }; +} diff --git a/src/functions/backend/db/extensions/test-custom-tool.ts b/src/functions/backend/db/extensions/test-custom-tool.ts index 6f1c141..eab5816 100644 --- a/src/functions/backend/db/extensions/test-custom-tool.ts +++ b/src/functions/backend/db/extensions/test-custom-tool.ts @@ -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; /** * 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 }; } } diff --git a/src/functions/backend/db/extensions/test-mcp-draft.ts b/src/functions/backend/db/extensions/test-mcp-draft.ts new file mode 100644 index 0000000..262e8d2 --- /dev/null +++ b/src/functions/backend/db/extensions/test-mcp-draft.ts @@ -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), + }; + } +} diff --git a/src/harness/mcp/mcp-stdio-pool.ts b/src/harness/mcp/mcp-stdio-pool.ts index 6c40cd8..1fe5e71 100644 --- a/src/harness/mcp/mcp-stdio-pool.ts +++ b/src/harness/mcp/mcp-stdio-pool.ts @@ -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 { 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; 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; } diff --git a/src/harness/mcp/open-mcp-server.ts b/src/harness/mcp/open-mcp-server.ts index 4f93b4c..1cae81d 100644 --- a/src/harness/mcp/open-mcp-server.ts +++ b/src/harness/mcp/open-mcp-server.ts @@ -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 | 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 = (connecting: Promise) => + 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 () => { diff --git a/src/harness/mcp/start-stdio-mcp-client.ts b/src/harness/mcp/start-stdio-mcp-client.ts new file mode 100644 index 0000000..a79a1c2 --- /dev/null +++ b/src/harness/mcp/start-stdio-mcp-client.ts @@ -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; +}; + +/** + * 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 }; +} diff --git a/src/harness/mcp/test-mcp-server.ts b/src/harness/mcp/test-mcp-server.ts new file mode 100644 index 0000000..ae84d73 --- /dev/null +++ b/src/harness/mcp/test-mcp-server.ts @@ -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(() => {}); + } +} diff --git a/src/harness/tools/custom/build-custom-tool.ts b/src/harness/tools/custom/build-custom-tool.ts index 90c02ad..6fc7eb4 100644 --- a/src/harness/tools/custom/build-custom-tool.ts +++ b/src/harness/tools/custom/build-custom-tool.ts @@ -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> | 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; } }, diff --git a/src/hooks/use-extensions.ts b/src/hooks/use-extensions.ts new file mode 100644 index 0000000..228688f --- /dev/null +++ b/src/hooks/use-extensions.ts @@ -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( + initial || { attached: [], addable: [], can_manage: false }, + ); + const [busy, setBusy] = useState(); + const [status, setStatus] = useState(); + 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({ + 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 }; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-add-tool-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-add-tool-modal.tsx new file mode 100644 index 0000000..217aad8 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-add-tool-modal.tsx @@ -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; +}; + +/** + * 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 ( + } + className="gap-1! px-2! py-0.5! text-[12px] leading-4" + > + Add + + } + targetWrapperProps={{ className: "shrink-0" }} + open={open} + setOpen={setOpen} + className="max-w-lg bg-paper p-6" + > + + +

Add a tool

+ + {`${bot_name} can use it everywhere it works, for everyone it works for.`} + +
+ { + if (await onAdd(picked)) setOpen(false); + }} + /> +
+
+ ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-tool-chip.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-tool-chip.tsx new file mode 100644 index 0000000..91cb3f4 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-tool-chip.tsx @@ -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 ( + + + {item.name} + {onRemove ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx new file mode 100644 index 0000000..44f81fc --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-tools.tsx @@ -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 ( + + Tools + {extensions.attached.map((item) => ( + extensions.remove({ kind: item.kind, id: item.id, name: item.name }) + : undefined + } + /> + ))} + {extensions.can_manage ? ( + + ) : null} + {extensions.status?.error ? ( + + {extensions.status.msg} + + ) : null} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx index 1e58b65..4545316 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx @@ -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(); @@ -128,6 +135,7 @@ export default function Composer({ bot, workspace_id, onSent }: Props) { onClick={send} /> + ); diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index db9a134..085962e 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -112,6 +112,7 @@ export default function Conversation() { { if (startsNewSession(message) && message.session_id != session_id) { setSessionId(Number(message.session_id)); diff --git a/src/pages/admin/settings/(partials)/extension-access-modal.tsx b/src/pages/admin/settings/(partials)/extension-access-modal.tsx index 96d0dc0..5ef690d 100644 --- a/src/pages/admin/settings/(partials)/extension-access-modal.tsx +++ b/src/pages/admin/settings/(partials)/extension-access-modal.tsx @@ -24,8 +24,9 @@ type Props = { }; /** - * 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({ @@ -77,47 +78,43 @@ export default function ExtensionAccessModal({ {targets.can_edit ? ( ) : access.all_bots ? ( - + ) : null} - {all_bots ? null : ( - <> - ({ - 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))} - /> - ({ - 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))} - /> - - )} + ({ + 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))} + /> + ({ + 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))} + /> diff --git a/src/pages/admin/settings/(partials)/extension-row.tsx b/src/pages/admin/settings/(partials)/extension-row.tsx new file mode 100644 index 0000000..3dc22a2 --- /dev/null +++ b/src/pages/admin/settings/(partials)/extension-row.tsx @@ -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 ( + } + title={item.name} + href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"} + subtitle={[item.detail, state].filter(Boolean).join(" · ")} + > + {onRemove ? ( + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/(partials)/extension-status-line.tsx b/src/pages/admin/settings/(partials)/extension-status-line.tsx new file mode 100644 index 0000000..4b21bac --- /dev/null +++ b/src/pages/admin/settings/(partials)/extension-status-line.tsx @@ -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 ( + + + + {text} + + {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/(partials)/extensions-group.tsx b/src/pages/admin/settings/(partials)/extensions-group.tsx new file mode 100644 index 0000000..fa70d1c --- /dev/null +++ b/src/pages/admin/settings/(partials)/extensions-group.tsx @@ -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 ( + + + {extensions.attached.map((item) => ( + extensions.remove({ kind: item.kind, id: item.id, name: item.name }) + : undefined + } + /> + ))} + + {extensions.can_manage ? ( + + ) : null} + + + + + + + ); +} diff --git a/src/pages/admin/settings/(utils)/checked-ago.ts b/src/pages/admin/settings/(utils)/checked-ago.ts new file mode 100644 index 0000000..3ff2420 --- /dev/null +++ b/src/pages/admin/settings/(utils)/checked-ago.ts @@ -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}`; +} diff --git a/src/pages/admin/settings/(utils)/extension-access-label.ts b/src/pages/admin/settings/(utils)/extension-access-label.ts index ee1384b..83ceba5 100644 --- a/src/pages/admin/settings/(utils)/extension-access-label.ts +++ b/src/pages/admin/settings/(utils)/extension-access-label.ts @@ -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"}` diff --git a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx index e6b5f0a..a4b7c8e 100644 --- a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx +++ b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx @@ -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 || []} /> + {is_coordinator ? null : } ); diff --git a/src/pages/admin/settings/bots/[bot_id]/index.server.ts b/src/pages/admin/settings/bots/[bot_id]/index.server.ts index 3381d8e..24e3e85 100644 --- a/src/pages/admin/settings/bots/[bot_id]/index.server.ts +++ b/src/pages/admin/settings/bots/[bot_id]/index.server.ts @@ -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 = async (params) => { @@ -35,6 +37,7 @@ export const server: BunextPageServerFn = 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 = 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 = async (params) => { bot_computers, computers: all_computers, bot_harness_overview, + bot_extensions, }, }; }; diff --git a/src/pages/admin/settings/mcps/(partials)/mcp-server-modal.tsx b/src/pages/admin/settings/mcps/(partials)/mcp-server-modal.tsx index f17bb18..fa50a52 100644 --- a/src/pages/admin/settings/mcps/(partials)/mcp-server-modal.tsx +++ b/src/pages/admin/settings/mcps/(partials)/mcp-server-modal.tsx @@ -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(); + 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({ 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({ 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 ? ( - - ) : null} )} - {server?.status == "error" && server.last_error ? ( + + + {transport == "stdio" && server?.id && onReinstall ? ( + + ) : null} + + {test ? ( + + ) : null} + {test?.error || (!test && server?.status == "error" && server.last_error) ? ( - {server.last_error} + {test?.error || server?.last_error} ) : null} - {server?.tools.length ? ( + {tools.length ? ( Tools bots get - {server.tools.map((tool) => ( + {tools.map((tool) => ( diff --git a/src/pages/admin/settings/mcps/(partials)/mcp-server-row.tsx b/src/pages/admin/settings/mcps/(partials)/mcp-server-row.tsx index 7dc97e8..5d23bdf 100644 --- a/src/pages/admin/settings/mcps/(partials)/mcp-server-row.tsx +++ b/src/pages/admin/settings/mcps/(partials)/mcp-server-row.tsx @@ -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 ( @@ -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} + {open == "edit" ? ( {adding ? ( { + 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)} /> + diff --git a/src/pages/admin/settings/tools/(partials)/custom-tool-row.tsx b/src/pages/admin/settings/tools/(partials)/custom-tool-row.tsx index ee085d5..b6e56bd 100644 --- a/src/pages/admin/settings/tools/(partials)/custom-tool-row.tsx +++ b/src/pages/admin/settings/tools/(partials)/custom-tool-row.tsx @@ -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 ( ) : null} + {open == "edit" ? ( setOpen(undefined)} /> ) : null} @@ -92,7 +108,7 @@ export default function CustomToolRow({ tool, targets, onChanged, onError }: Pro onCancel={() => setOpen(undefined)} /> ) : null} - {open == "try" ? setOpen(undefined)} /> : null} + {open == "try" ? setOpen(undefined)} /> : null} ); } diff --git a/src/pages/admin/settings/tools/(partials)/custom-tool-test-panel.tsx b/src/pages/admin/settings/tools/(partials)/custom-tool-test-panel.tsx new file mode 100644 index 0000000..0fee20a --- /dev/null +++ b/src/pages/admin/settings/tools/(partials)/custom-tool-test-panel.tsx @@ -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; + /** + * 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(); + + 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({ + 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 ( + + setArgs(value)} + /> + + + {result ? ( + + {result.output} + + ) : null} + + ); +} diff --git a/src/pages/admin/settings/tools/(partials)/custom-tool-try-modal.tsx b/src/pages/admin/settings/tools/(partials)/custom-tool-try-modal.tsx index 9730cd7..e056882 100644 --- a/src/pages/admin/settings/tools/(partials)/custom-tool-try-modal.tsx +++ b/src/pages/admin/settings/tools/(partials)/custom-tool-try-modal.tsx @@ -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(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - - 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({ - 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 ( !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." > -
- setArgs(value)} - /> - {output !== undefined ? ( - - {output} - - ) : null} - - + + ({ custom_tool_id: tool.id })} onTested={onTried} /> + + + +
); } diff --git a/src/pages/admin/settings/tools/(sections)/tools-settings-section.tsx b/src/pages/admin/settings/tools/(sections)/tools-settings-section.tsx index 50efd9f..90acbc4 100644 --- a/src/pages/admin/settings/tools/(sections)/tools-settings-section.tsx +++ b/src/pages/admin/settings/tools/(sections)/tools-settings-section.tsx @@ -37,7 +37,7 @@ export default function ToolsSettingsSection() { return ( {adding ? ( ))} diff --git a/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx b/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx index 8e2fafe..51b8157 100644 --- a/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx +++ b/src/pages/admin/settings/workspaces/[workspace_id]/(sections)/workspace-overview.tsx @@ -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 || []} /> + {harnesses?.length ? ( <> = async (params) => { const settings = await grabCrewSettingsServerProps(params); @@ -41,12 +42,14 @@ export const server: BunextPageServerFn = 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 = async (params) => { workspace_computers, computers, workspace_harness_overview, + workspace_extensions, }, }; }; diff --git a/src/pages/api/admin/bots/add-extension.ts b/src/pages/api/admin/bots/add-extension.ts new file mode 100644 index 0000000..8c7b336 --- /dev/null +++ b/src/pages/api/admin/bots/add-extension.ts @@ -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> = 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 }) }; + } +}; diff --git a/src/pages/api/admin/bots/remove-extension.ts b/src/pages/api/admin/bots/remove-extension.ts new file mode 100644 index 0000000..f9450fb --- /dev/null +++ b/src/pages/api/admin/bots/remove-extension.ts @@ -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> = 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 }) }; + } +}; diff --git a/src/pages/api/admin/mcps/test-mcp.ts b/src/pages/api/admin/mcps/test-mcp.ts new file mode 100644 index 0000000..80e7f0c --- /dev/null +++ b/src/pages/api/admin/mcps/test-mcp.ts @@ -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 +> = 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 }) }; + } +}; diff --git a/src/pages/api/admin/tools/test-tool.ts b/src/pages/api/admin/tools/test-tool.ts index 32f713b..aff3a37 100644 --- a/src/pages/api/admin/tools/test-tool.ts +++ b/src/pages/api/admin/tools/test-tool.ts @@ -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 @@ -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 }) }; } diff --git a/src/pages/api/admin/workspaces/add-extension.ts b/src/pages/api/admin/workspaces/add-extension.ts new file mode 100644 index 0000000..b5ded43 --- /dev/null +++ b/src/pages/api/admin/workspaces/add-extension.ts @@ -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> = 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 }) }; + } +}; diff --git a/src/pages/api/admin/workspaces/remove-extension.ts b/src/pages/api/admin/workspaces/remove-extension.ts new file mode 100644 index 0000000..0159296 --- /dev/null +++ b/src/pages/api/admin/workspaces/remove-extension.ts @@ -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> = 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 }) }; + } +}; diff --git a/src/pages/auth/login/(partials)/login-form/index.tsx b/src/pages/auth/login/(partials)/login-form/index.tsx index 6a0e929..e23b9d5 100644 --- a/src/pages/auth/login/(partials)/login-form/index.tsx +++ b/src/pages/auth/login/(partials)/login-form/index.tsx @@ -15,7 +15,7 @@ export default function LoginForm() { return ( - + {/* */}
{ diff --git a/src/schemas/extension-schema.ts b/src/schemas/extension-schema.ts index 521fc8f..e940653 100644 --- a/src/schemas/extension-schema.ts +++ b/src/schemas/extension-schema.ts @@ -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; export type SaveCustomToolRequest = z.infer; +export type TestMcpServerRequest = z.infer; +export type DraftCustomTool = z.infer; diff --git a/src/scripts/attach-every-bot-extensions.ts b/src/scripts/attach-every-bot-extensions.ts new file mode 100644 index 0000000..15ab8ef --- /dev/null +++ b/src/scripts/attach-every-bot-extensions.ts @@ -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); diff --git a/src/types/index.ts b/src/types/index.ts index ac26b76..dea4678 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; 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; 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; diff --git a/src/utils/bot-extensions-sql.test.ts b/src/utils/bot-extensions-sql.test.ts index 8d9ecce..63ad6d7 100644 --- a/src/utils/bot-extensions-sql.test.ts +++ b/src/utils/bot-extensions-sql.test.ts @@ -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[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]); }); }); diff --git a/src/utils/bot-extensions-sql.ts b/src/utils/bot-extensions-sql.ts index b74c238..7ef414f 100644 --- a/src/utils/bot-extensions-sql.ts +++ b/src/utils/bot-extensions-sql.ts @@ -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)], diff --git a/src/utils/extension-option-value.test.ts b/src/utils/extension-option-value.test.ts new file mode 100644 index 0000000..bac7f72 --- /dev/null +++ b/src/utils/extension-option-value.test.ts @@ -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(); + }); +}); diff --git a/src/utils/extension-option-value.ts b/src/utils/extension-option-value.ts new file mode 100644 index 0000000..4611567 --- /dev/null +++ b/src/utils/extension-option-value.ts @@ -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) }; +}