Give bots tools and MCP servers per bot and workspace, with status and tests
- Bots and workspaces get tools and MCP servers from their settings pages; bots also from chips under the chat composer, removable there - "Every bot" now gives one to every bot now and every new bot; each can still drop it (attach-every-bot-extensions.ts moves old installs over) - Status lines: MCP servers record whether they connect, tools whether their last run worked - Test connection in the MCP form and a Test panel in the tool form check the form as it is, without saving - Hide Google sign-in on the login form Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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`).
|
- **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*`.
|
- **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`. Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so). `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`.
|
||||||
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
|
- **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://<hostname>:<port>/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs).
|
||||||
- **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). Each goes to the bots it's attached to (`bot_mcp_servers`, `bot_custom_tools`), to every bot in a workspace there (`workspace_mcp_servers`, `workspace_custom_tools`), or to every bot (`all_bots`, the coordinator included); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. Super admins define them; super admins and admins attach them (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.
|
- **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).
|
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`.
|
||||||
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); 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.
|
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
|
||||||
- **Replies and tasks:** `run-bot-reply.ts` answers from the last `BotReplyHistoryMessages` messages (`botMessagesToHarness`) with `ChatReplyNote`, the bot's computers (`compose-computers-note.ts`: what they are and that they're reached through `start_task`) and the chat tools, at most `BotReplyMaxToolSteps` steps, and finishes silently if the bot already had the last word (`enqueueBotReply` reuses a pending reply, so a burst of messages gets one). `run-bot-task.ts` runs the brief (`compose-job-brief.ts`) with `TaskRunNote`, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest are `BotJobFatalError`.
|
- **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.
|
- **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)
|
- `/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
|
- Routines, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
|
||||||
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
- `/admin/backups` - super admin only: create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
|
||||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/people/create/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||||
|
|
||||||
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -21,7 +21,7 @@ import { AppData } from "@/src/data/app-data";
|
|||||||
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
|
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite/types";
|
||||||
import { EnvParadigms } from "@/src/dict/env-dict";
|
import { EnvParadigms } from "@/src/dict/env-dict";
|
||||||
import { McpServerStatuses, McpTransports } from "@/src/dict/mcp-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 = {
|
const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||||
tables: [
|
tables: [
|
||||||
@@ -1827,6 +1827,22 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
options: [0, 1],
|
options: [0, 1],
|
||||||
defaultValue: 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",
|
fieldName: "user_id",
|
||||||
fieldDescription: `Who created it. Not a foreign key: it belongs to the install`,
|
fieldDescription: `Who created it. Not a foreign key: it belongs to the install`,
|
||||||
|
|||||||
@@ -1132,6 +1132,15 @@ export type BUN_SQLITE_COBBLE_CUSTOM_TOOLS = {
|
|||||||
*/
|
*/
|
||||||
all_bots?: 0 | 1 | "";
|
all_bots?: 0 | 1 | "";
|
||||||
enabled?: 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
|
* Who created it. Not a foreign key: it belongs to the install
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import { extensionOptionValue, parseExtensionOptionValue } from "@/src/utils/extension-option-value";
|
||||||
|
import InlineAddSelect from "./inline-add-select";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
addable: BotExtensionsPayload["addable"];
|
||||||
|
loading?: boolean;
|
||||||
|
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks an MCP server or tool to give a bot.
|
||||||
|
*/
|
||||||
|
export default function ExtensionAddSelect({ id, addable, loading, onAdd }: Props) {
|
||||||
|
return (
|
||||||
|
<InlineAddSelect
|
||||||
|
id={id}
|
||||||
|
noun="tool or MCP server"
|
||||||
|
options={addable.map((item) => ({
|
||||||
|
value: extensionOptionValue(item),
|
||||||
|
title: `${item.kind == "mcp" ? "MCP" : "Tool"} · ${item.name}`,
|
||||||
|
}))}
|
||||||
|
loading={loading}
|
||||||
|
onAdd={({ value }) => {
|
||||||
|
const picked = parseExtensionOptionValue({ value });
|
||||||
|
if (picked) onAdd(picked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Plug, Wrench } from "lucide-react";
|
||||||
|
import type { BotExtension } from "@/src/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icon of an MCP server (a plug) or a tool (a wrench).
|
||||||
|
*/
|
||||||
|
export default function extensionIcon({ kind }: { kind: BotExtension["kind"] }) {
|
||||||
|
return kind == "mcp" ? Plug : Wrench;
|
||||||
|
}
|
||||||
@@ -23,6 +23,19 @@ export const CustomToolKinds = [
|
|||||||
|
|
||||||
export type CustomToolKind = (typeof CustomToolKinds)[number]["value"];
|
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 const CustomToolHttpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||||
|
|
||||||
export type CustomToolHttpMethod = (typeof CustomToolHttpMethods)[number];
|
export type CustomToolHttpMethod = (typeof CustomToolHttpMethods)[number];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { PagePropsType } from "@/src/types";
|
|||||||
import grabBots from "../bots/grab-bots";
|
import grabBots from "../bots/grab-bots";
|
||||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||||
import grabBotMessages from "../bots/grab-bot-messages";
|
import grabBotMessages from "../bots/grab-bot-messages";
|
||||||
|
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||||
import grabBotComputers from "../computers/grab-bot-computers";
|
import grabBotComputers from "../computers/grab-bot-computers";
|
||||||
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
|
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
|
||||||
import grabHarnessOverview from "../harness/grab-harness-overview";
|
import grabHarnessOverview from "../harness/grab-harness-overview";
|
||||||
@@ -23,7 +24,8 @@ type Params = {
|
|||||||
* The page props of a conversation (`/admin/bots/<id>` or
|
* The page props of a conversation (`/admin/bots/<id>` or
|
||||||
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||||
* in a workspace) and workspaces, the bot, its active session (marked
|
* 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.
|
* Null when the user can't chat with the bot there.
|
||||||
*/
|
*/
|
||||||
export default async function grabConversationProps({
|
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
|
* workspace the workspace's own models come first, so what runs now
|
||||||
* shows its pick.
|
* 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! }),
|
grabBotMessages({ session_id: session.id! }),
|
||||||
(bot.kind == "coordinator" && !scope
|
(bot.kind == "coordinator" && !scope
|
||||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||||
@@ -73,6 +75,7 @@ export default async function grabConversationProps({
|
|||||||
).catch(() => null),
|
).catch(() => null),
|
||||||
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
||||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
||||||
|
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -85,5 +88,6 @@ export default async function grabConversationProps({
|
|||||||
bot_harness_overview,
|
bot_harness_overview,
|
||||||
bot_computers,
|
bot_computers,
|
||||||
bot_task,
|
bot_task,
|
||||||
|
bot_extensions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
|||||||
import type { BotHue } from "@/src/dict/bot-dict";
|
import type { BotHue } from "@/src/dict/bot-dict";
|
||||||
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||||
|
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||||
|
|
||||||
type Params = {
|
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}`);
|
throw new Error(`Couldn't add the bot: ${res.msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await attachDefaultExtensions({ bot_id });
|
||||||
|
|
||||||
return Number(bot_id);
|
return Number(bot_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
||||||
import { CoordinatorBotName } from "@/src/dict/bot-dict";
|
import { CoordinatorBotName } from "@/src/dict/bot-dict";
|
||||||
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
||||||
|
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
/**
|
/**
|
||||||
@@ -66,5 +67,7 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
|||||||
throw new Error(`Couldn't create the coordinator: ${res.msg}`);
|
throw new Error(`Couldn't create the coordinator: ${res.msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await attachDefaultExtensions({ bot_id });
|
||||||
|
|
||||||
return bot_id;
|
return bot_id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws unless the user manages this bot's MCP servers and tools: super
|
||||||
|
* admins and admins, the super admin alone for the coordinator. Returns
|
||||||
|
* the bot.
|
||||||
|
*/
|
||||||
|
export default async function assertBotExtensionManager({ user_id, bot_id }: Params) {
|
||||||
|
const role = await grabCrewRole({ user_id });
|
||||||
|
const bot = (
|
||||||
|
await BunSQLite.sql<{ id: number; kind: string; name: string }>({
|
||||||
|
sql: `SELECT id, kind, name FROM bots WHERE id = ?`,
|
||||||
|
values: [bot_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!bot) throw new AppError(`That bot doesn't exist.`);
|
||||||
|
|
||||||
|
if (role == "member" || (bot.kind == "coordinator" && role != "super_admin")) {
|
||||||
|
throw new AppError(`Only an admin can change a bot's tools.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives a new bot every MCP server and tool set to "every bot". Called
|
||||||
|
* when a bot is created; it can drop any of them afterwards.
|
||||||
|
*/
|
||||||
|
export default async function attachDefaultExtensions({ bot_id }: Params) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO bot_mcp_servers (created_at, updated_at, bot_id, mcp_server_id)
|
||||||
|
SELECT ?, ?, ?, id FROM mcp_servers WHERE all_bots = 1`,
|
||||||
|
values: [now, now, Number(bot_id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO bot_custom_tools (created_at, updated_at, bot_id, custom_tool_id)
|
||||||
|
SELECT ?, ?, ?, id FROM custom_tools WHERE all_bots = 1`,
|
||||||
|
values: [now, now, Number(bot_id)],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
kind: ExtensionKind;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives an MCP server or tool set to "every bot" to every bot there is,
|
||||||
|
* the coordinator included. Each can still drop it afterwards.
|
||||||
|
*/
|
||||||
|
export default async function attachToAllBots({ kind, id }: Params) {
|
||||||
|
const { bot_link, column } = ExtensionTables[kind];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO ${bot_link} (created_at, updated_at, bot_id, ${column})
|
||||||
|
SELECT ?, ?, id, ? FROM bots`,
|
||||||
|
values: [now, now, id],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
import grabExtensionsOverview from "./grab-extensions-overview";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Who's looking: only super admins and admins (the super admin alone
|
||||||
|
* for the coordinator) get what can be added.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the bot is seen in: its MCP servers and tools show
|
||||||
|
* too, not removable from the bot.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot's MCP servers and tools (given to it, and its workspace's there),
|
||||||
|
* and the rest of them, which can be added.
|
||||||
|
*/
|
||||||
|
export default async function grabBotExtensionsOverview({
|
||||||
|
user_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BotExtensionsPayload> {
|
||||||
|
const [role, bot] = await Promise.all([
|
||||||
|
grabCrewRole({ user_id }),
|
||||||
|
BunSQLite.sql<{ kind: string }>({ sql: `SELECT kind FROM bots WHERE id = ?`, values: [bot_id] }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return grabExtensionsOverview({
|
||||||
|
can_manage: role == "super_admin" || (role == "admin" && bot.payload?.[0]?.kind != "coordinator"),
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||||
|
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||||
|
import { McpTransports } from "@/src/dict/mcp-dict";
|
||||||
|
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
|
||||||
|
import parseCustomToolRow from "./parse-custom-tool-row";
|
||||||
|
import parseMcpServerRow from "./parse-mcp-server-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Whether the viewer can give and take them: only then is what can be
|
||||||
|
* added listed.
|
||||||
|
*/
|
||||||
|
can_manage: boolean;
|
||||||
|
/**
|
||||||
|
* A bot's: given to it, and (with `workspace_id`) that workspace's
|
||||||
|
* too, not removable from the bot. Without it, only the workspace's.
|
||||||
|
*/
|
||||||
|
bot_id?: number | string | null;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Linked<T> = T & { source: "bot" | "workspace" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP servers and tools a bot or a workspace has, and the rest of
|
||||||
|
* them, which can be added (`grabBotExtensionsOverview`,
|
||||||
|
* `grabWorkspaceExtensionsOverview`).
|
||||||
|
*/
|
||||||
|
export default async function grabExtensionsOverview({
|
||||||
|
can_manage,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BotExtensionsPayload> {
|
||||||
|
const bot = Number(bot_id || 0);
|
||||||
|
const workspace = Number(workspace_id || 0);
|
||||||
|
|
||||||
|
const linked = (column: string, bot_link: string, workspace_link: string) => ({
|
||||||
|
sql: `SELECT x.*, CASE WHEN x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?) THEN 'bot' ELSE 'workspace' END AS source
|
||||||
|
FROM %TABLE% x WHERE x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
|
||||||
|
OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)
|
||||||
|
ORDER BY x.name COLLATE NOCASE ASC`,
|
||||||
|
values: [bot, bot, workspace],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
|
||||||
|
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
|
||||||
|
|
||||||
|
const [servers, tools, all_servers, all_tools] = await Promise.all([
|
||||||
|
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
|
||||||
|
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
|
||||||
|
values: mcp_query.values,
|
||||||
|
}),
|
||||||
|
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>>({
|
||||||
|
sql: tool_query.sql.replace("%TABLE%", "custom_tools"),
|
||||||
|
values: tool_query.values,
|
||||||
|
}),
|
||||||
|
can_manage
|
||||||
|
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
|
||||||
|
: null,
|
||||||
|
can_manage
|
||||||
|
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` })
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const attached: BotExtension[] = [
|
||||||
|
...(servers.payload || []).map((row) => {
|
||||||
|
const server = parseMcpServerRow({ row });
|
||||||
|
const transport = McpTransports.find((t) => t.value == server.transport)?.value == "stdio" ? "Command" : "Remote";
|
||||||
|
const count = server.tools.filter((tool) => !server.excluded_tools.includes(tool.name)).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "mcp" as const,
|
||||||
|
id: server.id,
|
||||||
|
name: server.name,
|
||||||
|
description: server.description,
|
||||||
|
detail: `MCP · ${transport}${server.status == "ok" ? ` · ${count} ${count == 1 ? "tool" : "tools"}` : ""}`,
|
||||||
|
state: server.status,
|
||||||
|
enabled: server.enabled,
|
||||||
|
source: row.source,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...(tools.payload || []).map((row) => {
|
||||||
|
const tool = parseCustomToolRow({ row });
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "tool" as const,
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
detail: `Tool · ${CustomToolKinds.find((k) => k.value == tool.kind)?.title || tool.kind}`,
|
||||||
|
state: tool.status,
|
||||||
|
enabled: tool.enabled,
|
||||||
|
source: row.source,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot can be given one its workspace has; a workspace only what it
|
||||||
|
* hasn't.
|
||||||
|
*/
|
||||||
|
const has = (kind: "mcp" | "tool", id: number) =>
|
||||||
|
attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
attached,
|
||||||
|
addable: [
|
||||||
|
...(all_servers?.payload || [])
|
||||||
|
.filter((row) => !has("mcp", Number(row.id)))
|
||||||
|
.map((row) => ({ kind: "mcp" as const, id: Number(row.id), name: row.name })),
|
||||||
|
...(all_tools?.payload || [])
|
||||||
|
.filter((row) => !has("tool", Number(row.id)))
|
||||||
|
.map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })),
|
||||||
|
],
|
||||||
|
can_manage,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ export default async function grabToolsOverview({ user_id }: Params): Promise<Cu
|
|||||||
description: tool.description,
|
description: tool.description,
|
||||||
kind: tool.kind,
|
kind: tool.kind,
|
||||||
enabled: tool.enabled,
|
enabled: tool.enabled,
|
||||||
|
status: tool.status,
|
||||||
|
last_error: tool.last_error,
|
||||||
|
last_checked_at: tool.last_checked_at,
|
||||||
access: { all_bots: tool.all_bots, ...access(tool.id) },
|
access: { all_bots: tool.all_bots, ...access(tool.id) },
|
||||||
...(targets.can_edit ? { parameters: tool.parameters, config: tool.config } : {}),
|
...(targets.can_edit ? { parameters: tool.parameters, config: tool.config } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
import grabExtensionsOverview from "./grab-extensions-overview";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Who's looking: only super admins and admins get what can be added.
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP servers and tools every bot working in a workspace gets there,
|
||||||
|
* and the rest of them, which can be added.
|
||||||
|
*/
|
||||||
|
export default async function grabWorkspaceExtensionsOverview({
|
||||||
|
user_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): Promise<BotExtensionsPayload> {
|
||||||
|
const role = await grabCrewRole({ user_id });
|
||||||
|
|
||||||
|
return grabExtensionsOverview({ can_manage: role != "member", workspace_id });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS } from "@/db/types/db";
|
import type { 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 type { CustomToolRecord } from "@/src/types";
|
||||||
import parseJsonColumn from "./parse-json-column";
|
import parseJsonColumn from "./parse-json-column";
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ export default function parseCustomToolRow({ row }: Params): CustomToolRecord {
|
|||||||
}),
|
}),
|
||||||
all_bots: row.all_bots == 1,
|
all_bots: row.all_bots == 1,
|
||||||
enabled: row.enabled != 0,
|
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",
|
kind: row.kind || "prompt",
|
||||||
config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }),
|
config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }),
|
||||||
} as CustomToolRecord;
|
} as CustomToolRecord;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
custom_tool_id: number;
|
||||||
|
/**
|
||||||
|
* Left out when it worked.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves how a custom tool's run went (`status`, `last_error`,
|
||||||
|
* `last_checked_at`), for its row in Settings, under Tools. Never throws:
|
||||||
|
* a run doesn't fail over its bookkeeping.
|
||||||
|
*/
|
||||||
|
export default async function recordCustomToolRun({ custom_tool_id, error }: Params) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE custom_tools SET status = ?, last_error = ?, last_checked_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, custom_tool_id],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
mcp_server_id: number;
|
||||||
|
/**
|
||||||
|
* Left out when it connected.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves whether a bot's run could connect to an MCP server, so its row in
|
||||||
|
* Settings, under MCPs, shows a server that stopped answering (or came
|
||||||
|
* back). Its tools listing is left as it was. Never throws.
|
||||||
|
*/
|
||||||
|
export default async function recordMcpServerConnection({ mcp_server_id, error }: Params) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE mcp_servers SET status = ?, last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, mcp_server_id],
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import 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 { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BunSQLiteTables } from "@/db/types/db";
|
||||||
import type { SaveCustomToolRequest } from "@/src/schemas/extension-schema";
|
import type { SaveCustomToolRequest } from "@/src/schemas/extension-schema";
|
||||||
import AppError from "@/src/utils/app-error";
|
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),
|
config: JSON.stringify(data.config),
|
||||||
enabled: data.enabled ? 1 : 0,
|
enabled: data.enabled ? 1 : 0,
|
||||||
all_bots: data.all_bots ? 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;
|
} as const;
|
||||||
|
|
||||||
if (data.custom_tool_id) {
|
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 (!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;
|
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 (!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);
|
return Number(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
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 type { BUN_SQLITE_COBBLE_MCP_SERVERS, BunSQLiteTables } from "@/db/types/db";
|
||||||
import { stopStdioMcpServer } from "@/src/harness/mcp/mcp-stdio-pool";
|
import { stopStdioMcpServer } from "@/src/harness/mcp/mcp-stdio-pool";
|
||||||
import type { SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
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 (!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;
|
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 (!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);
|
return Number(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import assertBotExtensionManager from "./assert-bot-extension-manager";
|
||||||
|
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot_id: number | string;
|
||||||
|
kind: ExtensionKind;
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Gives it to the bot, or takes it away.
|
||||||
|
*/
|
||||||
|
attached: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives a bot an MCP server or tool, or takes it away, even one set to
|
||||||
|
* "every bot" (that only decides what bots start with). What its
|
||||||
|
* workspaces have stays theirs. Returns the bot and the item's name.
|
||||||
|
*/
|
||||||
|
export default async function setBotExtension({ user_id, bot_id, kind, id, attached }: Params) {
|
||||||
|
const bot = await assertBotExtensionManager({ user_id, bot_id });
|
||||||
|
const { table, bot_link, column, label } = ExtensionTables[kind];
|
||||||
|
|
||||||
|
const item = (
|
||||||
|
await BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM ${table} WHERE id = ?`, values: [id] })
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!item) throw new AppError(`That ${label} doesn't exist.`);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const res = attached
|
||||||
|
? await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO ${bot_link} (created_at, updated_at, bot_id, ${column}) VALUES (?, ?, ?, ?)`,
|
||||||
|
values: [now, now, bot.id, id],
|
||||||
|
})
|
||||||
|
: await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM ${bot_link} WHERE bot_id = ? AND ${column} = ?`,
|
||||||
|
values: [bot.id, id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) throw new Error(`Couldn't change the bot's ${label}s: ${res.error}`);
|
||||||
|
|
||||||
|
return { bot, name: item.name };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import assertCrewManager from "../access/assert-crew-manager";
|
import assertCrewManager from "../access/assert-crew-manager";
|
||||||
|
import attachToAllBots from "./attach-to-all-bots";
|
||||||
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -18,13 +19,18 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* Attaches an MCP server or tool to exactly these bots and workspaces, for
|
* 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
|
* 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) {
|
export default async function setExtensionAccess({ user_id, kind, id, bot_ids, workspace_ids, all_bots }: Params) {
|
||||||
const role = await assertCrewManager({ user_id });
|
const role = await assertCrewManager({ user_id });
|
||||||
const { table, bot_link, workspace_link, column, label } = ExtensionTables[kind];
|
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.`);
|
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 = ?`,
|
sql: `UPDATE ${table} SET all_bots = ?, updated_at = ? WHERE id = ?`,
|
||||||
values: [all_bots ? 1 : 0, now, id],
|
values: [all_bots ? 1 : 0, now, id],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (all_bots && found.payload?.[0]?.all_bots != 1) await attachToAllBots({ kind, id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import assertCrewManager from "../access/assert-crew-manager";
|
||||||
|
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
workspace_id: number | string;
|
||||||
|
kind: ExtensionKind;
|
||||||
|
id: number;
|
||||||
|
/**
|
||||||
|
* Gives it to the workspace, or takes it away.
|
||||||
|
*/
|
||||||
|
attached: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives a workspace an MCP server or tool (every bot working there gets
|
||||||
|
* it, there), or takes it away; for super admins and admins. Returns the
|
||||||
|
* workspace and the item's name.
|
||||||
|
*/
|
||||||
|
export default async function setWorkspaceExtension({ user_id, workspace_id, kind, id, attached }: Params) {
|
||||||
|
await assertCrewManager({ user_id });
|
||||||
|
|
||||||
|
const { table, workspace_link, column, label } = ExtensionTables[kind];
|
||||||
|
|
||||||
|
const [workspace, item] = await Promise.all([
|
||||||
|
BunSQLite.sql<{ id: number; name: string }>({
|
||||||
|
sql: `SELECT id, name FROM workspaces WHERE id = ?`,
|
||||||
|
values: [workspace_id],
|
||||||
|
}).then((res) => res.payload?.[0]),
|
||||||
|
BunSQLite.sql<{ id: number; name: string }>({
|
||||||
|
sql: `SELECT id, name FROM ${table} WHERE id = ?`,
|
||||||
|
values: [id],
|
||||||
|
}).then((res) => res.payload?.[0]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!workspace) throw new AppError(`That workspace doesn't exist.`);
|
||||||
|
if (!item) throw new AppError(`That ${label} doesn't exist.`);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const res = attached
|
||||||
|
? await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO ${workspace_link} (created_at, updated_at, workspace_id, ${column}) VALUES (?, ?, ?, ?)`,
|
||||||
|
values: [now, now, workspace.id, id],
|
||||||
|
})
|
||||||
|
: await BunSQLite.sql({
|
||||||
|
sql: `DELETE FROM ${workspace_link} WHERE workspace_id = ? AND ${column} = ?`,
|
||||||
|
values: [workspace.id, id],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) throw new Error(`Couldn't change the workspace's ${label}s: ${res.error}`);
|
||||||
|
|
||||||
|
return { workspace, name: item.name };
|
||||||
|
}
|
||||||
@@ -2,11 +2,20 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import buildCustomTool from "@/src/harness/tools/custom/build-custom-tool";
|
import buildCustomTool from "@/src/harness/tools/custom/build-custom-tool";
|
||||||
import AppError from "@/src/utils/app-error";
|
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 grabCustomTool from "./grab-custom-tool";
|
||||||
import grabRunEnvs from "./grab-run-envs";
|
import grabRunEnvs from "./grab-run-envs";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
custom_tool_id: number | string;
|
/**
|
||||||
|
* A saved tool: the run counts as its last one.
|
||||||
|
*/
|
||||||
|
custom_tool_id?: number | string;
|
||||||
|
/**
|
||||||
|
* The tool as its form has it, saved or not: nothing is recorded.
|
||||||
|
*/
|
||||||
|
draft?: DraftCustomTool;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
/**
|
/**
|
||||||
* Where a script tool runs. The main computer when not given.
|
* 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
|
* 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
|
* 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) {
|
export default async function testCustomTool({ custom_tool_id, draft, args, computer_id }: Params) {
|
||||||
const tool = await grabCustomTool({ custom_tool_id });
|
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 =
|
const main =
|
||||||
computer_id ||
|
computer_id ||
|
||||||
(await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM computers WHERE kind = 'main' ORDER BY id LIMIT 1` }))
|
(await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM computers WHERE kind = 'main' ORDER BY id LIMIT 1` }))
|
||||||
.payload?.[0]?.id;
|
.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.`);
|
if (!harness_tool) throw new AppError(`There's no computer to run this script on.`);
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error: unknown) {
|
||||||
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
return { output: `Error: ${error instanceof Error ? error.message : String(error)}`, ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import testMcpServer from "@/src/harness/mcp/test-mcp-server";
|
||||||
|
import type { TestMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||||
|
import type { McpToolInfo } from "@/src/types";
|
||||||
|
import mcpServerSlug from "@/src/utils/mcp-server-slug";
|
||||||
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
|
import grabRunEnvs from "./grab-run-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
data: TestMcpServerRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to an MCP server as its form has it (saved or not) and lists
|
||||||
|
* its tools, saving nothing. Never throws for the server's own failure:
|
||||||
|
* returns why, secrets blanked out.
|
||||||
|
*/
|
||||||
|
export default async function testMcpDraft({
|
||||||
|
data,
|
||||||
|
}: Params): Promise<{ ok: true; tools: McpToolInfo[] } | { ok: false; error: string }> {
|
||||||
|
const envs = await grabRunEnvs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tools = await testMcpServer({
|
||||||
|
server: {
|
||||||
|
id: data.mcp_server_id || 0,
|
||||||
|
name: data.name || "The MCP server",
|
||||||
|
slug: mcpServerSlug({ name: data.name }),
|
||||||
|
description: data.description,
|
||||||
|
transport: data.transport,
|
||||||
|
url: data.url,
|
||||||
|
headers: data.headers,
|
||||||
|
command: data.command,
|
||||||
|
setup_command: data.setup_command,
|
||||||
|
env: data.env,
|
||||||
|
excluded_tools: data.excluded_tools,
|
||||||
|
tools: [],
|
||||||
|
all_bots: data.all_bots,
|
||||||
|
enabled: data.enabled,
|
||||||
|
status: "untested",
|
||||||
|
last_error: "",
|
||||||
|
last_synced_at: null,
|
||||||
|
},
|
||||||
|
envs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, tools };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: redactSecrets({
|
||||||
|
text: error instanceof Error ? error.message : String(error),
|
||||||
|
secrets: envs.filter((env) => env.secret),
|
||||||
|
}).slice(0, 4000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import { SiteData } from "@/src/data/site-data";
|
|
||||||
import type { McpServerRecord } from "@/src/types";
|
import type { McpServerRecord } from "@/src/types";
|
||||||
import grabMcpDir from "./grab-mcp-dir";
|
|
||||||
import grabMcpStdioEnv from "./grab-mcp-stdio-env";
|
import grabMcpStdioEnv from "./grab-mcp-stdio-env";
|
||||||
import hostShell from "./host-shell";
|
import startStdioMcpClient from "./start-stdio-mcp-client";
|
||||||
import runMcpSetup from "./run-mcp-setup";
|
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
/**
|
/**
|
||||||
@@ -31,11 +28,9 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A connected client for a stdio MCP server, started on first use (after
|
* A connected client for a stdio MCP server, started on first use
|
||||||
* its setup, `runMcpSetup`) with the host's shell in its directory, and
|
* (`startStdioMcpClient`) and kept running for later calls until it's idle for `AppData.McpStdioIdleMs`,
|
||||||
* kept running for later calls until it's idle for `AppData.McpStdioIdleMs`,
|
* its configuration changes, or it exits.
|
||||||
* its configuration changes, or it exits. Its stderr's last lines explain
|
|
||||||
* a failed start.
|
|
||||||
*/
|
*/
|
||||||
export default async function grabStdioMcpClient({ server, envs }: Params): Promise<Client> {
|
export default async function grabStdioMcpClient({ server, envs }: Params): Promise<Client> {
|
||||||
const env = grabMcpStdioEnv({ server, envs });
|
const env = grabMcpStdioEnv({ server, envs });
|
||||||
@@ -77,25 +72,10 @@ export function stopStdioMcpServer({ server_id }: { server_id: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function start({ server, env, entry }: { server: McpServerRecord; env: Record<string, string>; entry: Entry }) {
|
async function start({ server, env, entry }: { server: McpServerRecord; env: Record<string, string>; entry: Entry }) {
|
||||||
await runMcpSetup({ server, env });
|
const { client, transport } = await startStdioMcpClient({ 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
entry.transport = transport;
|
entry.transport = transport;
|
||||||
|
|
||||||
const client = new Client({ name: SiteData["SiteSlug"], version: "1.0.0" });
|
|
||||||
|
|
||||||
client.onclose = () => {
|
client.onclose = () => {
|
||||||
if (pool.get(server.id) == entry) {
|
if (pool.get(server.id) == entry) {
|
||||||
pool.delete(server.id);
|
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;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
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 type { McpServerRecord } from "@/src/types";
|
||||||
import fillTemplate from "@/src/utils/fill-template";
|
import fillTemplate from "@/src/utils/fill-template";
|
||||||
import connectMcpServer from "./connect-mcp-server";
|
import connectMcpServer from "./connect-mcp-server";
|
||||||
@@ -17,13 +18,39 @@ type Params = {
|
|||||||
* How one run reaches an MCP server, without connecting yet: `client()`
|
* How one run reaches an MCP server, without connecting yet: `client()`
|
||||||
* connects on the first call and is reused after. A remote server gets
|
* 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
|
* 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) {
|
export default function openMcpServer({ server, envs, signal }: Params) {
|
||||||
let remote: Promise<Client> | undefined;
|
let remote: Promise<Client> | undefined;
|
||||||
|
|
||||||
|
let recorded = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once per run: an error every time (its message may change), a
|
||||||
|
* success only when the server was failing.
|
||||||
|
*/
|
||||||
|
const record = <T>(connecting: Promise<T>) =>
|
||||||
|
connecting.then(
|
||||||
|
async (client) => {
|
||||||
|
if (!recorded && server.status != "ok") await recordMcpServerConnection({ mcp_server_id: server.id });
|
||||||
|
recorded = true;
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
async (error: unknown) => {
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
await recordMcpServerConnection({
|
||||||
|
mcp_server_id: server.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const client = () => {
|
const client = () => {
|
||||||
if (server.transport == "stdio") return grabStdioMcpClient({ server, envs });
|
if (server.transport == "stdio") return record(grabStdioMcpClient({ server, envs }));
|
||||||
|
|
||||||
remote ||= connectMcpServer({
|
remote ||= connectMcpServer({
|
||||||
url: fillTemplate({ template: server.url, envs }),
|
url: fillTemplate({ template: server.url, envs }),
|
||||||
@@ -36,7 +63,7 @@ export default function openMcpServer({ server, envs, signal }: Params) {
|
|||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
return remote;
|
return record(remote);
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = async () => {
|
const close = async () => {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
import type { McpServerRecord } from "@/src/types";
|
||||||
|
import grabMcpDir from "./grab-mcp-dir";
|
||||||
|
import hostShell from "./host-shell";
|
||||||
|
import runMcpSetup from "./run-mcp-setup";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
server: McpServerRecord;
|
||||||
|
/**
|
||||||
|
* Its environment (`grabMcpStdioEnv`).
|
||||||
|
*/
|
||||||
|
env: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a stdio MCP server after its setup (`runMcpSetup`), with the
|
||||||
|
* host's shell in its directory, and connects to it. A failed start
|
||||||
|
* throws with the last lines it wrote to stderr. Closing the client stops
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
export default async function startStdioMcpClient({ server, env }: Params) {
|
||||||
|
await runMcpSetup({ server, env });
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: hostShell(),
|
||||||
|
args: ["-c", server.command],
|
||||||
|
cwd: await grabMcpDir({ server_id: server.id }),
|
||||||
|
env,
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr = `${stderr}${chunk.toString()}`.slice(-4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new Client({ name: SiteData["SiteSlug"], version: "1.0.0" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect(transport, { timeout: AppData.McpConnectTimeoutMs * 4 });
|
||||||
|
} catch (error) {
|
||||||
|
await client.close().catch(() => {});
|
||||||
|
throw new Error(
|
||||||
|
`Couldn't start ${server.name}: ${error instanceof Error ? error.message : String(error)}${
|
||||||
|
stderr.trim() ? `\n${stderr.trim().slice(-2000)}` : ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { client, transport };
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import recordCustomToolRun from "@/src/functions/backend/db/extensions/record-custom-tool-run";
|
||||||
import type { CustomToolRecord } from "@/src/types";
|
import type { CustomToolRecord } from "@/src/types";
|
||||||
import redactSecrets from "@/src/utils/redact-secrets";
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
import type { HarnessTool } from "../../types";
|
import type { HarnessTool } from "../../types";
|
||||||
@@ -19,12 +20,18 @@ type Params = {
|
|||||||
computer_id?: number | string | null;
|
computer_id?: number | string | null;
|
||||||
bot_id?: number | string;
|
bot_id?: number | string;
|
||||||
workspace_id?: number | string | null;
|
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
|
* 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
|
* 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.
|
* when the run has no computer.
|
||||||
*/
|
*/
|
||||||
export default function buildCustomTool({
|
export default function buildCustomTool({
|
||||||
@@ -33,6 +40,7 @@ export default function buildCustomTool({
|
|||||||
computer_id,
|
computer_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
|
record = true,
|
||||||
}: Params): HarnessTool<Record<string, unknown>> | null {
|
}: Params): HarnessTool<Record<string, unknown>> | null {
|
||||||
if (tool.kind == "script" && !computer_id) return null;
|
if (tool.kind == "script" && !computer_id) return null;
|
||||||
|
|
||||||
@@ -58,11 +66,22 @@ export default function buildCustomTool({
|
|||||||
: tool.description,
|
: tool.description,
|
||||||
parameters: tool.parameters,
|
parameters: tool.parameters,
|
||||||
run: async ({ args, signal }) => {
|
run: async ({ args, signal }) => {
|
||||||
|
checkRequiredArgs({ parameters: tool.parameters, args });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
checkRequiredArgs({ parameters: tool.parameters, args });
|
const result = redact(await call({ args, signal }));
|
||||||
return redact(await call({ args, signal }));
|
if (record) await recordCustomToolRun({ custom_tool_id: tool.id });
|
||||||
|
return result;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof Error) error.message = redact(error.message);
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Whose they are: a bot's (`workspace_id` is where it's seen, so that
|
||||||
|
* workspace's show too) or a workspace's.
|
||||||
|
*/
|
||||||
|
owner: { bot_id: number; workspace_id?: number | null } | { workspace_id: number };
|
||||||
|
/**
|
||||||
|
* The bot's or workspace's name, for the confirmations.
|
||||||
|
*/
|
||||||
|
owner_name: string;
|
||||||
|
initial?: BotExtensionsPayload | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot's or workspace's MCP servers and tools, with adding and removing
|
||||||
|
* them (each asks first). Shared by the bot and workspace settings pages
|
||||||
|
* and the chat's composer.
|
||||||
|
*/
|
||||||
|
export default function useExtensions({ owner, owner_name, initial }: Params) {
|
||||||
|
const [data, setData] = useState<BotExtensionsPayload>(
|
||||||
|
initial || { attached: [], addable: [], can_manage: false },
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState<string>();
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
const api = "bot_id" in owner ? "bots" : "workspaces";
|
||||||
|
|
||||||
|
const change = async ({
|
||||||
|
action,
|
||||||
|
kind,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
action: "add" | "remove";
|
||||||
|
kind: BotExtension["kind"];
|
||||||
|
id: number;
|
||||||
|
}) => {
|
||||||
|
setBusy(`${kind}:${id}`);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<BotExtensionsPayload>({
|
||||||
|
path: `${api}/${action}-extension`,
|
||||||
|
body: { ...owner, kind, id },
|
||||||
|
});
|
||||||
|
|
||||||
|
setBusy(undefined);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(res.singleRes);
|
||||||
|
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = async ({ kind, id }: { kind: BotExtension["kind"]; id: number }) => {
|
||||||
|
const item = data.addable.find((entry) => entry.kind == kind && entry.id == id);
|
||||||
|
|
||||||
|
if (!window.confirm(`Give ${owner_name} ${item?.name || "this"}?`)) return false;
|
||||||
|
|
||||||
|
return change({ action: "add", kind, id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async ({ kind, id, name }: { kind: BotExtension["kind"]; id: number; name: string }) => {
|
||||||
|
if (!window.confirm(`Remove ${name} from ${owner_name}?`)) return false;
|
||||||
|
|
||||||
|
return change({ action: "remove", kind, id });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...data, busy, status, add, remove };
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Modal from "@moduletrace/twui/elements/Modal";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import H2 from "@moduletrace/twui/layout/H2";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
|
||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot_name: string;
|
||||||
|
addable: BotExtensionsPayload["addable"];
|
||||||
|
loading?: boolean;
|
||||||
|
onAdd: (params: { kind: "mcp" | "tool"; id: number }) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Add" chip under the composer: picks an MCP server or tool to give
|
||||||
|
* the bot, everywhere it works.
|
||||||
|
*/
|
||||||
|
export default function ComposerAddToolModal({ bot_name, addable, loading, onAdd }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!addable.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
target={
|
||||||
|
<Button
|
||||||
|
title={`Give ${bot_name} a tool or MCP server`}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
beforeIcon={<Plus size={12} aria-hidden />}
|
||||||
|
className="gap-1! px-2! py-0.5! text-[12px] leading-4"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
targetWrapperProps={{ className: "shrink-0" }}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
className="max-w-lg bg-paper p-6"
|
||||||
|
>
|
||||||
|
<Stack className="w-full gap-4! items-stretch">
|
||||||
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
|
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">Add a tool</H2>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{`${bot_name} can use it everywhere it works, for everyone it works for.`}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
<ExtensionAddSelect
|
||||||
|
id="composer-add-extension"
|
||||||
|
addable={addable}
|
||||||
|
loading={loading}
|
||||||
|
onAdd={async (picked) => {
|
||||||
|
if (await onAdd(picked)) setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import { CircleAlert, X } from "lucide-react";
|
||||||
|
import extensionIcon from "@/src/components/general/extension-icon";
|
||||||
|
import type { BotExtension } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BotExtension;
|
||||||
|
busy?: boolean;
|
||||||
|
/**
|
||||||
|
* Takes it from the bot; not given for a workspace's own or when the
|
||||||
|
* user can't.
|
||||||
|
*/
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One MCP server or tool the bot can use here, under the composer. One
|
||||||
|
* that isn't working shows an alert icon, one that's off is struck out.
|
||||||
|
*/
|
||||||
|
export default function ComposerToolChip({ item, busy, onRemove }: Props) {
|
||||||
|
const Icon = item.state == "error" ? CircleAlert : extensionIcon({ kind: item.kind });
|
||||||
|
const notes = [
|
||||||
|
item.detail,
|
||||||
|
item.source == "workspace" ? "From this workspace" : "",
|
||||||
|
!item.enabled ? "Off for every bot" : item.state == "error" ? "Not working" : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
title={`${item.name}: ${notes.join(" · ")}${item.description ? `\n${item.description}` : ""}`}
|
||||||
|
className={`max-w-48 gap-1.5! flex-nowrap items-center rounded-default-lg border border-solid border-hairline bg-surface pl-2 py-0.5 text-[12px] leading-4 ${onRemove ? "pr-0.5" : "pr-2"} ${item.enabled ? "text-ink" : "text-ink-secondary line-through"}`}
|
||||||
|
>
|
||||||
|
<Icon size={12} aria-hidden className="shrink-0" />
|
||||||
|
<Span className="truncate">{item.name}</Span>
|
||||||
|
{onRemove ? (
|
||||||
|
<Button
|
||||||
|
title={`Remove ${item.name}`}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
className="shrink-0 p-0.5!"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<X size={12} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import useExtensions from "@/src/hooks/use-extensions";
|
||||||
|
import type { BotExtensionsPayload, BotWithLastMessage } from "@/src/types";
|
||||||
|
import ComposerAddToolModal from "./composer-add-tool-modal";
|
||||||
|
import ComposerToolChip from "./composer-tool-chip";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
workspace_id?: number | null;
|
||||||
|
initial?: BotExtensionsPayload | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP servers and tools the bot can use here, under the composer.
|
||||||
|
* Super admins and admins remove any it was given (even one every bot
|
||||||
|
* started with) and add more; a workspace's own stay.
|
||||||
|
*/
|
||||||
|
export default function ComposerTools({ bot, workspace_id, initial }: Props) {
|
||||||
|
const extensions = useExtensions({
|
||||||
|
owner: { bot_id: bot.id || 0, workspace_id },
|
||||||
|
owner_name: bot.name || "this bot",
|
||||||
|
initial,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!extensions.attached.length && !(extensions.can_manage && extensions.addable.length)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-6 pb-3 -mt-3">
|
||||||
|
<Span className="text-[12px] leading-4 text-ink-secondary">Tools</Span>
|
||||||
|
{extensions.attached.map((item) => (
|
||||||
|
<ComposerToolChip
|
||||||
|
key={`${item.kind}:${item.id}`}
|
||||||
|
item={item}
|
||||||
|
busy={extensions.busy == `${item.kind}:${item.id}`}
|
||||||
|
onRemove={
|
||||||
|
extensions.can_manage && item.source == "bot"
|
||||||
|
? () => extensions.remove({ kind: item.kind, id: item.id, name: item.name })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{extensions.can_manage ? (
|
||||||
|
<ComposerAddToolModal
|
||||||
|
bot_name={bot.name || "This bot"}
|
||||||
|
addable={extensions.addable}
|
||||||
|
loading={Boolean(extensions.busy)}
|
||||||
|
onAdd={extensions.add}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{extensions.status?.error ? (
|
||||||
|
<Span role="alert" className="text-[12px] leading-4 text-ink-secondary">
|
||||||
|
{extensions.status.msg}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
|
|||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||||
import type {
|
import type {
|
||||||
|
BotExtensionsPayload,
|
||||||
BotMessageWithAttachments,
|
BotMessageWithAttachments,
|
||||||
BotWithLastMessage,
|
BotWithLastMessage,
|
||||||
} from "@/src/types";
|
} from "@/src/types";
|
||||||
@@ -12,6 +13,7 @@ import useComposerDraft from "../(hooks)/use-composer-draft";
|
|||||||
import ComposerAttachButton from "./composer-attach-button";
|
import ComposerAttachButton from "./composer-attach-button";
|
||||||
import ComposerAttachments from "./composer-attachments";
|
import ComposerAttachments from "./composer-attachments";
|
||||||
import ComposerSendButton from "./composer-send-button";
|
import ComposerSendButton from "./composer-send-button";
|
||||||
|
import ComposerTools from "./composer-tools";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
@@ -19,6 +21,10 @@ type Props = {
|
|||||||
* The workspace the conversation is in, if any.
|
* The workspace the conversation is in, if any.
|
||||||
*/
|
*/
|
||||||
workspace_id?: number | null;
|
workspace_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Its MCP servers and tools, listed under the input.
|
||||||
|
*/
|
||||||
|
extensions?: BotExtensionsPayload | null;
|
||||||
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,9 +32,10 @@ type Props = {
|
|||||||
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
|
* 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
|
* 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
|
* 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 { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
@@ -128,6 +135,7 @@ export default function Composer({ bot, workspace_id, onSent }: Props) {
|
|||||||
onClick={send}
|
onClick={send}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
<ComposerTools bot={bot} workspace_id={workspace_id} initial={extensions} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export default function Conversation() {
|
|||||||
<Composer
|
<Composer
|
||||||
bot={bot}
|
bot={bot}
|
||||||
workspace_id={workspace_id}
|
workspace_id={workspace_id}
|
||||||
|
extensions={pageProps?.bot_extensions}
|
||||||
onSent={({ message }) => {
|
onSent={({ message }) => {
|
||||||
if (startsNewSession(message) && message.session_id != session_id) {
|
if (startsNewSession(message) && message.session_id != session_id) {
|
||||||
setSessionId(Number(message.session_id));
|
setSessionId(Number(message.session_id));
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ type Props<T> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who gets an MCP server or tool: every bot (super admins only), or the
|
* Who gets an MCP server or tool: the bots and workspaces picked here.
|
||||||
* bots and workspaces picked here. A workspace gives it to every bot in
|
* "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.
|
* it, there.
|
||||||
*/
|
*/
|
||||||
export default function ExtensionAccessModal<T extends { [k: string]: any }>({
|
export default function ExtensionAccessModal<T extends { [k: string]: any }>({
|
||||||
@@ -77,47 +78,43 @@ export default function ExtensionAccessModal<T extends { [k: string]: any }>({
|
|||||||
{targets.can_edit ? (
|
{targets.can_edit ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
title="Every bot"
|
title="Every bot"
|
||||||
label="Every bot, the coordinator included"
|
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
|
||||||
checked={all_bots}
|
checked={all_bots}
|
||||||
changeHandler={setAllBots}
|
changeHandler={setAllBots}
|
||||||
/>
|
/>
|
||||||
) : access.all_bots ? (
|
) : access.all_bots ? (
|
||||||
<Checkbox title="Every bot" label="Every bot (only a super admin changes this)" checked readOnly />
|
<Checkbox title="Every bot" label="Every bot by default (only a super admin changes this)" checked readOnly />
|
||||||
) : null}
|
) : null}
|
||||||
{all_bots ? null : (
|
<SearchSelect
|
||||||
<>
|
id={`${path}-${id}-bots`}
|
||||||
<SearchSelect
|
title="Bots"
|
||||||
id={`${path}-${id}-bots`}
|
label="Bots"
|
||||||
title="Bots"
|
showLabel
|
||||||
label="Bots"
|
multiple
|
||||||
showLabel
|
placeholder="Pick the bots that get it"
|
||||||
multiple
|
options={targets.bots.map((bot) => ({
|
||||||
placeholder="Pick the bots that get it"
|
value: String(bot.id),
|
||||||
options={targets.bots.map((bot) => ({
|
title: bot.role ? `${bot.name} (${bot.role})` : bot.name,
|
||||||
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."
|
||||||
defaultValues={bot_ids.map(String)}
|
changeHandler={(value) => setBotIds(ids(value))}
|
||||||
info="They get it everywhere they work."
|
/>
|
||||||
changeHandler={(value) => setBotIds(ids(value))}
|
<SearchSelect
|
||||||
/>
|
id={`${path}-${id}-workspaces`}
|
||||||
<SearchSelect
|
title="Workspaces"
|
||||||
id={`${path}-${id}-workspaces`}
|
label="Workspaces"
|
||||||
title="Workspaces"
|
showLabel
|
||||||
label="Workspaces"
|
multiple
|
||||||
showLabel
|
placeholder="Pick the workspaces that get it"
|
||||||
multiple
|
options={targets.workspaces.map((workspace) => ({
|
||||||
placeholder="Pick the workspaces that get it"
|
value: String(workspace.id),
|
||||||
options={targets.workspaces.map((workspace) => ({
|
title: workspace.name,
|
||||||
value: String(workspace.id),
|
}))}
|
||||||
title: workspace.name,
|
defaultValues={workspace_ids.map(String)}
|
||||||
}))}
|
info="Every bot in these gets it, there."
|
||||||
defaultValues={workspace_ids.map(String)}
|
changeHandler={(value) => setWorkspaceIds(ids(value))}
|
||||||
info="Every bot in these gets it, there."
|
/>
|
||||||
changeHandler={(value) => setWorkspaceIds(ids(value))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
||||||
</Form>
|
</Form>
|
||||||
</AddModalShell>
|
</AddModalShell>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import IconTile from "@/src/components/general/icon-tile";
|
||||||
|
import extensionIcon from "@/src/components/general/extension-icon";
|
||||||
|
import type { BotExtension } from "@/src/types";
|
||||||
|
import SettingsListRow from "./settings-list-row";
|
||||||
|
import SettingsRowAction from "./settings-row-action";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: BotExtension;
|
||||||
|
busy?: boolean;
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One MCP server or tool a bot or workspace has, and whether it's working.
|
||||||
|
*/
|
||||||
|
export default function ExtensionRow({ item, busy, onRemove }: Props) {
|
||||||
|
const state = !item.enabled
|
||||||
|
? "Off for every bot"
|
||||||
|
: item.state == "error"
|
||||||
|
? "Not working"
|
||||||
|
: item.state == "untested"
|
||||||
|
? "Not checked yet"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsListRow
|
||||||
|
avatar={<IconTile Icon={extensionIcon({ kind: item.kind })} />}
|
||||||
|
title={item.name}
|
||||||
|
href={item.kind == "mcp" ? "/admin/settings/mcps" : "/admin/settings/tools"}
|
||||||
|
subtitle={[item.detail, state].filter(Boolean).join(" · ")}
|
||||||
|
>
|
||||||
|
{onRemove ? (
|
||||||
|
<SettingsRowAction title={`Remove ${item.name}`} label="Remove" disabled={busy} onClick={onRemove} />
|
||||||
|
) : null}
|
||||||
|
</SettingsListRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { Check, CircleAlert, CircleDashed } from "lucide-react";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
state: "ok" | "error" | "untested";
|
||||||
|
/**
|
||||||
|
* The status in words, e.g. "Connected · 12 tools · checked 5m ago".
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* What went wrong, under it.
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an MCP server connects or a tool works, under its row. Said in
|
||||||
|
* words with an icon, never with color alone.
|
||||||
|
*/
|
||||||
|
export default function ExtensionStatusLine({ state, text, error }: Props) {
|
||||||
|
const Icon = state == "ok" ? Check : state == "error" ? CircleAlert : CircleDashed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack role="status" className="w-full gap-0.5! items-stretch pl-14 pr-3 pb-2.5 -mt-1">
|
||||||
|
<Row className="gap-1.5! flex-nowrap items-center">
|
||||||
|
<Icon size={14} aria-hidden className="shrink-0" />
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink">{text}</Span>
|
||||||
|
</Row>
|
||||||
|
{error ? (
|
||||||
|
<Span
|
||||||
|
title={error}
|
||||||
|
className="text-[13px] leading-[18px] text-ink-secondary whitespace-pre-wrap break-words line-clamp-3"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import { Plug, Wrench } from "lucide-react";
|
||||||
|
import ExtensionAddSelect from "@/src/components/general/extension-add-select";
|
||||||
|
import useExtensions from "@/src/hooks/use-extensions";
|
||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import ExtensionRow from "./extension-row";
|
||||||
|
import SettingsGroup from "./settings-group";
|
||||||
|
import SettingsList from "./settings-list";
|
||||||
|
import SettingsStatus from "./settings-status";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
owner: { bot_id: number } | { workspace_id: number };
|
||||||
|
owner_name: string;
|
||||||
|
description: string;
|
||||||
|
initial?: BotExtensionsPayload | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP servers and tools a bot or a workspace has, removable, and a
|
||||||
|
* picker to add more. On the bot and workspace settings pages.
|
||||||
|
*/
|
||||||
|
export default function ExtensionsGroup({ owner, owner_name, description, initial }: Props) {
|
||||||
|
const extensions = useExtensions({ owner, owner_name, initial });
|
||||||
|
const id = "bot_id" in owner ? `bot-${owner.bot_id}` : `workspace-${owner.workspace_id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup id="tools" title="Tools and MCP servers" description={description}>
|
||||||
|
<SettingsList is_empty={!extensions.attached.length} empty="No tools or MCP servers yet.">
|
||||||
|
{extensions.attached.map((item) => (
|
||||||
|
<ExtensionRow
|
||||||
|
key={`${item.kind}:${item.id}`}
|
||||||
|
item={item}
|
||||||
|
busy={extensions.busy == `${item.kind}:${item.id}`}
|
||||||
|
onRemove={
|
||||||
|
extensions.can_manage
|
||||||
|
? () => extensions.remove({ kind: item.kind, id: item.id, name: item.name })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsList>
|
||||||
|
{extensions.can_manage ? (
|
||||||
|
<ExtensionAddSelect
|
||||||
|
id={`${id}-add-extension`}
|
||||||
|
addable={extensions.addable}
|
||||||
|
loading={Boolean(extensions.busy)}
|
||||||
|
onAdd={extensions.add}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Row className="gap-1!">
|
||||||
|
<Button
|
||||||
|
title="Manage MCP servers"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
href="/admin/settings/mcps"
|
||||||
|
beforeIcon={<Plug size={14} aria-hidden />}
|
||||||
|
className="gap-1!"
|
||||||
|
>
|
||||||
|
MCP servers
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
title="Manage tools"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
href="/admin/settings/tools"
|
||||||
|
beforeIcon={<Wrench size={14} aria-hidden />}
|
||||||
|
className="gap-1!"
|
||||||
|
>
|
||||||
|
Tools
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
<SettingsStatus status={extensions.status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import relativeTime from "@/src/utils/relative-time";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a status was last checked, for a sentence: "just now", "5m ago",
|
||||||
|
* "on Tue".
|
||||||
|
*/
|
||||||
|
export default function checkedAgo({ at }: Params) {
|
||||||
|
const time = relativeTime({ at });
|
||||||
|
|
||||||
|
if (time == "now") return "just now";
|
||||||
|
|
||||||
|
return /^\d/.test(time) ? `${time} ago` : `on ${time}`;
|
||||||
|
}
|
||||||
@@ -5,13 +5,12 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who gets an MCP server or tool, in a few words: "Every bot",
|
* Who gets an MCP server or tool, in a few words: "2 bots · 1 workspace",
|
||||||
* "2 bots · 1 workspace", or "No bots yet".
|
* "Every bot by default · 5 bots", or "No bots yet".
|
||||||
*/
|
*/
|
||||||
export default function extensionAccessLabel({ access }: Params) {
|
export default function extensionAccessLabel({ access }: Params) {
|
||||||
if (access.all_bots) return "Every bot";
|
|
||||||
|
|
||||||
const parts = [
|
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.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} ${access.workspace_ids.length == 1 ? "workspace" : "workspaces"}`
|
? `${access.workspace_ids.length} ${access.workspace_ids.length == 1 ? "workspace" : "workspaces"}`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AppContext } from "@/src/pages/__root";
|
|||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import type { BotWithLastMessage } from "@/src/types";
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
import BotHarnessForm from "../../../(partials)/bot-harness-form";
|
import BotHarnessForm from "../../../(partials)/bot-harness-form";
|
||||||
|
import ExtensionsGroup from "../../../(partials)/extensions-group";
|
||||||
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
||||||
import SettingsShell from "../../../(partials)/settings-shell";
|
import SettingsShell from "../../../(partials)/settings-shell";
|
||||||
import BotComputersGroup from "../(partials)/bot-computers-group";
|
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,
|
* 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.
|
* edits it.
|
||||||
*/
|
*/
|
||||||
export default function BotSettingsSection() {
|
export default function BotSettingsSection() {
|
||||||
@@ -70,6 +71,12 @@ export default function BotSettingsSection() {
|
|||||||
computers={pageProps?.bot_computers || []}
|
computers={pageProps?.bot_computers || []}
|
||||||
all_computers={pageProps?.computers || []}
|
all_computers={pageProps?.computers || []}
|
||||||
/>
|
/>
|
||||||
|
<ExtensionsGroup
|
||||||
|
owner={{ bot_id: bot.id }}
|
||||||
|
owner_name={name}
|
||||||
|
description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there."
|
||||||
|
initial={pageProps?.bot_extensions}
|
||||||
|
/>
|
||||||
{is_coordinator ? null : <BotDeleteGroup bot={bot} />}
|
{is_coordinator ? null : <BotDeleteGroup bot={bot} />}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
|||||||
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-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 grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||||
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
|
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
|
||||||
import grabBotWorkspaces from "@/src/functions/backend/db/workspaces/grab-bot-workspaces";
|
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";
|
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.
|
* super admins and admins; the coordinator is the super admin's alone.
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
@@ -35,6 +37,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
all_computers,
|
all_computers,
|
||||||
bot_computer_rows,
|
bot_computer_rows,
|
||||||
bot_harness_overview,
|
bot_harness_overview,
|
||||||
|
bot_extensions,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
grabBots({ user_id }),
|
grabBots({ user_id }),
|
||||||
grabUserWorkspaces({ user_id }),
|
grabUserWorkspaces({ user_id }),
|
||||||
@@ -45,6 +48,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||||
: grabBotHarnessOverview({ bot_id: bot.id })
|
: grabBotHarnessOverview({ bot_id: bot.id })
|
||||||
).catch(() => null),
|
).catch(() => null),
|
||||||
|
grabBotExtensionsOverview({ user_id, bot_id: bot.id }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bot_computers = bot_computer_rows
|
const bot_computers = bot_computer_rows
|
||||||
@@ -65,6 +69,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
bot_computers,
|
bot_computers,
|
||||||
computers: all_computers,
|
computers: all_computers,
|
||||||
bot_harness_overview,
|
bot_harness_overview,
|
||||||
|
bot_extensions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import Input from "@moduletrace/twui/form/Input";
|
|||||||
import Select from "@moduletrace/twui/form/Select";
|
import Select from "@moduletrace/twui/form/Select";
|
||||||
import CodeEditor from "@/src/components/general/code-editor";
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
|
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
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 parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
|
||||||
import McpToolToggle from "./mcp-tool-toggle";
|
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
|
* 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,
|
* 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
|
* 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
|
* use `{{env.KEY}}`. Test connection checks the form as it is, saved or
|
||||||
* left out.
|
* 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) {
|
export default function McpServerModal({ server, busy, onReinstall, onSaved, onCancel }: Props) {
|
||||||
const [name, setName] = useState(server?.name || "");
|
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 [all_bots, setAllBots] = useState(server?.access.all_bots ?? false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
const [tools, setTools] = useState(server?.tools || []);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [test, setTest] = useState<{ ok: boolean; msg: string; error?: string }>();
|
||||||
|
|
||||||
const id = `mcp-${server?.id || "new"}`;
|
const id = `mcp-${server?.id || "new"}`;
|
||||||
|
|
||||||
|
const body = () => ({
|
||||||
|
mcp_server_id: server?.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
transport,
|
||||||
|
url,
|
||||||
|
headers: parseTemplateLines({ text: headers, separator: ":" }),
|
||||||
|
command,
|
||||||
|
setup_command,
|
||||||
|
env: parseTemplateLines({ text: env, separator: "=" }),
|
||||||
|
excluded_tools: excluded,
|
||||||
|
enabled,
|
||||||
|
all_bots,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects as the form is now, saved or not, and shows its tools.
|
||||||
|
*/
|
||||||
|
const runTest = async () => {
|
||||||
|
if (testing) return;
|
||||||
|
|
||||||
|
setTesting(true);
|
||||||
|
setTest(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<McpServerTestPayload>({ path: "mcps/test-mcp", body: body() });
|
||||||
|
|
||||||
|
setTesting(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setTest({ ok: false, msg: res.msg || "Couldn't test it." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.singleRes.ok) setTools(res.singleRes.tools);
|
||||||
|
|
||||||
|
setTest({ ok: res.singleRes.ok, msg: res.msg || "", error: res.singleRes.error });
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (!window.confirm(server ? `Save ${server.name}?` : `Add ${name || "this MCP server"}?`)) return;
|
if (!window.confirm(server ? `Save ${server.name}?` : `Add ${name || "this MCP server"}?`)) return;
|
||||||
@@ -62,20 +106,7 @@ export default function McpServerModal({ server, busy, onReinstall, onSaved, onC
|
|||||||
|
|
||||||
const res = await postAdminApi<McpServersOverviewPayload>({
|
const res = await postAdminApi<McpServersOverviewPayload>({
|
||||||
path: "mcps/save-mcp",
|
path: "mcps/save-mcp",
|
||||||
body: {
|
body: 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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
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."
|
info="One KEY=value per line: the only envs it gets, besides PATH and HOME."
|
||||||
onChange={(value) => setEnv(value)}
|
onChange={(value) => setEnv(value)}
|
||||||
/>
|
/>
|
||||||
{server?.id && onReinstall ? (
|
|
||||||
<Button
|
|
||||||
title="Run the setup command again"
|
|
||||||
type="button"
|
|
||||||
variant="outlined"
|
|
||||||
color="gray"
|
|
||||||
size="small"
|
|
||||||
className="self-start"
|
|
||||||
disabled={busy || loading}
|
|
||||||
onClick={onReinstall}
|
|
||||||
>
|
|
||||||
Reinstall
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{server?.status == "error" && server.last_error ? (
|
<Row className="gap-2! items-center">
|
||||||
|
<Button
|
||||||
|
title="Connect as the form is now, without saving"
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
loading={testing}
|
||||||
|
disabled={testing || loading || busy}
|
||||||
|
onClick={runTest}
|
||||||
|
>
|
||||||
|
Test connection
|
||||||
|
</Button>
|
||||||
|
{transport == "stdio" && server?.id && onReinstall ? (
|
||||||
|
<Button
|
||||||
|
title="Run the setup command again"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
disabled={testing || loading || busy}
|
||||||
|
onClick={onReinstall}
|
||||||
|
>
|
||||||
|
Reinstall
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
{test ? (
|
||||||
|
<SettingsStatus status={test.ok ? { success: true, msg: test.msg } : { error: true, msg: test.msg }} />
|
||||||
|
) : null}
|
||||||
|
{test?.error || (!test && server?.status == "error" && server.last_error) ? (
|
||||||
<Span className="text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words">
|
<Span className="text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words">
|
||||||
{server.last_error}
|
{test?.error || server?.last_error}
|
||||||
</Span>
|
</Span>
|
||||||
) : null}
|
) : null}
|
||||||
{server?.tools.length ? (
|
{tools.length ? (
|
||||||
<Stack className="gap-2! items-stretch">
|
<Stack className="gap-2! items-stretch">
|
||||||
<Span className="text-[13px] font-bold leading-5 text-ink">Tools bots get</Span>
|
<Span className="text-[13px] font-bold leading-5 text-ink">Tools bots get</Span>
|
||||||
{server.tools.map((tool) => (
|
{tools.map((tool) => (
|
||||||
<McpToolToggle
|
<McpToolToggle
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
tool={tool}
|
tool={tool}
|
||||||
@@ -216,7 +263,7 @@ export default function McpServerModal({ server, busy, onReinstall, onSaved, onC
|
|||||||
<Checkbox title="On" label="On: bots get its tools" checked={enabled} changeHandler={setEnabled} />
|
<Checkbox title="On" label="On: bots get its tools" checked={enabled} changeHandler={setEnabled} />
|
||||||
<Checkbox
|
<Checkbox
|
||||||
title="Every bot"
|
title="Every bot"
|
||||||
label="Every bot, the coordinator included"
|
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
|
||||||
checked={all_bots}
|
checked={all_bots}
|
||||||
changeHandler={setAllBots}
|
changeHandler={setAllBots}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import IconTile from "@/src/components/general/icon-tile";
|
|||||||
import { McpTransports } from "@/src/dict/mcp-dict";
|
import { McpTransports } from "@/src/dict/mcp-dict";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
import type { ExtensionTargets, McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
|
import type { ExtensionTargets, McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
|
||||||
|
import ExtensionStatusLine from "../../(partials)/extension-status-line";
|
||||||
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||||
|
import checkedAgo from "../../(utils)/checked-ago";
|
||||||
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||||
import McpServerModal from "./mcp-server-modal";
|
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
|
* One MCP server: how it's reached and who gets it, and under it whether
|
||||||
* it. Edit and Access open in a modal.
|
* 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) {
|
export default function McpServerRow({ server, targets, onChanged, onError }: Props) {
|
||||||
const [open, setOpen] = useState<"edit" | "access">();
|
const [open, setOpen] = useState<"edit" | "access">();
|
||||||
@@ -42,12 +45,14 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
|
|||||||
onChanged(overview, msg);
|
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 =
|
const status =
|
||||||
server.status == "ok"
|
server.status == "ok"
|
||||||
? `${server.tools.length} ${server.tools.length == 1 ? "tool" : "tools"}`
|
? { text: `Connected · ${tools}${when}` }
|
||||||
: server.status == "error"
|
: server.status == "error"
|
||||||
? `Error: ${server.last_error}`
|
? { text: `Couldn't connect${when}`, error: server.last_error }
|
||||||
: "Not connected yet";
|
: { text: server.enabled ? "Not connected yet: Refresh to connect." : "Not connected yet." };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||||
@@ -57,7 +62,6 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
|
|||||||
badge={server.enabled ? undefined : "Off"}
|
badge={server.enabled ? undefined : "Off"}
|
||||||
subtitle={[
|
subtitle={[
|
||||||
McpTransports.find((t) => t.value == server.transport)?.title,
|
McpTransports.find((t) => t.value == server.transport)?.title,
|
||||||
status,
|
|
||||||
extensionAccessLabel({ access: server.access }),
|
extensionAccessLabel({ access: server.access }),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -100,6 +104,7 @@ export default function McpServerRow({ server, targets, onChanged, onError }: Pr
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SettingsListRow>
|
</SettingsListRow>
|
||||||
|
<ExtensionStatusLine state={server.status} {...status} />
|
||||||
{open == "edit" ? (
|
{open == "edit" ? (
|
||||||
<McpServerModal
|
<McpServerModal
|
||||||
server={server}
|
server={server}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function McpsSettingsSection() {
|
|||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="MCP servers"
|
title="MCP servers"
|
||||||
description="A server's tools go to the bots and workspaces it's attached to, or to every bot. Bots only connect when they call one."
|
description="A server's tools go to the bots and workspaces it's attached to (give one to a bot on its settings page or in its chat); one set to every bot goes to every bot by default. Bots only connect when they call one."
|
||||||
>
|
>
|
||||||
{adding ? (
|
{adding ? (
|
||||||
<McpServerModal
|
<McpServerModal
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
|||||||
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
|
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
|
||||||
import { DefaultToolConfigs, DefaultToolParameters } from "../(data)/tool-defaults";
|
import { DefaultToolConfigs, DefaultToolParameters } from "../(data)/tool-defaults";
|
||||||
import CustomToolKindFields, { type ToolConfigs } from "./custom-tool-kind-fields";
|
import CustomToolKindFields, { type ToolConfigs } from "./custom-tool-kind-fields";
|
||||||
|
import CustomToolTestPanel from "./custom-tool-test-panel";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +26,8 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* Adds or edits a custom tool (super admins): the name and description
|
* Adds or edits a custom tool (super admins): the name and description
|
||||||
* the model reads, its arguments as a JSON schema, and what it does (an
|
* the model reads, its arguments as a JSON schema, and what it does (an
|
||||||
* HTTP request, a script, or a prompt).
|
* HTTP request, a script, or a prompt). Test runs the form as it is,
|
||||||
|
* saved or not, with sample arguments.
|
||||||
*/
|
*/
|
||||||
export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
|
export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
|
||||||
const [name, setName] = useState(tool?.name || "");
|
const [name, setName] = useState(tool?.name || "");
|
||||||
@@ -45,6 +47,17 @@ export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
|
|||||||
|
|
||||||
const id = `tool-${tool?.id || "new"}`;
|
const id = `tool-${tool?.id || "new"}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tool as the form has it, for a test that saves nothing.
|
||||||
|
*/
|
||||||
|
const draft = () => {
|
||||||
|
try {
|
||||||
|
return { draft: { parameters: JSON.parse(parameters), kind, config: configs[kind] } };
|
||||||
|
} catch {
|
||||||
|
return "The arguments schema isn't valid JSON.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
@@ -133,10 +146,11 @@ export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
|
|||||||
changeHandler={(value) => setKind(String(value) as CustomToolKind)}
|
changeHandler={(value) => setKind(String(value) as CustomToolKind)}
|
||||||
/>
|
/>
|
||||||
<CustomToolKindFields key={kind} id={id} kind={kind} configs={configs} onChange={setConfigs} />
|
<CustomToolKindFields key={kind} id={id} kind={kind} configs={configs} onChange={setConfigs} />
|
||||||
|
<CustomToolTestPanel target={draft} />
|
||||||
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
|
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
|
||||||
<Checkbox
|
<Checkbox
|
||||||
title="Every bot"
|
title="Every bot"
|
||||||
label="Every bot, the coordinator included"
|
label="Every bot by default: every bot now and every new one, the coordinator included. Each can still drop it."
|
||||||
checked={all_bots}
|
checked={all_bots}
|
||||||
changeHandler={setAllBots}
|
changeHandler={setAllBots}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import IconTile from "@/src/components/general/icon-tile";
|
|||||||
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
import type { CustomToolsOverviewPayload, ExtensionTargets, PublicCustomTool } from "@/src/types";
|
import type { CustomToolsOverviewPayload, ExtensionTargets, PublicCustomTool } from "@/src/types";
|
||||||
|
import ExtensionStatusLine from "../../(partials)/extension-status-line";
|
||||||
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||||
|
import checkedAgo from "../../(utils)/checked-ago";
|
||||||
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||||
import CustomToolModal from "./custom-tool-modal";
|
import CustomToolModal from "./custom-tool-modal";
|
||||||
import CustomToolTryModal from "./custom-tool-try-modal";
|
import CustomToolTryModal from "./custom-tool-try-modal";
|
||||||
@@ -16,14 +18,19 @@ type Props = {
|
|||||||
tool: PublicCustomTool;
|
tool: PublicCustomTool;
|
||||||
targets: ExtensionTargets;
|
targets: ExtensionTargets;
|
||||||
onChanged: (overview: CustomToolsOverviewPayload, msg: string) => void;
|
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;
|
onError: (msg: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One custom tool: its name, kind and who gets it. Edit, Access and Try
|
* One custom tool: its name, kind and who gets it, and under it how its
|
||||||
* open in a modal.
|
* 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 [open, setOpen] = useState<"edit" | "access" | "try">();
|
||||||
const [busy, setBusy] = useState(false);
|
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.");
|
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 (
|
return (
|
||||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||||
<SettingsListRow
|
<SettingsListRow
|
||||||
@@ -78,6 +93,7 @@ export default function CustomToolRow({ tool, targets, onChanged, onError }: Pro
|
|||||||
<SettingsRowAction title={`Delete ${tool.name}`} label="Delete" disabled={busy} onClick={remove} />
|
<SettingsRowAction title={`Delete ${tool.name}`} label="Delete" disabled={busy} onClick={remove} />
|
||||||
) : null}
|
) : null}
|
||||||
</SettingsListRow>
|
</SettingsListRow>
|
||||||
|
<ExtensionStatusLine state={tool.status} {...status} />
|
||||||
{open == "edit" ? (
|
{open == "edit" ? (
|
||||||
<CustomToolModal tool={tool} onSaved={saved} onCancel={() => setOpen(undefined)} />
|
<CustomToolModal tool={tool} onSaved={saved} onCancel={() => setOpen(undefined)} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -92,7 +108,7 @@ export default function CustomToolRow({ tool, targets, onChanged, onError }: Pro
|
|||||||
onCancel={() => setOpen(undefined)}
|
onCancel={() => setOpen(undefined)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{open == "try" ? <CustomToolTryModal tool={tool} onClose={() => setOpen(undefined)} /> : null}
|
{open == "try" ? <CustomToolTryModal tool={tool} onTried={onRefreshed} onClose={() => setOpen(undefined)} /> : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { CustomToolsOverviewPayload, CustomToolTestPayload } from "@/src/types";
|
||||||
|
import SettingsStatus from "../../(partials)/settings-status";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* What to run: `{ custom_tool_id }` for a saved tool, `{ draft }` for
|
||||||
|
* the form as it is, or why the form can't be run yet.
|
||||||
|
*/
|
||||||
|
target: () => Record<string, unknown> | string;
|
||||||
|
/**
|
||||||
|
* The page data after a saved tool's run, with its new status.
|
||||||
|
*/
|
||||||
|
onTested?: (overview: CustomToolsOverviewPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a tool once with sample arguments and shows whether it worked and
|
||||||
|
* what a bot would read. A script runs on the main computer, with general
|
||||||
|
* envs.
|
||||||
|
*/
|
||||||
|
export default function CustomToolTestPanel({ target, onTested }: Props) {
|
||||||
|
const [args, setArgs] = useState("{}");
|
||||||
|
const [result, setResult] = useState<{ output: string; ok: boolean }>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
const what = target();
|
||||||
|
|
||||||
|
if (typeof what == "string") {
|
||||||
|
setError(what);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(args || "{}");
|
||||||
|
} catch {
|
||||||
|
setError("The test arguments aren't valid JSON.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
setResult(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<CustomToolTestPayload>({
|
||||||
|
path: "tools/test-tool",
|
||||||
|
body: { ...what, args: parsed },
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setError(res.msg || "Couldn't run the tool.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({ output: res.singleRes.output, ok: res.singleRes.ok });
|
||||||
|
onTested?.(res.singleRes.overview);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full gap-3! items-stretch">
|
||||||
|
<CodeEditor
|
||||||
|
mode="json"
|
||||||
|
className="h-28"
|
||||||
|
title="Test arguments"
|
||||||
|
content={args}
|
||||||
|
info="Sample arguments, as a bot would send them."
|
||||||
|
onChange={(value) => setArgs(value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
title="Run it once with these arguments"
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
className="self-start"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={run}
|
||||||
|
>
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<SettingsStatus
|
||||||
|
status={
|
||||||
|
error
|
||||||
|
? { error: true, msg: error }
|
||||||
|
: result
|
||||||
|
? result.ok
|
||||||
|
? { success: true, msg: "It worked." }
|
||||||
|
: { error: true, msg: "It failed." }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{result ? (
|
||||||
|
<Span className="block font-mono text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words max-h-96 overflow-auto rounded-default-sm border border-solid border-hairline bg-paper p-3">
|
||||||
|
{result.output}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,80 +1,39 @@
|
|||||||
import { useState } from "react";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
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 AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
import Form from "@moduletrace/twui/form/Form";
|
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import CustomToolTestPanel from "./custom-tool-test-panel";
|
||||||
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";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tool: PublicCustomTool;
|
tool: PublicCustomTool;
|
||||||
|
/**
|
||||||
|
* The page data with the tool's new status.
|
||||||
|
*/
|
||||||
|
onTried: (overview: CustomToolsOverviewPayload) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a saved tool once with sample arguments and shows what a bot would
|
* Runs a saved tool once with sample arguments (`CustomToolTestPanel`).
|
||||||
* read. A script runs on the main computer, with general envs.
|
* The run becomes the tool's status.
|
||||||
*/
|
*/
|
||||||
export default function CustomToolTryModal({ tool, onClose }: Props) {
|
export default function CustomToolTryModal({ tool, onTried, onClose }: Props) {
|
||||||
const [args, setArgs] = useState("{}");
|
|
||||||
const [output, setOutput] = useState<string>();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (loading) return;
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(args || "{}");
|
|
||||||
} catch {
|
|
||||||
setError("The arguments aren't valid JSON.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(undefined);
|
|
||||||
|
|
||||||
const res = await postAdminApi<CustomToolTestPayload>({
|
|
||||||
path: "tools/test-tool",
|
|
||||||
body: { custom_tool_id: tool.id, args: parsed },
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
if (!res.success || !res.singleRes) {
|
|
||||||
setError(res.msg || "Couldn't run the tool.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOutput(res.singleRes.output);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AddModalShell
|
<AddModalShell
|
||||||
open
|
open
|
||||||
setOpen={(next) => !next && onClose()}
|
setOpen={(next) => !next && onClose()}
|
||||||
title={`Try ${tool.name}`}
|
title={`Try ${tool.name}`}
|
||||||
description={"Runs it once and shows what a bot would read. A script runs on the main computer, with general envs."}
|
description="Runs it once and shows what a bot would read. A script runs on the main computer, with general envs."
|
||||||
>
|
>
|
||||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
<Stack className="w-full gap-4! items-stretch pt-4">
|
||||||
<CodeEditor
|
<CustomToolTestPanel target={() => ({ custom_tool_id: tool.id })} onTested={onTried} />
|
||||||
mode="json"
|
<Row className="justify-end">
|
||||||
className="h-28"
|
<Button title="Close" type="button" variant="ghost" color="gray" size="small" onClick={onClose}>
|
||||||
title="Arguments"
|
Close
|
||||||
content={args}
|
</Button>
|
||||||
info="Saved changes only: save the tool first."
|
</Row>
|
||||||
onChange={(value) => setArgs(value)}
|
</Stack>
|
||||||
/>
|
|
||||||
{output !== undefined ? (
|
|
||||||
<Span className="block font-mono text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words max-h-96 overflow-auto rounded-default-sm border border-solid border-hairline bg-paper p-3">
|
|
||||||
{output}
|
|
||||||
</Span>
|
|
||||||
) : null}
|
|
||||||
<AddFormActions submit_label="Run it" loading={loading} error={error} onCancel={onClose} />
|
|
||||||
</Form>
|
|
||||||
</AddModalShell>
|
</AddModalShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function ToolsSettingsSection() {
|
|||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
title="Tools"
|
title="Tools"
|
||||||
description="An API call, a script on the bot's computer, or a prompt. A tool goes to the bots and workspaces it's attached to, or to every bot."
|
description="An API call, a script on the bot's computer, or a prompt. A tool goes to the bots and workspaces it's attached to (give one to a bot on its settings page or in its chat); one set to every bot goes to every bot by default."
|
||||||
>
|
>
|
||||||
{adding ? (
|
{adding ? (
|
||||||
<CustomToolModal
|
<CustomToolModal
|
||||||
@@ -55,6 +55,7 @@ export default function ToolsSettingsSection() {
|
|||||||
tool={tool}
|
tool={tool}
|
||||||
targets={overview}
|
targets={overview}
|
||||||
onChanged={changed}
|
onChanged={changed}
|
||||||
|
onRefreshed={setOverview}
|
||||||
onError={failed}
|
onError={failed}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+9
-1
@@ -2,6 +2,7 @@ import { useContext, useState } from "react";
|
|||||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import ExtensionsGroup from "../../../(partials)/extensions-group";
|
||||||
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
||||||
import SettingsShell from "../../../(partials)/settings-shell";
|
import SettingsShell from "../../../(partials)/settings-shell";
|
||||||
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
||||||
@@ -12,7 +13,8 @@ import WorkspaceImageGroup from "../(partials)/workspace-image-group";
|
|||||||
import WorkspaceModels from "../(partials)/workspace-models";
|
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
|
* then deleting it. Super admins and admins manage it; a member with no
|
||||||
* bots sees an empty note.
|
* bots sees an empty note.
|
||||||
*/
|
*/
|
||||||
@@ -62,6 +64,12 @@ export default function WorkspaceOverview() {
|
|||||||
computers={pageProps?.workspace_computers || []}
|
computers={pageProps?.workspace_computers || []}
|
||||||
all_computers={pageProps?.computers || []}
|
all_computers={pageProps?.computers || []}
|
||||||
/>
|
/>
|
||||||
|
<ExtensionsGroup
|
||||||
|
owner={{ workspace_id }}
|
||||||
|
owner_name={name}
|
||||||
|
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here."
|
||||||
|
initial={pageProps?.workspace_extensions}
|
||||||
|
/>
|
||||||
{harnesses?.length ? (
|
{harnesses?.length ? (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionHeading
|
<SettingsSectionHeading
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { PagePropsType, PageQueryObject } from "@/src/types";
|
|||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||||
|
import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
|
||||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||||
@@ -9,8 +10,8 @@ import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-w
|
|||||||
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One workspace's settings: its picture, details, bots, computers and
|
* One workspace's settings: its picture, details, bots, computers, tools
|
||||||
* models. For super admins and admins.
|
* and MCP servers, and models. For super admins and admins.
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
const settings = await grabCrewSettingsServerProps(params);
|
const settings = await grabCrewSettingsServerProps(params);
|
||||||
@@ -41,12 +42,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
workspace_computers,
|
workspace_computers,
|
||||||
computers,
|
computers,
|
||||||
workspace_harness_overview,
|
workspace_harness_overview,
|
||||||
|
workspace_extensions,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
grabWorkspaceBots({ user_id, workspace_id }),
|
grabWorkspaceBots({ user_id, workspace_id }),
|
||||||
grabBots({ user_id }),
|
grabBots({ user_id }),
|
||||||
grabWorkspaceComputers({ workspace_id }),
|
grabWorkspaceComputers({ workspace_id }),
|
||||||
grabUserComputers({ user_id }),
|
grabUserComputers({ user_id }),
|
||||||
grabBotHarnessOverview({ workspace_id }).catch(() => null),
|
grabBotHarnessOverview({ workspace_id }).catch(() => null),
|
||||||
|
grabWorkspaceExtensionsOverview({ user_id, workspace_id }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -60,6 +63,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
workspace_computers,
|
workspace_computers,
|
||||||
computers,
|
computers,
|
||||||
workspace_harness_overview,
|
workspace_harness_overview,
|
||||||
|
workspace_extensions,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview";
|
||||||
|
import setBotExtension from "@/src/functions/backend/db/extensions/set-bot-extension";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SetBotExtensionRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type {
|
||||||
|
APIResponseObject,
|
||||||
|
BunextAPIRouteHandler,
|
||||||
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admins and admins (the super admin alone for the coordinator):
|
||||||
|
* gives a bot an MCP server or tool.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bot_id, kind, id, workspace_id } = parseWithSchema({ schema: SetBotExtensionRequestSchema, data: body });
|
||||||
|
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: true });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Bot tool added",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to ${bot.name} (#${bot.id}).`,
|
||||||
|
metadata: { bot_id, kind, id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Added ${name}.`,
|
||||||
|
singleRes: await grabBotExtensionsOverview({ user_id: user.id, bot_id, workspace_id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview";
|
||||||
|
import setBotExtension from "@/src/functions/backend/db/extensions/set-bot-extension";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SetBotExtensionRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type {
|
||||||
|
APIResponseObject,
|
||||||
|
BunextAPIRouteHandler,
|
||||||
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admins and admins (the super admin alone for the coordinator):
|
||||||
|
* takes an MCP server or tool from a bot, even one every bot started
|
||||||
|
* with.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bot_id, kind, id, workspace_id } = parseWithSchema({ schema: SetBotExtensionRequestSchema, data: body });
|
||||||
|
const { bot, name } = await setBotExtension({ user_id: user.id, bot_id, kind, id, attached: false });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Bot tool removed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from ${bot.name} (#${bot.id}).`,
|
||||||
|
metadata: { bot_id, kind, id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Removed ${name}.`,
|
||||||
|
singleRes: await grabBotExtensionsOverview({ user_id: user.id, bot_id, workspace_id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import testMcpDraft from "@/src/functions/backend/db/extensions/test-mcp-draft";
|
||||||
|
import { TestMcpServerRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import type { McpServerTestPayload } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type {
|
||||||
|
APIResponseObject,
|
||||||
|
BunextAPIRouteHandler,
|
||||||
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admin only: connects to an MCP server as the form has it, saved or
|
||||||
|
* not, and returns its tools or why it didn't connect. Nothing is saved.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<McpServerTestPayload>
|
||||||
|
> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await grabSuperAdminUser({ req });
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
throw new AppError(`Only a super admin can do that.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseWithSchema({ schema: TestMcpServerRequestSchema, data: body });
|
||||||
|
const result = await testMcpDraft({ data });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: result.ok
|
||||||
|
? `Connected: ${result.tools.length} ${result.tools.length == 1 ? "tool" : "tools"}.`
|
||||||
|
: `Couldn't connect.`,
|
||||||
|
singleRes: result.ok ? { ok: true, tools: result.tools } : { ok: false, tools: [], error: result.error },
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
import 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 testCustomTool from "@/src/functions/backend/db/extensions/test-custom-tool";
|
||||||
import { TestCustomToolRequestSchema } from "@/src/schemas/extension-schema";
|
import { TestCustomToolRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
@@ -12,8 +13,9 @@ import type {
|
|||||||
} from "@moduletrace/bunext/types";
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Super admin only: runs a custom tool once with sample arguments and
|
* Super admin only: runs a custom tool (saved, or as its form has it)
|
||||||
* returns what a bot would read.
|
* once with sample arguments and
|
||||||
|
* returns what a bot would read, with the page data (its new status).
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<
|
export const handler: BunextAPIRouteHandler<
|
||||||
APIResponseObject<CustomToolTestPayload>
|
APIResponseObject<CustomToolTestPayload>
|
||||||
@@ -29,19 +31,23 @@ export const handler: BunextAPIRouteHandler<
|
|||||||
throw new AppError(`Only a super admin can do that.`);
|
throw new AppError(`Only a super admin can do that.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { custom_tool_id, args, computer_id } = parseWithSchema({ schema: TestCustomToolRequestSchema, data: body });
|
const { custom_tool_id, draft, args, computer_id } = parseWithSchema({ schema: TestCustomToolRequestSchema, data: body });
|
||||||
const output = await testCustomTool({ custom_tool_id, args, computer_id });
|
const { output, ok } = await testCustomTool({ custom_tool_id, draft, args, computer_id });
|
||||||
|
|
||||||
await logEvent({
|
await logEvent({
|
||||||
title: "Tool tried",
|
title: "Tool tried",
|
||||||
category: "system",
|
category: "system",
|
||||||
user_id: user.id,
|
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 },
|
metadata: { custom_tool_id },
|
||||||
ip_address: grabRequestIP(req),
|
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) {
|
} catch (error: unknown) {
|
||||||
return { success: false, msg: safeErrorMessage({ error }) };
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
|
||||||
|
import setWorkspaceExtension from "@/src/functions/backend/db/extensions/set-workspace-extension";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SetWorkspaceExtensionRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type {
|
||||||
|
APIResponseObject,
|
||||||
|
BunextAPIRouteHandler,
|
||||||
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admins and admins: gives a workspace an MCP server or tool, which
|
||||||
|
* every bot working there gets, there.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace_id, kind, id } = parseWithSchema({ schema: SetWorkspaceExtensionRequestSchema, data: body });
|
||||||
|
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: true });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Workspace tool added",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} gave ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} to the workspace ${workspace.name} (#${workspace.id}).`,
|
||||||
|
metadata: { workspace_id, kind, id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Added ${name}.`,
|
||||||
|
singleRes: await grabWorkspaceExtensionsOverview({ user_id: user.id, workspace_id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
|
||||||
|
import setWorkspaceExtension from "@/src/functions/backend/db/extensions/set-workspace-extension";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SetWorkspaceExtensionRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import type { BotExtensionsPayload } from "@/src/types";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||||
|
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||||
|
import type {
|
||||||
|
APIResponseObject,
|
||||||
|
BunextAPIRouteHandler,
|
||||||
|
} from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admins and admins: takes an MCP server or tool from a workspace.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<APIResponseObject<BotExtensionsPayload>> = async ({ req, body }) => {
|
||||||
|
try {
|
||||||
|
if (req.method != "POST") {
|
||||||
|
throw new AppError(`Method not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
|
if (!user?.logged_in_status || !user.id) {
|
||||||
|
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||||
|
throw new AppError(`This user can only read records.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workspace_id, kind, id } = parseWithSchema({ schema: SetWorkspaceExtensionRequestSchema, data: body });
|
||||||
|
const { workspace, name } = await setWorkspaceExtension({ user_id: user.id, workspace_id, kind, id, attached: false });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Workspace tool removed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} took ${kind == "mcp" ? "the MCP server" : "the tool"} ${name} from the workspace ${workspace.name} (#${workspace.id}).`,
|
||||||
|
metadata: { workspace_id, kind, id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Removed ${name}.`,
|
||||||
|
singleRes: await grabWorkspaceExtensionsOverview({ user_id: user.id, workspace_id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,7 +15,7 @@ export default function LoginForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginShell status={status}>
|
<LoginShell status={status}>
|
||||||
<GoogleAuthSection {...init} />
|
{/* <GoogleAuthSection {...init} /> */}
|
||||||
<Form
|
<Form
|
||||||
className="w-full flex items-center justify-center relative gap-4"
|
className="w-full flex items-center justify-center relative gap-4"
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
|
|||||||
@@ -20,30 +20,42 @@ const TemplateMap = z
|
|||||||
|
|
||||||
const TimeoutMs = z.coerce.number().int().min(1000).max(AppData.CustomToolMaxTimeoutMs).optional();
|
const TimeoutMs = z.coerce.number().int().min(1000).max(AppData.CustomToolMaxTimeoutMs).optional();
|
||||||
|
|
||||||
export const SaveMcpServerRequestSchema = z
|
const McpServerFields = z.object({
|
||||||
.object({
|
mcp_server_id: Id.optional(),
|
||||||
mcp_server_id: Id.optional(),
|
name: z.string().trim().min(1, "Enter a name").max(80),
|
||||||
name: z.string().trim().min(1, "Enter a name").max(80),
|
description: z.string().trim().max(1000).default(""),
|
||||||
description: z.string().trim().max(1000).default(""),
|
transport: z.enum(McpTransports.map((t) => t.value) as [McpTransport, ...McpTransport[]]),
|
||||||
transport: z.enum(McpTransports.map((t) => t.value) as [McpTransport, ...McpTransport[]]),
|
url: z.string().trim().max(2000).default(""),
|
||||||
url: z.string().trim().max(2000).default(""),
|
headers: TemplateMap,
|
||||||
headers: TemplateMap,
|
command: z.string().trim().max(4000).default(""),
|
||||||
command: z.string().trim().max(4000).default(""),
|
setup_command: z.string().trim().max(8000).default(""),
|
||||||
setup_command: z.string().trim().max(8000).default(""),
|
env: TemplateMap,
|
||||||
env: TemplateMap,
|
excluded_tools: z.array(z.string().max(200)).max(500).default([]),
|
||||||
excluded_tools: z.array(z.string().max(200)).max(500).default([]),
|
enabled: z.boolean().default(true),
|
||||||
enabled: z.boolean().default(true),
|
all_bots: z.boolean().default(false),
|
||||||
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." });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.transport == "stdio" && !value.command) {
|
function checkMcpServerReach(
|
||||||
ctx.addIssue({ code: "custom", path: ["command"], message: "Enter the command that starts the server." });
|
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({
|
export const McpServerIdRequestSchema = z.object({
|
||||||
mcp_server_id: Id,
|
mcp_server_id: Id,
|
||||||
@@ -63,6 +75,22 @@ export const SetExtensionAccessRequestSchema = z.object({
|
|||||||
all_bots: z.boolean().optional(),
|
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.
|
* A tool's arguments: a JSON schema of an object.
|
||||||
*/
|
*/
|
||||||
@@ -121,14 +149,28 @@ export const CustomToolIdRequestSchema = z.object({
|
|||||||
custom_tool_id: Id,
|
custom_tool_id: Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TestCustomToolRequestSchema = z.object({
|
/**
|
||||||
custom_tool_id: Id,
|
* A tool as its form has it, saved or not: only what runs it.
|
||||||
args: z.record(z.string(), z.unknown()).default({}),
|
*/
|
||||||
/**
|
const DraftCustomTool = z.object({ parameters: ToolParameters }).and(ToolConfig);
|
||||||
* Where a script tool runs. The main computer when not given.
|
|
||||||
*/
|
/**
|
||||||
computer_id: Id.optional(),
|
* Tries a saved tool (`custom_tool_id`, the run counts as its last) or the
|
||||||
});
|
* one in the form (`draft`, nothing saved).
|
||||||
|
*/
|
||||||
|
export const TestCustomToolRequestSchema = z
|
||||||
|
.object({
|
||||||
|
custom_tool_id: Id.optional(),
|
||||||
|
draft: DraftCustomTool.optional(),
|
||||||
|
args: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
/**
|
||||||
|
* Where a script tool runs. The main computer when not given.
|
||||||
|
*/
|
||||||
|
computer_id: Id.optional(),
|
||||||
|
})
|
||||||
|
.refine((value) => value.custom_tool_id || value.draft, { message: "Pick a tool to try." });
|
||||||
|
|
||||||
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
|
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
|
||||||
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
|
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
|
||||||
|
export type TestMcpServerRequest = z.infer<typeof TestMcpServerRequestSchema>;
|
||||||
|
export type DraftCustomTool = z.infer<typeof DraftCustomTool>;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import attachToAllBots from "@/src/functions/backend/db/extensions/attach-to-all-bots";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off, safe to run again: an MCP server or tool set to "every bot" used
|
||||||
|
* to reach every bot without being given to it. Now "every bot" only
|
||||||
|
* decides what bots are given, so this gives each of them to every bot
|
||||||
|
* that exists, keeping what they had.
|
||||||
|
*
|
||||||
|
* Run once per install: `bun src/scripts/attach-every-bot-extensions.ts`.
|
||||||
|
*/
|
||||||
|
async function attachEveryBotExtensions() {
|
||||||
|
const servers = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM mcp_servers WHERE all_bots = 1` });
|
||||||
|
const tools = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM custom_tools WHERE all_bots = 1` });
|
||||||
|
|
||||||
|
for (const row of servers.payload || []) await attachToAllBots({ kind: "mcp", id: row.id });
|
||||||
|
for (const row of tools.payload || []) await attachToAllBots({ kind: "tool", id: row.id });
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Gave ${servers.payload?.length || 0} MCP servers and ${tools.payload?.length || 0} tools to every bot.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await attachEveryBotExtensions();
|
||||||
|
process.exit(0);
|
||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
CustomToolHttpMethod,
|
CustomToolHttpMethod,
|
||||||
CustomToolKind,
|
CustomToolKind,
|
||||||
CustomToolScriptLanguage,
|
CustomToolScriptLanguage,
|
||||||
|
CustomToolStatus,
|
||||||
} from "../dict/custom-tool-dict";
|
} from "../dict/custom-tool-dict";
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
@@ -421,6 +422,9 @@ export type CustomToolRecord = {
|
|||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
all_bots: boolean;
|
all_bots: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
status: CustomToolStatus;
|
||||||
|
last_error: string;
|
||||||
|
last_checked_at: number | null;
|
||||||
} & (
|
} & (
|
||||||
| { kind: "http"; config: CustomToolHttpConfig }
|
| { kind: "http"; config: CustomToolHttpConfig }
|
||||||
| { kind: "script"; config: CustomToolScriptConfig }
|
| { kind: "script"; config: CustomToolScriptConfig }
|
||||||
@@ -453,6 +457,9 @@ export type PublicCustomTool = {
|
|||||||
description: string;
|
description: string;
|
||||||
kind: CustomToolKind;
|
kind: CustomToolKind;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
status: CustomToolStatus;
|
||||||
|
last_error: string;
|
||||||
|
last_checked_at: number | null;
|
||||||
access: ExtensionAccess;
|
access: ExtensionAccess;
|
||||||
parameters?: Record<string, unknown>;
|
parameters?: Record<string, unknown>;
|
||||||
config?: CustomToolHttpConfig | CustomToolScriptConfig | CustomToolPromptConfig;
|
config?: CustomToolHttpConfig | CustomToolScriptConfig | CustomToolPromptConfig;
|
||||||
@@ -471,6 +478,38 @@ export type ExtensionTargets = {
|
|||||||
can_edit: boolean;
|
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 & {
|
export type McpServersOverviewPayload = ExtensionTargets & {
|
||||||
servers: PublicMcpServer[];
|
servers: PublicMcpServer[];
|
||||||
};
|
};
|
||||||
@@ -479,11 +518,26 @@ export type CustomToolsOverviewPayload = ExtensionTargets & {
|
|||||||
tools: PublicCustomTool[];
|
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.
|
* What trying a custom tool returned, as a bot would read it.
|
||||||
*/
|
*/
|
||||||
export type CustomToolTestPayload = {
|
export type CustomToolTestPayload = {
|
||||||
output: string;
|
output: string;
|
||||||
|
ok: boolean;
|
||||||
|
/**
|
||||||
|
* The page data, with the tool's new status.
|
||||||
|
*/
|
||||||
|
overview: CustomToolsOverviewPayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EnvsOverviewPayload = {
|
export type EnvsOverviewPayload = {
|
||||||
@@ -600,6 +654,8 @@ export type PagePropsType = {
|
|||||||
envs_overview?: EnvsOverviewPayload | null;
|
envs_overview?: EnvsOverviewPayload | null;
|
||||||
mcps_overview?: McpServersOverviewPayload | null;
|
mcps_overview?: McpServersOverviewPayload | null;
|
||||||
tools_overview?: CustomToolsOverviewPayload | null;
|
tools_overview?: CustomToolsOverviewPayload | null;
|
||||||
|
bot_extensions?: BotExtensionsPayload | null;
|
||||||
|
workspace_extensions?: BotExtensionsPayload | null;
|
||||||
envs?: {
|
envs?: {
|
||||||
GOOGLE_CLIENT_ID?: string;
|
GOOGLE_CLIENT_ID?: string;
|
||||||
R2_PUBLIC_DOMAIN?: string;
|
R2_PUBLIC_DOMAIN?: string;
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ beforeAll(() => {
|
|||||||
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
|
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)`);
|
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.
|
* 1: every bot (given to 5 and 6). 2: bot 5. 3: workspace 9. 4: off,
|
||||||
* 5: nobody.
|
* 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 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, 2)`);
|
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)`);
|
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -26,14 +26,15 @@ function ids(params: Parameters<typeof botExtensionsSql>[0]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("botExtensionsSql", () => {
|
describe("botExtensionsSql", () => {
|
||||||
test("a bot gets every-bot ones and its own, never disabled ones", () => {
|
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]);
|
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: 6 })).toEqual([1]);
|
||||||
|
expect(ids({ kind: "tool", bot_id: 7 })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("in a workspace, the workspace's too", () => {
|
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: 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: 9 })).toEqual([1, 2, 3, 6]);
|
||||||
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2]);
|
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2, 6]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,16 +27,17 @@ const Tables = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The enabled MCP servers or custom tools a bot gets on one run: those
|
* 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 bot, and in a workspace those attached to the
|
||||||
* attached to the workspace.
|
* 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) {
|
export default function botExtensionsSql({ kind, bot_id, workspace_id }: Params) {
|
||||||
const { table, bot_link, workspace_link, column } = Tables[kind];
|
const { table, bot_link, workspace_link, column } = Tables[kind];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sql: `SELECT x.* FROM ${table} x WHERE x.enabled = 1 AND (
|
sql: `SELECT x.* FROM ${table} x WHERE x.enabled = 1 AND (
|
||||||
x.all_bots = 1
|
x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
|
||||||
OR 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 = ?)` : ""}
|
${workspace_id ? `OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)` : ""}
|
||||||
) ORDER BY x.id ASC`,
|
) ORDER BY x.id ASC`,
|
||||||
values: workspace_id ? [Number(bot_id), Number(workspace_id)] : [Number(bot_id)],
|
values: workspace_id ? [Number(bot_id), Number(workspace_id)] : [Number(bot_id)],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user