Add MCP servers and custom tools for bots
Two new settings pages, MCPs and Tools. Super admins define them; super
admins and admins attach them to bots, to workspaces (every bot there),
or to every bot. Bots get them in chat replies and tasks.
- Custom tools: a JSON-schema of arguments plus an HTTP request, a script
on the bot's computer, or a prompt, templated with {{args.x}} and
{{env.KEY}}, secrets redacted from results.
- MCP servers: remote (URL and headers) or stdio commands run on the host
with a setup command and an isolated env, in a per-process pool. Tools
are listed into a cache and connect on first call.
- Add, edit, access and try open in modals.
- Markdown, JSON and command fields use twui's AceEditor (CodeEditor),
its scripts loaded on demand.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -132,6 +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.
|
||||||
|
- **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts).
|
||||||
|
- **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/<id>` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `<slug>__<tool>` (`mcpToolName`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete.
|
||||||
- **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.
|
||||||
|
|
||||||
@@ -146,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), those three 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`)), 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`).
|
||||||
|
|
||||||
@@ -187,7 +190,7 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
|
|||||||
| Directory | Description |
|
| Directory | Description |
|
||||||
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `@moduletrace/twui` | **The** design system - external package. Contains layout primitives, elements, editors (TinyMCE), form components, hooks, SVGs, and utilities. Import from `@moduletrace/twui` or a deep path such as `@moduletrace/twui/layout/Button` |
|
| `@moduletrace/twui` | **The** design system - external package. Contains layout primitives, elements, editors (TinyMCE), form components, hooks, SVGs, and utilities. Import from `@moduletrace/twui` or a deep path such as `@moduletrace/twui/layout/Button` |
|
||||||
| `src/components/general/` | Shared page components built on twui: `logo`, `logo-icon`, `wordmark`, `bot-stone` (+ `bot-stone-face`, `stone-eye-*`), `user-avatar` (round photo or initials, `sm|md|lg`), `computer-view` (+ `computer-view-frame`, `computer-view-mode-button`), `inner-page-hero-section`, `main-hero-section`, `admin-hero`, `login-shell`, `tagline-section`, media/image helpers |
|
| `src/components/general/` | Shared page components built on twui: `logo`, `logo-icon`, `wordmark`, `bot-stone` (+ `bot-stone-face`, `stone-eye-*`), `user-avatar` (round photo or initials, `sm|md|lg`), `code-editor` (twui `AceEditor` for commands and code, loading the ACE scripts on demand via `loadAceScripts`), `icon-tile`, `computer-view` (+ `computer-view-frame`, `computer-view-mode-button`), `inner-page-hero-section`, `main-hero-section`, `admin-hero`, `login-shell`, `tagline-section`, media/image helpers |
|
||||||
|
|
||||||
## UI & Styling Rules (MANDATORY)
|
## UI & Styling Rules (MANDATORY)
|
||||||
|
|
||||||
|
|||||||
+316
@@ -20,6 +20,8 @@ import { BotJobKinds, BotJobUpdateKinds } from "@/src/dict/bot-queue-dict";
|
|||||||
import { AppData } from "@/src/data/app-data";
|
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 { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||||
|
|
||||||
const schema: BUN_SQLITE_DatabaseSchemaType = {
|
const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||||
tables: [
|
tables: [
|
||||||
@@ -1682,6 +1684,320 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tableName: "mcp_servers",
|
||||||
|
tableDescription: `MCP servers bots can use (Settings, under MCPs). Per bot (bot_mcp_servers), per workspace (workspace_mcp_servers) or every bot (all_bots). Private: headers and env may hold literal secrets`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "name",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "slug",
|
||||||
|
fieldDescription: `Prefixes its tools' names (<slug>__<tool>). Unique`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "description",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "transport",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: McpTransports.map((t) => t.value),
|
||||||
|
defaultValue: "remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "url",
|
||||||
|
fieldDescription: `remote: the server's MCP endpoint`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "headers",
|
||||||
|
fieldDescription: `remote: request headers, {"Authorization":"Bearer {{env.KEY}}"}. Values are templates`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "command",
|
||||||
|
fieldDescription: `stdio: the command that starts the server, run with the host's shell in DATA_DIR/mcp/<id>`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "setup_command",
|
||||||
|
fieldDescription: `stdio: installs what the command needs. Runs before the first start and again whenever it changes`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "env",
|
||||||
|
fieldDescription: `stdio: the only environment the server gets (besides PATH and HOME), {"GITHUB_TOKEN":"{{env.GITHUB_TOKEN}}"}. Values are templates`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "excluded_tools",
|
||||||
|
fieldDescription: `Tool names (the server's own) bots don't get`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "tools_cache",
|
||||||
|
fieldDescription: `The server's tools as last listed ([{name, description, input_schema}]). Runs build their tools from it and connect on the first call`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "all_bots",
|
||||||
|
fieldDescription: `1 gives it to every bot, the coordinator included`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
options: [0, 1],
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "enabled",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
options: [0, 1],
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "status",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: McpServerStatuses.map((s) => s.value),
|
||||||
|
defaultValue: "untested",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "last_error",
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "last_synced_at",
|
||||||
|
fieldDescription: `Unix ms of the last tool listing`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "user_id",
|
||||||
|
fieldDescription: `Who created it. Not a foreign key: it belongs to the install`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "custom_tools",
|
||||||
|
tableDescription: `Tools bots can call (Settings, under Tools): an HTTP request, a script on a computer, or a prompt. Per bot (bot_custom_tools), per workspace (workspace_custom_tools) or every bot (all_bots)`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "name",
|
||||||
|
fieldDescription: `What the model calls it: snake_case, unique, never a built-in tool's name`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "description",
|
||||||
|
fieldDescription: `What the model reads to decide when to call it`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "kind",
|
||||||
|
dataType: "TEXT",
|
||||||
|
options: CustomToolKinds.map((k) => k.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "parameters",
|
||||||
|
fieldDescription: `JSON schema (an object) of its arguments`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "config",
|
||||||
|
fieldDescription: `Per kind. http: {method, url, headers, body, timeout_ms}; script: {language, code, timeout_ms}; prompt: {prompt}. Text fields are templates ({{args.x}}, {{env.KEY}})`,
|
||||||
|
dataType: "TEXT",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "all_bots",
|
||||||
|
fieldDescription: `1 gives it to every bot, the coordinator included`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
options: [0, 1],
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "enabled",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
options: [0, 1],
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "user_id",
|
||||||
|
fieldDescription: `Who created it. Not a foreign key: it belongs to the install`,
|
||||||
|
dataType: "INTEGER",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_mcp_servers",
|
||||||
|
tableDescription: `A bot that gets an MCP server's tools everywhere it works`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_mcp_servers_bots_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "mcp_server_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "mcp_servers",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_mcp_servers_mcp_servers_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueConstraints: [
|
||||||
|
{
|
||||||
|
alias: "one_mcp_server_per_bot",
|
||||||
|
constraintName: "A bot gets an MCP server once",
|
||||||
|
constraintTableFields: [{ value: "bot_id" }, { value: "mcp_server_id" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_mcp_servers_mcp_server_id",
|
||||||
|
indexTableFields: ["mcp_server_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "workspace_mcp_servers",
|
||||||
|
tableDescription: `Every bot in the workspace gets the MCP server's tools there`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "workspace_mcp_servers_workspaces_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "mcp_server_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "mcp_servers",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "workspace_mcp_servers_mcp_servers_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueConstraints: [
|
||||||
|
{
|
||||||
|
alias: "one_mcp_server_per_workspace",
|
||||||
|
constraintName: "A workspace gets an MCP server once",
|
||||||
|
constraintTableFields: [{ value: "workspace_id" }, { value: "mcp_server_id" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_workspace_mcp_servers_mcp_server_id",
|
||||||
|
indexTableFields: ["mcp_server_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "bot_custom_tools",
|
||||||
|
tableDescription: `A bot that gets a custom tool everywhere it works`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "bot_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "bots",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_custom_tools_bots_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "custom_tool_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "custom_tools",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "bot_custom_tools_custom_tools_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueConstraints: [
|
||||||
|
{
|
||||||
|
alias: "one_custom_tool_per_bot",
|
||||||
|
constraintName: "A bot gets a custom tool once",
|
||||||
|
constraintTableFields: [{ value: "bot_id" }, { value: "custom_tool_id" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_bot_custom_tools_custom_tool_id",
|
||||||
|
indexTableFields: ["custom_tool_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tableName: "workspace_custom_tools",
|
||||||
|
tableDescription: `Every bot in the workspace gets the custom tool there`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
fieldName: "workspace_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "workspaces",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "workspace_custom_tools_workspaces_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: "custom_tool_id",
|
||||||
|
dataType: "INTEGER",
|
||||||
|
foreignKey: {
|
||||||
|
cascadeDelete: true,
|
||||||
|
destinationTableName: "custom_tools",
|
||||||
|
destinationTableColumnName: "id",
|
||||||
|
destinationTableColumnType: "INTEGER",
|
||||||
|
foreignKeyName: "workspace_custom_tools_custom_tools_fk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueConstraints: [
|
||||||
|
{
|
||||||
|
alias: "one_custom_tool_per_workspace",
|
||||||
|
constraintName: "A workspace gets a custom tool once",
|
||||||
|
constraintTableFields: [{ value: "workspace_id" }, { value: "custom_tool_id" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
indexName: "idx_workspace_custom_tools_custom_tool_id",
|
||||||
|
indexTableFields: ["custom_tool_id"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tableName: "logs",
|
tableName: "logs",
|
||||||
fields: [
|
fields: [
|
||||||
|
|||||||
+181
-1
@@ -29,6 +29,12 @@ export const BunSQLiteTables = [
|
|||||||
"harness_settings",
|
"harness_settings",
|
||||||
"bot_harness_settings",
|
"bot_harness_settings",
|
||||||
"workspace_harness_settings",
|
"workspace_harness_settings",
|
||||||
|
"mcp_servers",
|
||||||
|
"custom_tools",
|
||||||
|
"bot_mcp_servers",
|
||||||
|
"workspace_mcp_servers",
|
||||||
|
"bot_custom_tools",
|
||||||
|
"workspace_custom_tools",
|
||||||
"logs",
|
"logs",
|
||||||
"envs",
|
"envs",
|
||||||
] as const
|
] as const
|
||||||
@@ -1026,6 +1032,180 @@ export type BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS = {
|
|||||||
options?: string;
|
options?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_MCP_SERVERS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* Prefixes its tools' names (<slug>__<tool>). Unique
|
||||||
|
*/
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
transport?: "remote" | "stdio" | "";
|
||||||
|
/**
|
||||||
|
* remote: the server's MCP endpoint
|
||||||
|
*/
|
||||||
|
url?: string;
|
||||||
|
/**
|
||||||
|
* remote: request headers, {"Authorization":"Bearer {{env.KEY}}"}. Values are templates
|
||||||
|
*/
|
||||||
|
headers?: string;
|
||||||
|
/**
|
||||||
|
* stdio: the command that starts the server, run with the host's shell in DATA_DIR/mcp/<id>
|
||||||
|
*/
|
||||||
|
command?: string;
|
||||||
|
/**
|
||||||
|
* stdio: installs what the command needs. Runs before the first start and again whenever it changes
|
||||||
|
*/
|
||||||
|
setup_command?: string;
|
||||||
|
/**
|
||||||
|
* stdio: the only environment the server gets (besides PATH and HOME), {"GITHUB_TOKEN":"{{env.GITHUB_TOKEN}}"}. Values are templates
|
||||||
|
*/
|
||||||
|
env?: string;
|
||||||
|
/**
|
||||||
|
* Tool names (the server's own) bots don't get
|
||||||
|
*/
|
||||||
|
excluded_tools?: string;
|
||||||
|
/**
|
||||||
|
* The server's tools as last listed ([{name, description, input_schema}]). Runs build their tools from it and connect on the first call
|
||||||
|
*/
|
||||||
|
tools_cache?: string;
|
||||||
|
/**
|
||||||
|
* 1 gives it to every bot, the coordinator included
|
||||||
|
*/
|
||||||
|
all_bots?: 0 | 1 | "";
|
||||||
|
enabled?: 0 | 1 | "";
|
||||||
|
status?: "untested" | "ok" | "error" | "";
|
||||||
|
last_error?: string;
|
||||||
|
/**
|
||||||
|
* Unix ms of the last tool listing
|
||||||
|
*/
|
||||||
|
last_synced_at?: number | "";
|
||||||
|
/**
|
||||||
|
* Who created it. Not a foreign key: it belongs to the install
|
||||||
|
*/
|
||||||
|
user_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_CUSTOM_TOOLS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
/**
|
||||||
|
* What the model calls it: snake_case, unique, never a built-in tool's name
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* What the model reads to decide when to call it
|
||||||
|
*/
|
||||||
|
description?: string;
|
||||||
|
kind?: "http" | "script" | "prompt" | "";
|
||||||
|
/**
|
||||||
|
* JSON schema (an object) of its arguments
|
||||||
|
*/
|
||||||
|
parameters?: string;
|
||||||
|
/**
|
||||||
|
* Per kind. http: {method, url, headers, body, timeout_ms}; script: {language, code, timeout_ms}; prompt: {prompt}. Text fields are templates ({{args.x}}, {{env.KEY}})
|
||||||
|
*/
|
||||||
|
config?: string;
|
||||||
|
/**
|
||||||
|
* 1 gives it to every bot, the coordinator included
|
||||||
|
*/
|
||||||
|
all_bots?: 0 | 1 | "";
|
||||||
|
enabled?: 0 | 1 | "";
|
||||||
|
/**
|
||||||
|
* Who created it. Not a foreign key: it belongs to the install
|
||||||
|
*/
|
||||||
|
user_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_BOT_MCP_SERVERS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
bot_id?: number | "";
|
||||||
|
mcp_server_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
workspace_id?: number | "";
|
||||||
|
mcp_server_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
bot_id?: number | "";
|
||||||
|
custom_tool_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS = {
|
||||||
|
/**
|
||||||
|
* The unique identifier of the record.
|
||||||
|
*/
|
||||||
|
id?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was created. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
created_at?: number | "";
|
||||||
|
/**
|
||||||
|
* The time when the record was updated. (Unix Timestamp)
|
||||||
|
*/
|
||||||
|
updated_at?: number | "";
|
||||||
|
workspace_id?: number | "";
|
||||||
|
custom_tool_id?: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_LOGS = {
|
export type BUN_SQLITE_COBBLE_LOGS = {
|
||||||
/**
|
/**
|
||||||
* The unique identifier of the record.
|
* The unique identifier of the record.
|
||||||
@@ -1120,4 +1300,4 @@ export type BUN_SQLITE_COBBLE_ENVS = {
|
|||||||
expose_to_computers?: 0 | 1 | "";
|
expose_to_computers?: 0 | 1 | "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
|
export type BUN_SQLITE_COBBLE_ALL_TYPEDEFS = BUN_SQLITE_COBBLE_USERS & BUN_SQLITE_COBBLE_USER_TYPES & BUN_SQLITE_COBBLE_SSO_LOGIN_CODES & BUN_SQLITE_COBBLE_USER_PASSWORDS & BUN_SQLITE_COBBLE_MEDIA & BUN_SQLITE_COBBLE_MEDIA_PARADIGMS & BUN_SQLITE_COBBLE_BOTS & BUN_SQLITE_COBBLE_BOT_SESSIONS & BUN_SQLITE_COBBLE_BOT_MESSAGES & BUN_SQLITE_COBBLE_BOT_TASKS & BUN_SQLITE_COBBLE_BOT_TASK_STEPS & BUN_SQLITE_COBBLE_BOTS_QUEUE & BUN_SQLITE_COBBLE_BOTS_QUEUE_UPDATES & BUN_SQLITE_COBBLE_CRON_JOBS & BUN_SQLITE_COBBLE_WORKSPACES & BUN_SQLITE_COBBLE_WORKSPACE_BOTS & BUN_SQLITE_COBBLE_WORKSPACE_USERS & BUN_SQLITE_COBBLE_BOT_USERS & BUN_SQLITE_COBBLE_BOT_USER_MEMORIES & BUN_SQLITE_COBBLE_BOT_USER_FACES & BUN_SQLITE_COBBLE_BOT_MESSAGE_MEDIA & BUN_SQLITE_COBBLE_CONTAINERS & BUN_SQLITE_COBBLE_COMPUTERS & BUN_SQLITE_COBBLE_BOT_COMPUTERS & BUN_SQLITE_COBBLE_WORKSPACE_COMPUTERS & BUN_SQLITE_COBBLE_HARNESS_PROVIDERS & BUN_SQLITE_COBBLE_HARNESS_MODELS & BUN_SQLITE_COBBLE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_BOT_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_WORKSPACE_HARNESS_SETTINGS & BUN_SQLITE_COBBLE_MCP_SERVERS & BUN_SQLITE_COBBLE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_BOT_MCP_SERVERS & BUN_SQLITE_COBBLE_WORKSPACE_MCP_SERVERS & BUN_SQLITE_COBBLE_BOT_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_WORKSPACE_CUSTOM_TOOLS & BUN_SQLITE_COBBLE_LOGS & BUN_SQLITE_COBBLE_ENVS
|
||||||
@@ -11,6 +11,7 @@ import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
|||||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
import runTextLoop from "@/src/harness/text/run-text-loop";
|
import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||||
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
|
||||||
|
import openBotExtensions from "@/src/harness/tools/extensions/open-bot-extensions";
|
||||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||||
import composeComputersNote from "./compose-computers-note";
|
import composeComputersNote from "./compose-computers-note";
|
||||||
import grabSessionCarryOver from "./grab-session-carry-over";
|
import grabSessionCarryOver from "./grab-session-carry-over";
|
||||||
@@ -33,7 +34,9 @@ type Params = BotJobContext & {
|
|||||||
* knowing where it is (its workspace, if the session is in one), what
|
* knowing where it is (its workspace, if the session is in one), what
|
||||||
* happened in the session before (`grabSessionCarryOver`) and its
|
* happened in the session before (`grabSessionCarryOver`) and its
|
||||||
* computers (`composeComputersNote`), with its chat tools (memory,
|
* computers (`composeComputersNote`), with its chat tools (memory,
|
||||||
* instructions, start_task, and for the coordinator delegate_task).
|
* instructions, start_task, and for the coordinator delegate_task) plus
|
||||||
|
* its MCP servers' and custom tools (`openBotExtensions`, script tools on
|
||||||
|
* its current computer).
|
||||||
* Finishes silently when the last message is already the bot's, i.e. an
|
* Finishes silently when the last message is already the bot's, i.e. an
|
||||||
* earlier reply answered everything.
|
* earlier reply answered everything.
|
||||||
*/
|
*/
|
||||||
@@ -82,6 +85,14 @@ export default async function runBotReply({
|
|||||||
onTakeOver: ({ ref }) => take_overs.push(ref),
|
onTakeOver: ({ ref }) => take_overs.push(ref),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extensions = await openBotExtensions({
|
||||||
|
bot_id: bot.id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id: computers[0]?.id || null,
|
||||||
|
taken: tools.map((tool) => tool.name),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* delegate_task is only there while the coordinator has workers.
|
* delegate_task is only there while the coordinator has workers.
|
||||||
*/
|
*/
|
||||||
@@ -92,33 +103,38 @@ export default async function runBotReply({
|
|||||||
? CoordinatorReplyNote
|
? CoordinatorReplyNote
|
||||||
: CoordinatorSoloNote;
|
: CoordinatorSoloNote;
|
||||||
|
|
||||||
const result = await runTextLoop({
|
try {
|
||||||
system: [
|
const result = await runTextLoop({
|
||||||
prompt,
|
system: [
|
||||||
carry_over,
|
prompt,
|
||||||
ChatReplyNote,
|
carry_over,
|
||||||
workspace_id ? WorkspaceLearningNote : "",
|
ChatReplyNote,
|
||||||
composeComputersNote({
|
workspace_id ? WorkspaceLearningNote : "",
|
||||||
computers,
|
composeComputersNote({
|
||||||
is_coordinator: bot.kind == "coordinator",
|
computers,
|
||||||
in_workspace: Boolean(workspace_id),
|
is_coordinator: bot.kind == "coordinator",
|
||||||
|
in_workspace: Boolean(workspace_id),
|
||||||
|
}),
|
||||||
|
crew_note,
|
||||||
|
extensions.note,
|
||||||
|
`Now: ${new Date().toUTCString()}.`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
messages: botMessagesToHarness({
|
||||||
|
messages: history,
|
||||||
|
parts: await loadAttachmentParts({ messages: history }),
|
||||||
}),
|
}),
|
||||||
crew_note,
|
tools: [...tools, ...extensions.tools],
|
||||||
`Now: ${new Date().toUTCString()}.`,
|
max_steps: AppData.BotReplyMaxToolSteps,
|
||||||
]
|
bot_id: bot.id,
|
||||||
.filter(Boolean)
|
workspace_id,
|
||||||
.join("\n\n"),
|
signal,
|
||||||
messages: botMessagesToHarness({
|
onStep: report,
|
||||||
messages: history,
|
});
|
||||||
parts: await loadAttachmentParts({ messages: history }),
|
|
||||||
}),
|
|
||||||
tools,
|
|
||||||
max_steps: AppData.BotReplyMaxToolSteps,
|
|
||||||
bot_id: bot.id,
|
|
||||||
workspace_id,
|
|
||||||
signal,
|
|
||||||
onStep: report,
|
|
||||||
});
|
|
||||||
|
|
||||||
return withTakeOverRefs({ text: result.text, refs: take_overs });
|
return withTakeOverRefs({ text: result.text, refs: take_overs });
|
||||||
|
} finally {
|
||||||
|
await extensions.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
|
|||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
import runTextLoop from "@/src/harness/text/run-text-loop";
|
import runTextLoop from "@/src/harness/text/run-text-loop";
|
||||||
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
|
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
|
||||||
|
import openBotExtensions, { type OpenBotExtensions } from "@/src/harness/tools/extensions/open-bot-extensions";
|
||||||
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
|
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
|
||||||
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
|
import createWorkspaceTool from "@/src/harness/tools/bot/create-workspace-tool";
|
||||||
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
|
import updateWorkspaceTool from "@/src/harness/tools/bot/update-workspace-tool";
|
||||||
@@ -28,8 +29,9 @@ type Params = BotJobContext & {
|
|||||||
/**
|
/**
|
||||||
* Works through a task in its session's workspace (if any): the bot
|
* Works through a task in its session's workspace (if any): the bot
|
||||||
* follows its brief with its full prompt,
|
* follows its brief with its full prompt,
|
||||||
* its own tools, search_computers across all its computers and, when the
|
* its own tools, search_computers across all its computers, its MCP
|
||||||
* job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
|
* servers' and custom tools (`openBotExtensions`) and, when the job has a
|
||||||
|
* computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
|
||||||
* When only the user can go on (a login, a captcha), the bot asks them to
|
* When only the user can go on (a login, a captcha), the bot asks them to
|
||||||
* take over (`ask_user_to_take_over`) and the report needs them.
|
* take over (`ask_user_to_take_over`) and the report needs them.
|
||||||
* Every model turn is reported as a step (`report`).
|
* Every model turn is reported as a step (`report`).
|
||||||
@@ -53,6 +55,8 @@ export default async function runBotTask({
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
let extensions: OpenBotExtensions | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session_id = await jobSessionId({ job });
|
const session_id = await jobSessionId({ job });
|
||||||
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
|
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id, workspace_id });
|
||||||
@@ -82,6 +86,43 @@ export default async function runBotTask({
|
|||||||
...computers.filter((c) => c.id != job.computer_id),
|
...computers.filter((c) => c.id != job.computer_id),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers }),
|
||||||
|
...(bot.kind == "coordinator"
|
||||||
|
? [
|
||||||
|
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
|
||||||
|
...(coordinator_workspaces.length
|
||||||
|
? [
|
||||||
|
updateWorkspaceTool({
|
||||||
|
user_id,
|
||||||
|
workspaces: coordinator_workspaces,
|
||||||
|
workspace_id,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(computers.length
|
||||||
|
? [
|
||||||
|
searchComputersTool({ computers, workspace_id }),
|
||||||
|
askToTakeOverTool({
|
||||||
|
computers: take_over_computers,
|
||||||
|
in_task: true,
|
||||||
|
onAsk: ({ ref }) => take_overs.push(ref),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(computer?.tools || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
extensions = await openBotExtensions({
|
||||||
|
bot_id: bot.id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id: job.computer_id || null,
|
||||||
|
taken: tools.map((tool) => tool.name),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await runTextLoop({
|
const result = await runTextLoop({
|
||||||
system: [
|
system: [
|
||||||
prompt,
|
prompt,
|
||||||
@@ -104,6 +145,7 @@ export default async function runBotTask({
|
|||||||
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
|
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
|
||||||
.join("\n")}`
|
.join("\n")}`
|
||||||
: "",
|
: "",
|
||||||
|
extensions.note,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n"),
|
.join("\n\n"),
|
||||||
@@ -117,34 +159,7 @@ export default async function runBotTask({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tools: [
|
tools: [...tools, ...extensions.tools],
|
||||||
...grabBotTools({ user_id, bot_id: bot.id, workspace_id, computers }),
|
|
||||||
...(bot.kind == "coordinator"
|
|
||||||
? [
|
|
||||||
createWorkspaceTool({ user_id, workspaces: coordinator_workspaces }),
|
|
||||||
...(coordinator_workspaces.length
|
|
||||||
? [
|
|
||||||
updateWorkspaceTool({
|
|
||||||
user_id,
|
|
||||||
workspaces: coordinator_workspaces,
|
|
||||||
workspace_id,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(computers.length
|
|
||||||
? [
|
|
||||||
searchComputersTool({ computers, workspace_id }),
|
|
||||||
askToTakeOverTool({
|
|
||||||
computers: take_over_computers,
|
|
||||||
in_task: true,
|
|
||||||
onAsk: ({ ref }) => take_overs.push(ref),
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(computer?.tools || []),
|
|
||||||
],
|
|
||||||
bot_id: bot.id,
|
bot_id: bot.id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
signal,
|
signal,
|
||||||
@@ -157,6 +172,7 @@ export default async function runBotTask({
|
|||||||
refs: take_overs,
|
refs: take_overs,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
await extensions?.close();
|
||||||
await computer?.close();
|
await computer?.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import AceEditor from "@moduletrace/twui/editors/AceEditor";
|
||||||
|
import type AceEditorModes from "@moduletrace/twui/editors/ace-editor-modes";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import loadAceScripts from "@/src/utils/load-ace-scripts";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* The starting text. Changes come back through `onChange`.
|
||||||
|
*/
|
||||||
|
content?: string;
|
||||||
|
mode: (typeof AceEditorModes)[number];
|
||||||
|
placeholder?: string;
|
||||||
|
/**
|
||||||
|
* One line under it, like a field's info.
|
||||||
|
*/
|
||||||
|
info?: string;
|
||||||
|
/**
|
||||||
|
* Its height, e.g. `h-24`.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A code field: twui's `AceEditor` with its label, and a line of info
|
||||||
|
* under it. Loads the editor's scripts when it first shows
|
||||||
|
* (`loadAceScripts`).
|
||||||
|
*/
|
||||||
|
export default function CodeEditor({ title, content, mode, placeholder, info, className, onChange }: Props) {
|
||||||
|
/**
|
||||||
|
* The editor keeps the handler it was created with; this keeps it
|
||||||
|
* calling the latest one.
|
||||||
|
*/
|
||||||
|
const change = useRef(onChange);
|
||||||
|
change.current = onChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAceScripts().catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full gap-1.5! items-stretch">
|
||||||
|
<AceEditor
|
||||||
|
title={title}
|
||||||
|
showLabel
|
||||||
|
content={content}
|
||||||
|
mode={mode}
|
||||||
|
placeholder={placeholder}
|
||||||
|
fontSize="13px"
|
||||||
|
delay={0}
|
||||||
|
wrapperProps={{ className: twMerge("h-40", className) }}
|
||||||
|
onChange={(value) => change.current(value)}
|
||||||
|
/>
|
||||||
|
{info ? <Span className="text-[13px] leading-[18px] text-ink-secondary">{info}</Span> : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import Span from "@moduletrace/twui/layout/Span";
|
|
||||||
import { Monitor } from "lucide-react";
|
import { Monitor } from "lucide-react";
|
||||||
|
import IconTile from "./icon-tile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A computer's avatar in lists: the monitor icon on a small tile.
|
* A computer's avatar in lists: the monitor icon on a small tile.
|
||||||
*/
|
*/
|
||||||
export default function ComputerIconTile() {
|
export default function ComputerIconTile() {
|
||||||
return (
|
return <IconTile Icon={Monitor} />;
|
||||||
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
|
|
||||||
<Monitor size={16} aria-hidden />
|
|
||||||
</Span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
Icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An avatar in lists for what has no picture: its icon on a small tile.
|
||||||
|
*/
|
||||||
|
export default function IconTile({ Icon }: Props) {
|
||||||
|
return (
|
||||||
|
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
|
||||||
|
<Icon size={16} aria-hidden />
|
||||||
|
</Span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import cronHeartbeat from "./heartbeat";
|
|||||||
import pruneBotsQueue from "./prune-bots-queue";
|
import pruneBotsQueue from "./prune-bots-queue";
|
||||||
import cronSyncHarnessModels from "./sync-harness-models";
|
import cronSyncHarnessModels from "./sync-harness-models";
|
||||||
import syncComputers from "./sync-computers";
|
import syncComputers from "./sync-computers";
|
||||||
|
import syncMcpServers from "./sync-mcp-servers";
|
||||||
import runCronJobs from "./run-cron-jobs";
|
import runCronJobs from "./run-cron-jobs";
|
||||||
import registerCronJob from "./register-cron-job";
|
import registerCronJob from "./register-cron-job";
|
||||||
|
|
||||||
@@ -40,6 +41,12 @@ export default function cron() {
|
|||||||
run_on_start: true,
|
run_on_start: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerCronJob({
|
||||||
|
title: "sync-mcp-servers",
|
||||||
|
schedule: "@hourly",
|
||||||
|
task: syncMcpServers,
|
||||||
|
});
|
||||||
|
|
||||||
registerCronJob({
|
registerCronJob({
|
||||||
title: "run-cron-jobs",
|
title: "run-cron-jobs",
|
||||||
schedule: "* * * * *",
|
schedule: "* * * * *",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists every enabled remote MCP server's tools again (`syncMcpServer`),
|
||||||
|
* so bots get tools the server added and lose ones it dropped. stdio
|
||||||
|
* servers are left alone: listing them means starting them, and they're
|
||||||
|
* refreshed when saved or from Settings, under MCPs.
|
||||||
|
*/
|
||||||
|
export default async function syncMcpServers() {
|
||||||
|
const rows =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `SELECT id FROM mcp_servers WHERE enabled = 1 AND transport = 'remote'`,
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
await syncMcpServer({ mcp_server_id: row.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,6 +181,22 @@ export const AppData = {
|
|||||||
* One MCP tool call gives up after this long without progress.
|
* One MCP tool call gives up after this long without progress.
|
||||||
*/
|
*/
|
||||||
McpToolTimeoutMs: 5 * 60 * 1000,
|
McpToolTimeoutMs: 5 * 60 * 1000,
|
||||||
|
/**
|
||||||
|
* MCP servers set up in Settings, under MCPs: a stdio server runs in
|
||||||
|
* `DATA_DIR/<McpDataDir>/<id>` (its setup installs there, so it
|
||||||
|
* survives updates), its setup command may take `McpSetupTimeoutMs`,
|
||||||
|
* and it's stopped after `McpStdioIdleMs` without a call.
|
||||||
|
*/
|
||||||
|
McpDataDir: "mcp",
|
||||||
|
McpSetupTimeoutMs: 10 * 60 * 1000,
|
||||||
|
McpStdioIdleMs: 10 * 60 * 1000,
|
||||||
|
/**
|
||||||
|
* A custom tool (Settings, under Tools) gives up after its own timeout,
|
||||||
|
* `CustomToolDefaultTimeoutMs` when it has none, never past
|
||||||
|
* `CustomToolMaxTimeoutMs`.
|
||||||
|
*/
|
||||||
|
CustomToolDefaultTimeoutMs: 60 * 1000,
|
||||||
|
CustomToolMaxTimeoutMs: 10 * 60 * 1000,
|
||||||
/**
|
/**
|
||||||
* `search_computers`: how long the command may run on each computer,
|
* `search_computers`: how long the command may run on each computer,
|
||||||
* and how much of each computer's output the bot reads.
|
* and how much of each computer's output the bot reads.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* What a custom tool does when a bot calls it. `http` calls an API from the
|
||||||
|
* app, `script` runs code on the run's computer, `prompt` runs one model
|
||||||
|
* turn with a filled-in prompt.
|
||||||
|
*/
|
||||||
|
export const CustomToolKinds = [
|
||||||
|
{
|
||||||
|
title: "HTTP request",
|
||||||
|
value: "http",
|
||||||
|
description: "Calls an API from the app and returns the response.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Script on a computer",
|
||||||
|
value: "script",
|
||||||
|
description: "Runs code on the bot's computer and returns what it prints.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Prompt",
|
||||||
|
value: "prompt",
|
||||||
|
description: "Runs one model turn with a filled-in prompt and returns the answer.",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CustomToolKind = (typeof CustomToolKinds)[number]["value"];
|
||||||
|
|
||||||
|
export const CustomToolHttpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||||
|
|
||||||
|
export type CustomToolHttpMethod = (typeof CustomToolHttpMethods)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Languages a script tool runs in, with the interpreter on the computer
|
||||||
|
* and the code editor's mode.
|
||||||
|
*/
|
||||||
|
export const CustomToolScriptLanguages = [
|
||||||
|
{ title: "Bash", value: "bash", interpreter: "bash", extension: "sh", editor_mode: "sh" },
|
||||||
|
{ title: "Python", value: "python", interpreter: "python3", extension: "py", editor_mode: "python" },
|
||||||
|
{ title: "JavaScript", value: "javascript", interpreter: "node", extension: "js", editor_mode: "javascript" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CustomToolScriptLanguage = (typeof CustomToolScriptLanguages)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names the built-in bot tools use. A custom tool can't take one, and
|
||||||
|
* names starting with a computer's tool prefixes are kept for them too.
|
||||||
|
*/
|
||||||
|
export const ReservedToolNames = [
|
||||||
|
"add_bot_to_workspace",
|
||||||
|
"ask_user_to_take_over",
|
||||||
|
"assign_computer",
|
||||||
|
"create_bot",
|
||||||
|
"create_schedule",
|
||||||
|
"create_workspace",
|
||||||
|
"delegate_task",
|
||||||
|
"delete_schedule",
|
||||||
|
"list_schedules",
|
||||||
|
"search_computers",
|
||||||
|
"set_bot_workspace_instructions",
|
||||||
|
"set_workspace_computer",
|
||||||
|
"start_task",
|
||||||
|
"update_instructions",
|
||||||
|
"update_memory",
|
||||||
|
"update_schedule",
|
||||||
|
"update_workspace",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ReservedToolPrefixes = ["sandbox_", "browser_"] as const;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* How the app reaches an MCP server. `remote`: over HTTP (Streamable HTTP,
|
||||||
|
* SSE fallback). `stdio`: a command the app starts on its own machine (the
|
||||||
|
* host in dev, the app's container in production) and talks to over its
|
||||||
|
* stdin and stdout.
|
||||||
|
*/
|
||||||
|
export const McpTransports = [
|
||||||
|
{
|
||||||
|
title: "Remote (URL)",
|
||||||
|
value: "remote",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Command on this server (stdio)",
|
||||||
|
value: "stdio",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type McpTransport = (typeof McpTransports)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last time the app listed a server's tools: `ok`, `error` (see its
|
||||||
|
* `last_error`), or `untested` before the first time.
|
||||||
|
*/
|
||||||
|
export const McpServerStatuses = [
|
||||||
|
{
|
||||||
|
title: "Untested",
|
||||||
|
value: "untested",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Connected",
|
||||||
|
value: "ok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Error",
|
||||||
|
value: "error",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type McpServerStatus = (typeof McpServerStatuses)[number]["value"];
|
||||||
@@ -2,7 +2,7 @@ import type { TableType } from "@/src/types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tables holding secrets (password hashes, pending login codes, model
|
* Tables holding secrets (password hashes, pending login codes, model
|
||||||
* provider keys, envs). They are never reachable through the admin CRUD API, not
|
* provider keys, envs, MCP servers' headers and env). They are never reachable through the admin CRUD API, not
|
||||||
* even by super admins.
|
* even by super admins.
|
||||||
*/
|
*/
|
||||||
export const PrivateTables: TableType[] = [
|
export const PrivateTables: TableType[] = [
|
||||||
@@ -10,4 +10,5 @@ export const PrivateTables: TableType[] = [
|
|||||||
"sso_login_codes",
|
"sso_login_codes",
|
||||||
"harness_providers",
|
"harness_providers",
|
||||||
"envs",
|
"envs",
|
||||||
|
"mcp_servers",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Layers,
|
Layers,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
Plug,
|
||||||
Users,
|
Users,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
UserRound,
|
UserRound,
|
||||||
Variable,
|
Variable,
|
||||||
|
Wrench,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||||
@@ -49,6 +51,8 @@ export const SettingsPages: SettingsPage[] = [
|
|||||||
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot, can_view: canManageCrew },
|
{ title: "Bots", url: "/admin/settings/bots", Icon: Bot, can_view: canManageCrew },
|
||||||
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers, can_view: canManageCrew },
|
{ title: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers, can_view: canManageCrew },
|
||||||
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
|
{ title: "Computers", url: SettingsComputersPath, Icon: Monitor, can_view: canManageCrew },
|
||||||
|
{ title: "MCPs", url: "/admin/settings/mcps", Icon: Plug, can_view: canManageCrew },
|
||||||
|
{ title: "Tools", url: "/admin/settings/tools", Icon: Wrench, can_view: canManageCrew },
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: "Users",
|
||||||
url: "/admin/settings/users",
|
url: "/admin/settings/users",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import grabCustomTool from "./grab-custom-tool";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
custom_tool_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a custom tool; its attachments go with it.
|
||||||
|
*/
|
||||||
|
export default async function deleteCustomTool({ custom_tool_id }: Params) {
|
||||||
|
const tool = await grabCustomTool({ custom_tool_id });
|
||||||
|
const res = await BunSQLite.sql({ sql: `DELETE FROM custom_tools WHERE id = ?`, values: [tool.id] });
|
||||||
|
|
||||||
|
if (!res.success) throw new Error(`Couldn't delete the tool: ${res.error}`);
|
||||||
|
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { stopStdioMcpServer } from "@/src/harness/mcp/mcp-stdio-pool";
|
||||||
|
import grabMcpServer from "./grab-mcp-server";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
mcp_server_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an MCP server (its attachments go with it) and stops it if it's
|
||||||
|
* running here. Its directory, with whatever its setup installed, stays.
|
||||||
|
*/
|
||||||
|
export default async function deleteMcpServer({ mcp_server_id }: Params) {
|
||||||
|
const server = await grabMcpServer({ mcp_server_id });
|
||||||
|
|
||||||
|
stopStdioMcpServer({ server_id: server.id });
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql({ sql: `DELETE FROM mcp_servers WHERE id = ?`, values: [server.id] });
|
||||||
|
|
||||||
|
if (!res.success) throw new Error(`Couldn't delete the MCP server: ${res.error}`);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Where each kind of extension lives: its table, and the link tables
|
||||||
|
* that attach it to bots and workspaces.
|
||||||
|
*/
|
||||||
|
export const ExtensionTables = {
|
||||||
|
mcp: {
|
||||||
|
table: "mcp_servers",
|
||||||
|
bot_link: "bot_mcp_servers",
|
||||||
|
workspace_link: "workspace_mcp_servers",
|
||||||
|
column: "mcp_server_id",
|
||||||
|
label: "MCP server",
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
table: "custom_tools",
|
||||||
|
bot_link: "bot_custom_tools",
|
||||||
|
workspace_link: "workspace_custom_tools",
|
||||||
|
column: "custom_tool_id",
|
||||||
|
label: "tool",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ExtensionKind = keyof typeof ExtensionTables;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||||
|
import botExtensionsSql from "@/src/utils/bot-extensions-sql";
|
||||||
|
import parseCustomToolRow from "./parse-custom-tool-row";
|
||||||
|
import parseMcpServerRow from "./parse-mcp-server-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP servers and custom tools a bot gets on one run
|
||||||
|
* (`botExtensionsSql`): every bot's, its own, and its workspace's there.
|
||||||
|
*/
|
||||||
|
export default async function grabBotExtensions({ bot_id, workspace_id }: Params) {
|
||||||
|
const [servers, tools] = await Promise.all([
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>(botExtensionsSql({ kind: "mcp", bot_id, workspace_id })),
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>(botExtensionsSql({ kind: "tool", bot_id, workspace_id })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers: (servers.payload || []).map((row) => parseMcpServerRow({ row })),
|
||||||
|
tools: (tools.payload || []).map((row) => parseCustomToolRow({ row })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS } from "@/db/types/db";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseCustomToolRow from "./parse-custom-tool-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
custom_tool_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One custom tool, or an `AppError` when there's none.
|
||||||
|
*/
|
||||||
|
export default async function grabCustomTool({ custom_tool_id }: Params) {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>({
|
||||||
|
sql: `SELECT * FROM custom_tools WHERE id = ?`,
|
||||||
|
values: [custom_tool_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row) throw new AppError(`That tool doesn't exist.`);
|
||||||
|
|
||||||
|
return parseCustomToolRow({ row });
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
kind: ExtensionKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots and workspaces every MCP server (or tool) is attached to, by
|
||||||
|
* its id.
|
||||||
|
*/
|
||||||
|
export default async function grabExtensionAccess({ kind }: Params) {
|
||||||
|
const { bot_link, workspace_link, column } = ExtensionTables[kind];
|
||||||
|
const [bots, workspaces] = await Promise.all([
|
||||||
|
BunSQLite.sql<{ id: number; target: number }>({
|
||||||
|
sql: `SELECT ${column} AS id, bot_id AS target FROM ${bot_link}`,
|
||||||
|
}),
|
||||||
|
BunSQLite.sql<{ id: number; target: number }>({
|
||||||
|
sql: `SELECT ${column} AS id, workspace_id AS target FROM ${workspace_link}`,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const access = new Map<number, { bot_ids: number[]; workspace_ids: number[] }>();
|
||||||
|
const entry = (id: number) => {
|
||||||
|
if (!access.has(id)) access.set(id, { bot_ids: [], workspace_ids: [] });
|
||||||
|
return access.get(id)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of bots.payload || []) entry(Number(row.id)).bot_ids.push(Number(row.target));
|
||||||
|
for (const row of workspaces.payload || []) entry(Number(row.id)).workspace_ids.push(Number(row.target));
|
||||||
|
|
||||||
|
return (id: number) => access.get(id) || { bot_ids: [], workspace_ids: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
|
import type { ExtensionTargets } from "@/src/types";
|
||||||
|
import type { CrewRole } from "@/src/utils/rbac/crew-role";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
role: CrewRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an MCP server or tool can be attached to: every worker (and the
|
||||||
|
* coordinator, for the super admin, who alone reaches it) and every
|
||||||
|
* workspace not archived. Only the super admin edits.
|
||||||
|
*/
|
||||||
|
export default async function grabExtensionTargets({ role }: Params): Promise<ExtensionTargets> {
|
||||||
|
const [bots, workspaces] = await Promise.all([
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: `SELECT id, name, role, kind FROM bots ${role == "super_admin" ? "" : `WHERE kind = 'worker'`}
|
||||||
|
ORDER BY kind = 'coordinator' DESC, name COLLATE NOCASE ASC, id ASC`,
|
||||||
|
}),
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||||
|
sql: `SELECT id, name FROM workspaces WHERE archived = 0 ORDER BY name COLLATE NOCASE ASC, id ASC`,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bots: (bots.payload || []).map((bot) => ({
|
||||||
|
id: Number(bot.id),
|
||||||
|
name: bot.name || `Bot ${bot.id}`,
|
||||||
|
role: bot.role || "",
|
||||||
|
kind: bot.kind || "worker",
|
||||||
|
})),
|
||||||
|
workspaces: (workspaces.payload || []).map((workspace) => ({
|
||||||
|
id: Number(workspace.id),
|
||||||
|
name: workspace.name || `Workspace ${workspace.id}`,
|
||||||
|
})),
|
||||||
|
can_edit: role == "super_admin",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseMcpServerRow from "./parse-mcp-server-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
mcp_server_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One MCP server, or an `AppError` when there's none.
|
||||||
|
*/
|
||||||
|
export default async function grabMcpServer({ mcp_server_id }: Params) {
|
||||||
|
const row = (
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>({
|
||||||
|
sql: `SELECT * FROM mcp_servers WHERE id = ?`,
|
||||||
|
values: [mcp_server_id],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (!row) throw new AppError(`That MCP server doesn't exist.`);
|
||||||
|
|
||||||
|
return parseMcpServerRow({ row });
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||||
|
import type { McpServersOverviewPayload } from "@/src/types";
|
||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
import grabExtensionAccess from "./grab-extension-access";
|
||||||
|
import grabExtensionTargets from "./grab-extension-targets";
|
||||||
|
import parseMcpServerRow from "./parse-mcp-server-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCPs settings page: every MCP server with who gets it, and what it
|
||||||
|
* can be attached to. How a server is reached (URL, headers, command,
|
||||||
|
* env) only goes to the super admin.
|
||||||
|
*/
|
||||||
|
export default async function grabMcpsOverview({ user_id }: Params): Promise<McpServersOverviewPayload> {
|
||||||
|
const role = await grabCrewRole({ user_id });
|
||||||
|
const [rows, access, targets] = await Promise.all([
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_MCP_SERVERS>({ sql: `SELECT * FROM mcp_servers ORDER BY name COLLATE NOCASE ASC, id ASC` }),
|
||||||
|
grabExtensionAccess({ kind: "mcp" }),
|
||||||
|
grabExtensionTargets({ role }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...targets,
|
||||||
|
servers: (rows.payload || []).map((row) => {
|
||||||
|
const { url, headers, command, setup_command, env, all_bots, ...server } = parseMcpServerRow({ row });
|
||||||
|
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
access: { all_bots, ...access(server.id) },
|
||||||
|
...(targets.can_edit ? { url, headers, command, setup_command, env } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { EnvDict } from "@/src/dict/env-dict";
|
||||||
|
import grabAllEnvs, { type DecryptedEnv } from "../envs/grab-all-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The workspace the run is in: its envs beat general ones.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The envs an MCP server's or custom tool's templates (`{{env.KEY}}`) read
|
||||||
|
* on one run, one per key: the workspace's beat general ones. App settings
|
||||||
|
* (`EnvDict`) never are. Unlike computers' envs, they don't have to be set
|
||||||
|
* on computers: the app fills them in itself.
|
||||||
|
*/
|
||||||
|
export default async function grabRunEnvs({ workspace_id }: Params = {}): Promise<DecryptedEnv[]> {
|
||||||
|
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||||
|
const envs = (await grabAllEnvs()).filter((env) => !app_keys.has(env.key));
|
||||||
|
const resolved = new Map<string, DecryptedEnv>();
|
||||||
|
|
||||||
|
for (const env of envs.filter((e) => e.paradigm == "general")) resolved.set(env.key, env);
|
||||||
|
|
||||||
|
if (workspace_id) {
|
||||||
|
for (const env of envs.filter((e) => e.paradigm == "workspace" && e.workspace_id == Number(workspace_id))) {
|
||||||
|
resolved.set(env.key, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...resolved.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS } from "@/db/types/db";
|
||||||
|
import type { CustomToolsOverviewPayload } from "@/src/types";
|
||||||
|
import grabCrewRole from "../access/grab-crew-role";
|
||||||
|
import grabExtensionAccess from "./grab-extension-access";
|
||||||
|
import grabExtensionTargets from "./grab-extension-targets";
|
||||||
|
import parseCustomToolRow from "./parse-custom-tool-row";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Tools settings page: every custom tool with who gets it, and what it
|
||||||
|
* can be attached to. Its arguments and how it runs only go to the super
|
||||||
|
* admin.
|
||||||
|
*/
|
||||||
|
export default async function grabToolsOverview({ user_id }: Params): Promise<CustomToolsOverviewPayload> {
|
||||||
|
const role = await grabCrewRole({ user_id });
|
||||||
|
const [rows, access, targets] = await Promise.all([
|
||||||
|
BunSQLite.sql<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>({ sql: `SELECT * FROM custom_tools ORDER BY name ASC, id ASC` }),
|
||||||
|
grabExtensionAccess({ kind: "tool" }),
|
||||||
|
grabExtensionTargets({ role }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...targets,
|
||||||
|
tools: (rows.payload || []).map((row) => {
|
||||||
|
const tool = parseCustomToolRow({ row });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
kind: tool.kind,
|
||||||
|
enabled: tool.enabled,
|
||||||
|
access: { all_bots: tool.all_bots, ...access(tool.id) },
|
||||||
|
...(targets.can_edit ? { parameters: tool.parameters, config: tool.config } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS } from "@/db/types/db";
|
||||||
|
import type { CustomToolRecord } from "@/src/types";
|
||||||
|
import parseJsonColumn from "./parse-json-column";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
row: BUN_SQLITE_COBBLE_CUSTOM_TOOLS;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `custom_tools` row with its JSON columns read.
|
||||||
|
*/
|
||||||
|
export default function parseCustomToolRow({ row }: Params): CustomToolRecord {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: row.name || "",
|
||||||
|
description: row.description || "",
|
||||||
|
parameters: parseJsonColumn<Record<string, unknown>>({
|
||||||
|
text: row.parameters,
|
||||||
|
fallback: { type: "object", properties: {} },
|
||||||
|
}),
|
||||||
|
all_bots: row.all_bots == 1,
|
||||||
|
enabled: row.enabled != 0,
|
||||||
|
kind: row.kind || "prompt",
|
||||||
|
config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }),
|
||||||
|
} as CustomToolRecord;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
type Params<T> = {
|
||||||
|
text?: string | null;
|
||||||
|
fallback: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a JSON column of an MCP server or custom tool. The fallback when
|
||||||
|
* it's empty, not JSON, or not the same shape (array or object).
|
||||||
|
*/
|
||||||
|
export default function parseJsonColumn<T extends object>({ text, fallback }: Params<T>): T {
|
||||||
|
if (!text) return fallback;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!value || typeof value != "object") return fallback;
|
||||||
|
if (Array.isArray(value) != Array.isArray(fallback)) return fallback;
|
||||||
|
|
||||||
|
return value as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||||
|
import type { McpServerStatus, McpTransport } from "@/src/dict/mcp-dict";
|
||||||
|
import type { McpServerRecord, McpToolInfo } from "@/src/types";
|
||||||
|
import parseJsonColumn from "./parse-json-column";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
row: BUN_SQLITE_COBBLE_MCP_SERVERS;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `mcp_servers` row with its JSON columns read.
|
||||||
|
*/
|
||||||
|
export default function parseMcpServerRow({ row }: Params): McpServerRecord {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: row.name || "",
|
||||||
|
slug: row.slug || "",
|
||||||
|
description: row.description || "",
|
||||||
|
transport: (row.transport || "remote") as McpTransport,
|
||||||
|
url: row.url || "",
|
||||||
|
headers: parseJsonColumn<Record<string, string>>({ text: row.headers, fallback: {} }),
|
||||||
|
command: row.command || "",
|
||||||
|
setup_command: row.setup_command || "",
|
||||||
|
env: parseJsonColumn<Record<string, string>>({ text: row.env, fallback: {} }),
|
||||||
|
excluded_tools: parseJsonColumn<string[]>({ text: row.excluded_tools, fallback: [] }),
|
||||||
|
tools: parseJsonColumn<McpToolInfo[]>({ text: row.tools_cache, fallback: [] }),
|
||||||
|
all_bots: row.all_bots == 1,
|
||||||
|
enabled: row.enabled != 0,
|
||||||
|
status: (row.status || "untested") as McpServerStatus,
|
||||||
|
last_error: row.last_error || "",
|
||||||
|
last_synced_at: typeof row.last_synced_at == "number" ? row.last_synced_at : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BunSQLiteTables } from "@/db/types/db";
|
||||||
|
import type { SaveCustomToolRequest } from "@/src/schemas/extension-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabCustomTool from "./grab-custom-tool";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The super admin saving it (checked by the caller).
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
data: SaveCustomToolRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or edits a custom tool and returns its id. Names are unique.
|
||||||
|
*/
|
||||||
|
export default async function saveCustomTool({ user_id, data }: Params) {
|
||||||
|
const clash = (
|
||||||
|
await BunSQLite.sql<{ id: number }>({
|
||||||
|
sql: `SELECT id FROM custom_tools WHERE name = ? AND id != ?`,
|
||||||
|
values: [data.name, data.custom_tool_id || 0],
|
||||||
|
})
|
||||||
|
).payload?.[0];
|
||||||
|
|
||||||
|
if (clash) throw new AppError(`Another tool is already called ${data.name}.`);
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
kind: data.kind,
|
||||||
|
parameters: JSON.stringify(data.parameters),
|
||||||
|
config: JSON.stringify(data.config),
|
||||||
|
enabled: data.enabled ? 1 : 0,
|
||||||
|
all_bots: data.all_bots ? 1 : 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (data.custom_tool_id) {
|
||||||
|
const tool = await grabCustomTool({ custom_tool_id: data.custom_tool_id });
|
||||||
|
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_CUSTOM_TOOLS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "custom_tools",
|
||||||
|
targetId: tool.id,
|
||||||
|
data: fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) throw new Error(`Couldn't save the tool: ${res.msg}`);
|
||||||
|
|
||||||
|
return tool.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_CUSTOM_TOOLS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "custom_tools",
|
||||||
|
data: [{ ...fields, user_id: Number(user_id) }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!res.success || !id) throw new Error(`Couldn't add the tool: ${res.msg}`);
|
||||||
|
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_MCP_SERVERS, BunSQLiteTables } from "@/db/types/db";
|
||||||
|
import { stopStdioMcpServer } from "@/src/harness/mcp/mcp-stdio-pool";
|
||||||
|
import type { SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||||
|
import mcpServerSlug from "@/src/utils/mcp-server-slug";
|
||||||
|
import grabMcpServer from "./grab-mcp-server";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The super admin saving it (checked by the caller).
|
||||||
|
*/
|
||||||
|
user_id: number | string;
|
||||||
|
data: SaveMcpServerRequest;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or edits an MCP server and returns its id. A new one gets a unique
|
||||||
|
* slug from its name (kept when it's renamed, so its tools keep their
|
||||||
|
* names). A running stdio server is stopped, so the next call starts it as
|
||||||
|
* saved.
|
||||||
|
*/
|
||||||
|
export default async function saveMcpServer({ user_id, data }: Params) {
|
||||||
|
const fields = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
transport: data.transport,
|
||||||
|
url: data.transport == "remote" ? data.url : "",
|
||||||
|
headers: JSON.stringify(data.transport == "remote" ? data.headers : {}),
|
||||||
|
command: data.transport == "stdio" ? data.command : "",
|
||||||
|
setup_command: data.transport == "stdio" ? data.setup_command : "",
|
||||||
|
env: JSON.stringify(data.transport == "stdio" ? data.env : {}),
|
||||||
|
excluded_tools: JSON.stringify(data.excluded_tools),
|
||||||
|
enabled: data.enabled ? 1 : 0,
|
||||||
|
all_bots: data.all_bots ? 1 : 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (data.mcp_server_id) {
|
||||||
|
const server = await grabMcpServer({ mcp_server_id: data.mcp_server_id });
|
||||||
|
|
||||||
|
stopStdioMcpServer({ server_id: server.id });
|
||||||
|
|
||||||
|
const res = await BunSQLite.update<BUN_SQLITE_COBBLE_MCP_SERVERS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "mcp_servers",
|
||||||
|
targetId: server.id,
|
||||||
|
data: fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.success) throw new Error(`Couldn't save the MCP server: ${res.msg}`);
|
||||||
|
|
||||||
|
return server.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_MCP_SERVERS, (typeof BunSQLiteTables)[number]>({
|
||||||
|
table: "mcp_servers",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
...fields,
|
||||||
|
slug: await uniqueSlug({ name: data.name }),
|
||||||
|
status: "untested",
|
||||||
|
tools_cache: "[]",
|
||||||
|
user_id: Number(user_id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||||
|
|
||||||
|
if (!res.success || !id) throw new Error(`Couldn't add the MCP server: ${res.msg}`);
|
||||||
|
|
||||||
|
return Number(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueSlug({ name }: { name: string }) {
|
||||||
|
const base = mcpServerSlug({ name });
|
||||||
|
const taken = new Set(
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<{ slug: string }>({
|
||||||
|
sql: `SELECT slug FROM mcp_servers WHERE slug = ? OR slug LIKE ?`,
|
||||||
|
values: [base, `${base}_%`],
|
||||||
|
})
|
||||||
|
).payload?.map((row) => row.slug),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!taken.has(base)) return base;
|
||||||
|
|
||||||
|
for (let n = 2; ; n++) {
|
||||||
|
if (!taken.has(`${base}_${n}`)) return `${base}_${n}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
kind: ExtensionKind;
|
||||||
|
id: number;
|
||||||
|
bot_ids: number[];
|
||||||
|
workspace_ids: number[];
|
||||||
|
/**
|
||||||
|
* Super admins only: left alone for anyone else.
|
||||||
|
*/
|
||||||
|
all_bots?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches an MCP server or tool to exactly these bots and workspaces, for
|
||||||
|
* super admins and admins. An admin never sees the coordinator, so its
|
||||||
|
* attachment is kept as it is; only a super admin sets "all bots".
|
||||||
|
*/
|
||||||
|
export default async function setExtensionAccess({ user_id, kind, id, bot_ids, workspace_ids, all_bots }: Params) {
|
||||||
|
const role = await assertCrewManager({ user_id });
|
||||||
|
const { table, bot_link, workspace_link, column, label } = ExtensionTables[kind];
|
||||||
|
|
||||||
|
const found = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM ${table} WHERE id = ?`, values: [id] });
|
||||||
|
|
||||||
|
if (!found.payload?.[0]) throw new AppError(`That ${label} doesn't exist.`);
|
||||||
|
|
||||||
|
const coordinator = role != "super_admin" ? `AND bot_id NOT IN (SELECT id FROM bots WHERE kind = 'coordinator')` : "";
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM ${bot_link} WHERE ${column} = ? ${coordinator}`, values: [id] });
|
||||||
|
await BunSQLite.sql({ sql: `DELETE FROM ${workspace_link} WHERE ${column} = ?`, values: [id] });
|
||||||
|
|
||||||
|
for (const bot_id of new Set(bot_ids)) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO ${bot_link} (created_at, updated_at, bot_id, ${column})
|
||||||
|
SELECT ?, ?, id, ? FROM bots WHERE id = ? ${role != "super_admin" ? `AND kind = 'worker'` : ""}`,
|
||||||
|
values: [now, now, id, bot_id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const workspace_id of new Set(workspace_ids)) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `INSERT OR IGNORE INTO ${workspace_link} (created_at, updated_at, workspace_id, ${column})
|
||||||
|
SELECT ?, ?, id, ? FROM workspaces WHERE id = ?`,
|
||||||
|
values: [now, now, id, workspace_id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role == "super_admin" && all_bots !== undefined) {
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE ${table} SET all_bots = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [all_bots ? 1 : 0, now, id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import listMcpTools from "@/src/harness/mcp/list-mcp-tools";
|
||||||
|
import openMcpServer from "@/src/harness/mcp/open-mcp-server";
|
||||||
|
import runMcpSetup from "@/src/harness/mcp/run-mcp-setup";
|
||||||
|
import grabMcpStdioEnv from "@/src/harness/mcp/grab-mcp-stdio-env";
|
||||||
|
import { stopStdioMcpServer } from "@/src/harness/mcp/mcp-stdio-pool";
|
||||||
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
|
import grabMcpServer from "./grab-mcp-server";
|
||||||
|
import grabRunEnvs from "./grab-run-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
mcp_server_id: number | string;
|
||||||
|
/**
|
||||||
|
* Runs a stdio server's setup again first, even if it already ran.
|
||||||
|
*/
|
||||||
|
reinstall?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to an MCP server (a stdio one is started, after its setup) and
|
||||||
|
* saves the tools it lists, which is what runs build their tools from.
|
||||||
|
* Saves the outcome on the server (`status`, `last_error`, secrets
|
||||||
|
* blanked out) and returns it; never throws for the server's own failure.
|
||||||
|
*/
|
||||||
|
export default async function syncMcpServer({ mcp_server_id, reinstall }: Params) {
|
||||||
|
const server = await grabMcpServer({ mcp_server_id });
|
||||||
|
const envs = await grabRunEnvs();
|
||||||
|
const open = openMcpServer({ server, envs });
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (reinstall && server.transport == "stdio") {
|
||||||
|
stopStdioMcpServer({ server_id: server.id });
|
||||||
|
await runMcpSetup({ server, env: grabMcpStdioEnv({ server, envs }), force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = await listMcpTools({ client: await open.client() });
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE mcp_servers SET tools_cache = ?, status = 'ok', last_error = '', last_synced_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [JSON.stringify(tools), now, now, server.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true as const, tools };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = redactSecrets({
|
||||||
|
text: error instanceof Error ? error.message : String(error),
|
||||||
|
secrets: envs.filter((env) => env.secret),
|
||||||
|
}).slice(0, 4000);
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE mcp_servers SET status = 'error', last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [message, now, now, server.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: false as const, error: message };
|
||||||
|
} finally {
|
||||||
|
await open.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import buildCustomTool from "@/src/harness/tools/custom/build-custom-tool";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import grabCustomTool from "./grab-custom-tool";
|
||||||
|
import grabRunEnvs from "./grab-run-envs";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
custom_tool_id: number | string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Where a script tool runs. The main computer when not given.
|
||||||
|
*/
|
||||||
|
computer_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a custom tool once with these arguments, as a bot would outside a
|
||||||
|
* workspace (general envs, the main computer for a script), and returns
|
||||||
|
* what the bot would read: its result, or `Error: ...`.
|
||||||
|
*/
|
||||||
|
export default async function testCustomTool({ custom_tool_id, args, computer_id }: Params) {
|
||||||
|
const tool = await grabCustomTool({ custom_tool_id });
|
||||||
|
const main =
|
||||||
|
computer_id ||
|
||||||
|
(await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM computers WHERE kind = 'main' ORDER BY id LIMIT 1` }))
|
||||||
|
.payload?.[0]?.id;
|
||||||
|
|
||||||
|
const harness_tool = buildCustomTool({ tool, envs: await grabRunEnvs(), computer_id: main });
|
||||||
|
|
||||||
|
if (!harness_tool) throw new AppError(`There's no computer to run this script on.`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await harness_tool.run({ args, signal: AbortSignal.timeout(AppData.CustomToolMaxTimeoutMs) });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { mkdir } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
server_id: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a stdio MCP server runs and its setup installs:
|
||||||
|
* `DATA_DIR/<AppData.McpDataDir>/<id>`, created when missing. On the data
|
||||||
|
* volume in production, so installs survive app updates.
|
||||||
|
*/
|
||||||
|
export default async function grabMcpDir({ server_id }: Params) {
|
||||||
|
const { DATA_DIR } = grabDirNames();
|
||||||
|
const dir = path.join(DATA_DIR, AppData.McpDataDir, String(server_id));
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||||
|
import type { McpServerRecord } from "@/src/types";
|
||||||
|
import fillTemplate from "@/src/utils/fill-template";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
server: McpServerRecord;
|
||||||
|
envs: { key: string; value: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only environment a stdio MCP server (and its setup) gets: the
|
||||||
|
* basics a shell needs (`PATH`, `HOME`, `SHELL`, `USER`, ...) plus its own
|
||||||
|
* `env`, filled from the envs. Never the app's own environment, which
|
||||||
|
* holds its encryption key.
|
||||||
|
*/
|
||||||
|
export default function grabMcpStdioEnv({ server, envs }: Params): Record<string, string> {
|
||||||
|
return {
|
||||||
|
...getDefaultEnvironment(),
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(server.env).map(([key, value]) => [key, fillTemplate({ template: value, envs })]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { AppData } from "@/src/data/app-data";
|
|
||||||
import type { HarnessTool } from "../types";
|
import type { HarnessTool } from "../types";
|
||||||
import mcpResultToText, { type McpImage } from "./mcp-result-to-text";
|
import listMcpTools from "./list-mcp-tools";
|
||||||
|
import mcpHarnessTool from "./mcp-harness-tool";
|
||||||
|
import type { McpImage } from "./mcp-result-to-text";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
client: Client;
|
client: Client;
|
||||||
@@ -18,57 +19,15 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every tool an MCP server offers, as harness tools. Arguments go to the
|
* Every tool an MCP server offers, as harness tools (`mcpHarnessTool`).
|
||||||
* server as the model wrote them (the server checks them against its own
|
|
||||||
* schema); results come back as text via `mcpResultToText`.
|
|
||||||
*/
|
*/
|
||||||
export default async function grabMcpTools({
|
export default async function grabMcpTools({
|
||||||
client,
|
client,
|
||||||
exclude = [],
|
exclude = [],
|
||||||
describeImage,
|
describeImage,
|
||||||
redact = (text) => text,
|
redact,
|
||||||
}: Params): Promise<HarnessTool[]> {
|
}: Params): Promise<HarnessTool[]> {
|
||||||
const tools: HarnessTool[] = [];
|
return (await listMcpTools({ client }))
|
||||||
let cursor: string | undefined;
|
.filter((tool) => !exclude.includes(tool.name))
|
||||||
|
.map((tool) => mcpHarnessTool({ tool, client, describeImage, redact }));
|
||||||
do {
|
|
||||||
const page = await client.listTools(cursor ? { cursor } : undefined, {
|
|
||||||
timeout: AppData.McpConnectTimeoutMs,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const tool of page.tools) {
|
|
||||||
if (exclude.includes(tool.name)) continue;
|
|
||||||
|
|
||||||
tools.push({
|
|
||||||
name: tool.name,
|
|
||||||
description: tool.description || tool.title || tool.name,
|
|
||||||
parameters: tool.inputSchema as Record<string, unknown>,
|
|
||||||
run: async ({ args, signal }) => {
|
|
||||||
try {
|
|
||||||
return redact(
|
|
||||||
await mcpResultToText({
|
|
||||||
result: await client.callTool(
|
|
||||||
{ name: tool.name, arguments: args },
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
signal,
|
|
||||||
timeout: AppData.McpToolTimeoutMs,
|
|
||||||
resetTimeoutOnProgress: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
describeImage,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error) error.message = redact(error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor = page.nextCursor;
|
|
||||||
} while (cursor);
|
|
||||||
|
|
||||||
return tools;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* The host's default shell, which runs stdio MCP servers and their setup
|
||||||
|
* (`<shell> -c <command>`).
|
||||||
|
*/
|
||||||
|
export default function hostShell() {
|
||||||
|
return process.env.SHELL || "/bin/bash";
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { McpToolInfo } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
client: Client;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tool an MCP server offers, page by page.
|
||||||
|
*/
|
||||||
|
export default async function listMcpTools({ client }: Params): Promise<McpToolInfo[]> {
|
||||||
|
const tools: McpToolInfo[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const page = await client.listTools(cursor ? { cursor } : undefined, {
|
||||||
|
timeout: AppData.McpConnectTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tool of page.tools) {
|
||||||
|
tools.push({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description || tool.title || tool.name,
|
||||||
|
input_schema: tool.inputSchema as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { HarnessTool } from "../types";
|
||||||
|
import mcpResultToText, { type McpImage } from "./mcp-result-to-text";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* What the model calls it. Defaults to the server's name for it.
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
/**
|
||||||
|
* The tool as the server lists it.
|
||||||
|
*/
|
||||||
|
tool: { name: string; description?: string; input_schema: Record<string, unknown> };
|
||||||
|
/**
|
||||||
|
* The connected client, or how to get it on the first call.
|
||||||
|
*/
|
||||||
|
client: Client | (() => Promise<Client>);
|
||||||
|
describeImage?: (image: McpImage) => Promise<string>;
|
||||||
|
redact?: (text: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One MCP server tool as a harness tool. Arguments go to the server as the
|
||||||
|
* model wrote them (the server checks them against its own schema);
|
||||||
|
* results come back as text via `mcpResultToText`, redacted.
|
||||||
|
*/
|
||||||
|
export default function mcpHarnessTool({
|
||||||
|
name,
|
||||||
|
tool,
|
||||||
|
client,
|
||||||
|
describeImage,
|
||||||
|
redact = (text) => text,
|
||||||
|
}: Params): HarnessTool {
|
||||||
|
return {
|
||||||
|
name: name || tool.name,
|
||||||
|
description: tool.description || tool.name,
|
||||||
|
parameters: tool.input_schema,
|
||||||
|
run: async ({ args, signal }) => {
|
||||||
|
try {
|
||||||
|
const connected = typeof client == "function" ? await client() : client;
|
||||||
|
|
||||||
|
return redact(
|
||||||
|
await mcpResultToText({
|
||||||
|
result: await connected.callTool({ name: tool.name, arguments: args }, undefined, {
|
||||||
|
signal,
|
||||||
|
timeout: AppData.McpToolTimeoutMs,
|
||||||
|
resetTimeoutOnProgress: true,
|
||||||
|
}),
|
||||||
|
describeImage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) error.message = redact(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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 grabMcpStdioEnv from "./grab-mcp-stdio-env";
|
||||||
|
import hostShell from "./host-shell";
|
||||||
|
import runMcpSetup from "./run-mcp-setup";
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
/**
|
||||||
|
* The command, setup and environment it was started with: a change
|
||||||
|
* restarts it.
|
||||||
|
*/
|
||||||
|
key: string;
|
||||||
|
client: Promise<Client>;
|
||||||
|
transport?: StdioClientTransport;
|
||||||
|
timer?: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stdio MCP servers running in this process, one per server, shared by
|
||||||
|
* every run.
|
||||||
|
*/
|
||||||
|
const pool = new Map<number, Entry>();
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
server: McpServerRecord;
|
||||||
|
envs: { key: string; value: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A connected client for a stdio MCP server, started on first use (after
|
||||||
|
* its setup, `runMcpSetup`) with the host's shell in its directory, and
|
||||||
|
* kept running for later calls until it's idle for `AppData.McpStdioIdleMs`,
|
||||||
|
* its configuration changes, or it exits. Its stderr's last lines explain
|
||||||
|
* a failed start.
|
||||||
|
*/
|
||||||
|
export default async function grabStdioMcpClient({ server, envs }: Params): Promise<Client> {
|
||||||
|
const env = grabMcpStdioEnv({ server, envs });
|
||||||
|
const key = String(Bun.hash(JSON.stringify([server.command, server.setup_command, env])));
|
||||||
|
const existing = pool.get(server.id);
|
||||||
|
|
||||||
|
if (existing?.key == key) {
|
||||||
|
touch({ server_id: server.id, entry: existing });
|
||||||
|
return existing.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) stopStdioMcpServer({ server_id: server.id });
|
||||||
|
|
||||||
|
const entry = { key } as Entry;
|
||||||
|
|
||||||
|
entry.client = start({ server, env, entry }).catch((error) => {
|
||||||
|
if (pool.get(server.id) == entry) pool.delete(server.id);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.set(server.id, entry);
|
||||||
|
touch({ server_id: server.id, entry });
|
||||||
|
|
||||||
|
return entry.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops a stdio MCP server running in this process, if it is (on a
|
||||||
|
* change, a delete, or idle).
|
||||||
|
*/
|
||||||
|
export function stopStdioMcpServer({ server_id }: { server_id: number }) {
|
||||||
|
const entry = pool.get(server_id);
|
||||||
|
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
pool.delete(server_id);
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.client.then((client) => client.close()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start({ server, env, entry }: { server: McpServerRecord; env: Record<string, string>; entry: Entry }) {
|
||||||
|
await runMcpSetup({ server, env });
|
||||||
|
|
||||||
|
const transport = new StdioClientTransport({
|
||||||
|
command: hostShell(),
|
||||||
|
args: ["-c", server.command],
|
||||||
|
cwd: await grabMcpDir({ server_id: server.id }),
|
||||||
|
env,
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr = `${stderr}${chunk.toString()}`.slice(-4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.transport = transport;
|
||||||
|
|
||||||
|
const client = new Client({ name: SiteData["SiteSlug"], version: "1.0.0" });
|
||||||
|
|
||||||
|
client.onclose = () => {
|
||||||
|
if (pool.get(server.id) == entry) {
|
||||||
|
pool.delete(server.id);
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch({ server_id, entry }: { server_id: number; entry: Entry }) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.timer = setTimeout(() => {
|
||||||
|
if (pool.get(server_id) == entry) stopStdioMcpServer({ server_id });
|
||||||
|
}, AppData.McpStdioIdleMs);
|
||||||
|
entry.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Children go with the app.
|
||||||
|
*/
|
||||||
|
process.once("exit", () => {
|
||||||
|
for (const entry of pool.values()) {
|
||||||
|
const pid = entry.transport?.pid;
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import type { McpServerRecord } from "@/src/types";
|
||||||
|
import fillTemplate from "@/src/utils/fill-template";
|
||||||
|
import connectMcpServer from "./connect-mcp-server";
|
||||||
|
import grabStdioMcpClient from "./mcp-stdio-pool";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
server: McpServerRecord;
|
||||||
|
/**
|
||||||
|
* What its templates (`{{env.KEY}}`) read.
|
||||||
|
*/
|
||||||
|
envs: { key: string; value: string }[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one run reaches an MCP server, without connecting yet: `client()`
|
||||||
|
* connects on the first call and is reused after. A remote server gets
|
||||||
|
* its own connection for the run (closed by `close`); a stdio server is
|
||||||
|
* shared through the process's pool and keeps running.
|
||||||
|
*/
|
||||||
|
export default function openMcpServer({ server, envs, signal }: Params) {
|
||||||
|
let remote: Promise<Client> | undefined;
|
||||||
|
|
||||||
|
const client = () => {
|
||||||
|
if (server.transport == "stdio") return grabStdioMcpClient({ server, envs });
|
||||||
|
|
||||||
|
remote ||= connectMcpServer({
|
||||||
|
url: fillTemplate({ template: server.url, envs }),
|
||||||
|
headers: Object.fromEntries(
|
||||||
|
Object.entries(server.headers).map(([key, value]) => [key, fillTemplate({ template: value, envs })]),
|
||||||
|
),
|
||||||
|
signal,
|
||||||
|
}).catch((error) => {
|
||||||
|
remote = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return remote;
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = async () => {
|
||||||
|
if (!remote) return;
|
||||||
|
await (await remote.catch(() => null))?.close().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { client, close };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { readdir, rm, writeFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { McpServerRecord } from "@/src/types";
|
||||||
|
import grabMcpDir from "./grab-mcp-dir";
|
||||||
|
import hostShell from "./host-shell";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
server: McpServerRecord;
|
||||||
|
/**
|
||||||
|
* Its environment (`grabMcpStdioEnv`).
|
||||||
|
*/
|
||||||
|
env: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Runs it even when this setup already ran.
|
||||||
|
*/
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkerPrefix = ".cobble-setup-";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a stdio MCP server's setup command with the host's shell in its
|
||||||
|
* directory, once per setup command: a marker file named after it is left
|
||||||
|
* behind, so it runs again when the command changes, when `force` is set,
|
||||||
|
* or when the directory was cleared. The output goes to `setup.log`; a
|
||||||
|
* failure throws with its last lines.
|
||||||
|
*/
|
||||||
|
export default async function runMcpSetup({ server, env, force }: Params) {
|
||||||
|
const command = server.setup_command.trim();
|
||||||
|
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
const dir = await grabMcpDir({ server_id: server.id });
|
||||||
|
const marker = `${MarkerPrefix}${Bun.hash(command).toString(36)}`;
|
||||||
|
const existing = (await readdir(dir)).filter((file) => file.startsWith(MarkerPrefix));
|
||||||
|
|
||||||
|
if (!force && existing.includes(marker)) return;
|
||||||
|
|
||||||
|
await Promise.all(existing.map((file) => rm(path.join(dir, file), { force: true })));
|
||||||
|
|
||||||
|
const proc = Bun.spawn([hostShell(), "-c", command], {
|
||||||
|
cwd: dir,
|
||||||
|
env,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
timeout: AppData.McpSetupTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [stdout, stderr, exit_code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
const output = `${stdout}${stderr ? `\n${stderr}` : ""}`.trim();
|
||||||
|
|
||||||
|
await writeFile(path.join(dir, "setup.log"), `$ ${command}\n${output}\nExit code: ${exit_code}\n`);
|
||||||
|
|
||||||
|
if (exit_code !== 0) {
|
||||||
|
throw new Error(`The setup command failed (exit code ${exit_code}): ${output.slice(-2000) || "no output"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(path.join(dir, marker), command);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { CustomToolRecord } from "@/src/types";
|
||||||
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import checkRequiredArgs from "./check-required-args";
|
||||||
|
import runHttpTool from "./run-http-tool";
|
||||||
|
import runPromptTool from "./run-prompt-tool";
|
||||||
|
import runScriptTool from "./run-script-tool";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
tool: CustomToolRecord;
|
||||||
|
/**
|
||||||
|
* What `{{env.KEY}}` reads (`grabRunEnvs`). Secret values are blanked
|
||||||
|
* out of every result and error.
|
||||||
|
*/
|
||||||
|
envs: { key: string; value: string; secret: boolean }[];
|
||||||
|
/**
|
||||||
|
* Where a script tool runs. Without one, a script tool isn't offered.
|
||||||
|
*/
|
||||||
|
computer_id?: number | string | null;
|
||||||
|
bot_id?: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom tool as a harness tool: its name, description and JSON schema
|
||||||
|
* as the model sees them, and `run` dispatching on its kind (an HTTP
|
||||||
|
* request, a script on the computer, or a prompt). Null for a script tool
|
||||||
|
* when the run has no computer.
|
||||||
|
*/
|
||||||
|
export default function buildCustomTool({
|
||||||
|
tool,
|
||||||
|
envs,
|
||||||
|
computer_id,
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
}: Params): HarnessTool<Record<string, unknown>> | null {
|
||||||
|
if (tool.kind == "script" && !computer_id) return null;
|
||||||
|
|
||||||
|
const secrets = envs.filter((env) => env.secret);
|
||||||
|
const redact = (text: string) => redactSecrets({ text, secrets });
|
||||||
|
|
||||||
|
const call = ({ args, signal }: { args: Record<string, unknown>; signal: AbortSignal }) => {
|
||||||
|
switch (tool.kind) {
|
||||||
|
case "http":
|
||||||
|
return runHttpTool({ config: tool.config, args, envs, signal });
|
||||||
|
case "script":
|
||||||
|
return runScriptTool({ config: tool.config, args, computer_id: computer_id!, workspace_id, signal });
|
||||||
|
case "prompt":
|
||||||
|
return runPromptTool({ config: tool.config, args, envs, bot_id, workspace_id, signal });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description:
|
||||||
|
tool.kind == "script"
|
||||||
|
? `${tool.description}\n(Runs on your current computer.)`
|
||||||
|
: tool.description,
|
||||||
|
parameters: tool.parameters,
|
||||||
|
run: async ({ args, signal }) => {
|
||||||
|
try {
|
||||||
|
checkRequiredArgs({ parameters: tool.parameters, args });
|
||||||
|
return redact(await call({ args, signal }));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) error.message = redact(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The tool's JSON schema.
|
||||||
|
*/
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws when the model left out an argument the tool's schema requires.
|
||||||
|
* The rest of the schema is the model's to follow: custom tools fill
|
||||||
|
* templates, so a wrong type does no harm the call wouldn't show.
|
||||||
|
*/
|
||||||
|
export default function checkRequiredArgs({ parameters, args }: Params) {
|
||||||
|
const required = Array.isArray(parameters.required) ? parameters.required.map(String) : [];
|
||||||
|
const missing = required.filter((key) => args[key] === undefined || args[key] === null || args[key] === "");
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`Missing required arguments: ${missing.join(", ")}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import type { CustomToolHttpConfig } from "@/src/types";
|
||||||
|
import fillTemplate from "@/src/utils/fill-template";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
config: CustomToolHttpConfig;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
envs: { key: string; value: string }[];
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the tool's API from the app: URL (arguments URL-encoded), headers
|
||||||
|
* and body filled in from the arguments and envs. With no body template,
|
||||||
|
* anything but GET sends the arguments as JSON. Returns the response (JSON
|
||||||
|
* pretty-printed); a non-2xx answer throws with its status and body.
|
||||||
|
*/
|
||||||
|
export default async function runHttpTool({ config, args, envs, signal }: Params) {
|
||||||
|
const fill = (template: string, encode?: (value: string) => string) =>
|
||||||
|
fillTemplate({ template, args, envs, encode });
|
||||||
|
|
||||||
|
const method = config.method || "GET";
|
||||||
|
const headers = new Headers(
|
||||||
|
Object.entries(config.headers || {}).map(([key, value]) => [key, fill(value)] as [string, string]),
|
||||||
|
);
|
||||||
|
const body =
|
||||||
|
method == "GET" ? undefined : config.body?.trim() ? fill(config.body) : JSON.stringify(args);
|
||||||
|
|
||||||
|
if (body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||||
|
|
||||||
|
const res = await fetch(fill(config.url, encodeURIComponent), {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.any([
|
||||||
|
signal,
|
||||||
|
AbortSignal.timeout(
|
||||||
|
Math.min(config.timeout_ms || AppData.CustomToolDefaultTimeoutMs, AppData.CustomToolMaxTimeoutMs),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = prettify({ text: await res.text() });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text.slice(0, 4000)}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text || `HTTP ${res.status}, no body.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettify({ text }: { text: string }) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(text), null, 2);
|
||||||
|
} catch {
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { CustomToolPromptConfig } from "@/src/types";
|
||||||
|
import fillTemplate from "@/src/utils/fill-template";
|
||||||
|
import generateText from "../../text/generate-text";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
config: CustomToolPromptConfig;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
envs: { key: string; value: string }[];
|
||||||
|
/**
|
||||||
|
* Its models are used, like the run's.
|
||||||
|
*/
|
||||||
|
bot_id?: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the tool's prompt in and answers it with one text model turn (no
|
||||||
|
* tools). Returns the answer.
|
||||||
|
*/
|
||||||
|
export default async function runPromptTool({ config, args, envs, bot_id, workspace_id, signal }: Params) {
|
||||||
|
const result = await generateText({
|
||||||
|
messages: [{ role: "user", content: fillTemplate({ template: config.prompt || "", args, envs }) }],
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.text || "(no answer)";
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import { CustomToolScriptLanguages } from "@/src/dict/custom-tool-dict";
|
||||||
|
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
|
||||||
|
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
|
||||||
|
import type { CustomToolScriptConfig } from "@/src/types";
|
||||||
|
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
|
||||||
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
|
import runComputerCommand from "../computer/run-computer-command";
|
||||||
|
import writeComputerEnv from "../computer/write-computer-env";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
config: CustomToolScriptConfig;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
computer_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the run is in: its envs apply on the computer.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the tool's code on a computer over the sandbox's REST API, with the
|
||||||
|
* computer's envs set (`writeComputerEnv`) and the arguments as JSON in
|
||||||
|
* `COBBLE_ARGS` and on stdin. Code and arguments travel base64-encoded,
|
||||||
|
* so nothing in them needs quoting. Returns what it printed (stdout and
|
||||||
|
* stderr), secrets redacted; a non-zero exit throws with it.
|
||||||
|
*/
|
||||||
|
export default async function runScriptTool({ config, args, computer_id, workspace_id, signal }: Params) {
|
||||||
|
const target = await grabComputerMcpUrl({ computer_id });
|
||||||
|
|
||||||
|
if (!target) throw new Error(`Computer #${computer_id} has no container to run on.`);
|
||||||
|
if (!target.ready) throw new Error(computerNotReadyMessage(target));
|
||||||
|
|
||||||
|
const envs = await grabComputerEnvs({ computer_id, workspace_id });
|
||||||
|
const { shell_path } = await writeComputerEnv({ base_url: target.base_url, envs, signal });
|
||||||
|
const language =
|
||||||
|
CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0];
|
||||||
|
|
||||||
|
const code = Buffer.from(config.code || "").toString("base64");
|
||||||
|
const input = Buffer.from(JSON.stringify(args)).toString("base64");
|
||||||
|
|
||||||
|
const result = await runComputerCommand({
|
||||||
|
base_url: target.base_url,
|
||||||
|
/**
|
||||||
|
* In a subshell: `exit` in the sandbox's own shell would end its
|
||||||
|
* session.
|
||||||
|
*/
|
||||||
|
command: `(${[
|
||||||
|
`f=$(mktemp --suffix=.${language.extension})`,
|
||||||
|
`echo ${code} | base64 -d > "$f"`,
|
||||||
|
`[ -f ${shell_path} ] && . ${shell_path}`,
|
||||||
|
`export COBBLE_ARGS="$(echo ${input} | base64 -d)"`,
|
||||||
|
`printf '%s' "$COBBLE_ARGS" | ${language.interpreter} "$f" 2>&1`,
|
||||||
|
`status=$?`,
|
||||||
|
`rm -f "$f"`,
|
||||||
|
`exit $status`,
|
||||||
|
].join("; ")})`,
|
||||||
|
timeout_ms: Math.min(config.timeout_ms || AppData.CustomToolDefaultTimeoutMs, AppData.CustomToolMaxTimeoutMs),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = redactSecrets({
|
||||||
|
text: result.output.trim(),
|
||||||
|
secrets: envs.filter((env) => env.secret),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exit_code) {
|
||||||
|
throw new Error(`Exit code ${result.exit_code}${output ? `:\n${output}` : "."}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output || "(no output)";
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions";
|
||||||
|
import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs";
|
||||||
|
import mcpToolName from "@/src/utils/mcp-tool-name";
|
||||||
|
import redactSecrets from "@/src/utils/redact-secrets";
|
||||||
|
import mcpHarnessTool from "../../mcp/mcp-harness-tool";
|
||||||
|
import openMcpServer from "../../mcp/open-mcp-server";
|
||||||
|
import readImage from "../../media/read-image";
|
||||||
|
import type { HarnessTool } from "../../types";
|
||||||
|
import buildCustomTool from "../custom/build-custom-tool";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
bot_id: number | string;
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Where script tools run: the task's computer, or in chat the bot's
|
||||||
|
* current one. None leaves script tools out.
|
||||||
|
*/
|
||||||
|
computer_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Names the run's built-in tools already use: they win.
|
||||||
|
*/
|
||||||
|
taken?: string[];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenBotExtensions = {
|
||||||
|
tools: HarnessTool[];
|
||||||
|
/**
|
||||||
|
* For the system prompt, only when something's wrong (an MCP server
|
||||||
|
* with no tools listed), so the bot can say so. Empty otherwise.
|
||||||
|
*/
|
||||||
|
note: string;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP servers' and custom tools' tools a bot gets on one run
|
||||||
|
* (`grabBotExtensions`), as harness tools. MCP tools are built from each
|
||||||
|
* server's last listing (`<slug>__<tool>`) and connect on their first call,
|
||||||
|
* so a run that doesn't use them never waits on them. Secret env values are
|
||||||
|
* blanked out of every result. Call `close` when the run ends.
|
||||||
|
*/
|
||||||
|
export default async function openBotExtensions({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
computer_id,
|
||||||
|
taken = [],
|
||||||
|
signal,
|
||||||
|
}: Params): Promise<OpenBotExtensions> {
|
||||||
|
const { servers, tools } = await grabBotExtensions({ bot_id, workspace_id });
|
||||||
|
|
||||||
|
if (!servers.length && !tools.length) return { tools: [], note: "", close: async () => {} };
|
||||||
|
|
||||||
|
const envs = await grabRunEnvs({ workspace_id });
|
||||||
|
const secrets = envs.filter((env) => env.secret);
|
||||||
|
const redact = (text: string) => redactSecrets({ text, secrets });
|
||||||
|
const names = new Set(taken);
|
||||||
|
const result: HarnessTool[] = [];
|
||||||
|
const notes: string[] = [];
|
||||||
|
const opened: ReturnType<typeof openMcpServer>[] = [];
|
||||||
|
|
||||||
|
const add = (tool: HarnessTool | null) => {
|
||||||
|
if (!tool || names.has(tool.name)) return;
|
||||||
|
names.add(tool.name);
|
||||||
|
result.push(tool);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
add(buildCustomTool({ tool, envs, computer_id, bot_id, workspace_id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
const listed = server.tools.filter((tool) => !server.excluded_tools.includes(tool.name));
|
||||||
|
|
||||||
|
if (!listed.length) {
|
||||||
|
notes.push(
|
||||||
|
`- ${server.name}: ${server.status == "error" ? `unavailable (${redact(server.last_error).slice(0, 300)})` : "no tools listed yet"}.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = openMcpServer({ server, envs, signal });
|
||||||
|
opened.push(open);
|
||||||
|
|
||||||
|
for (const tool of listed) {
|
||||||
|
add(
|
||||||
|
mcpHarnessTool({
|
||||||
|
name: mcpToolName({ slug: server.slug, name: tool.name }),
|
||||||
|
tool: { ...tool, description: `${server.name}: ${tool.description}` },
|
||||||
|
client: open.client,
|
||||||
|
redact,
|
||||||
|
describeImage: ({ data, mime_type }) =>
|
||||||
|
readImage({ url: `data:${mime_type};base64,${data}`, signal, bot_id, workspace_id }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools: result,
|
||||||
|
note: notes.length
|
||||||
|
? `These MCP servers were set up for you but you can't use them right now; if the user needs them, say so (an admin fixes them in Settings, under MCPs):\n${notes.join("\n")}`
|
||||||
|
: "",
|
||||||
|
close: async () => {
|
||||||
|
await Promise.all(opened.map((open) => open.close()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Form from "@moduletrace/twui/form/Form";
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
import Input from "@moduletrace/twui/form/Input";
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
import ImagePickField from "@/src/components/general/image-pick-field";
|
import ImagePickField from "@/src/components/general/image-pick-field";
|
||||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
import setProfileImage from "@/src/functions/frontend/set-profile-image";
|
import setProfileImage from "@/src/functions/frontend/set-profile-image";
|
||||||
@@ -82,16 +82,14 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
|
|||||||
defaultValue={description}
|
defaultValue={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
id="add-workspace-instructions"
|
mode="markdown"
|
||||||
|
className="h-32"
|
||||||
title="Context"
|
title="Context"
|
||||||
label="Context"
|
|
||||||
showLabel
|
|
||||||
rows={5}
|
|
||||||
placeholder="We ship on April 14. The repo is acme/launch. Marketing signs off on all copy."
|
placeholder="We ship on April 14. The repo is acme/launch. Marketing signs off on all copy."
|
||||||
defaultValue={system_prompt}
|
content={system_prompt}
|
||||||
info="Every bot working here gets this. You can change it later."
|
info="Every bot working here gets this. You can change it later."
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
onChange={(value) => setSystemPrompt(value)}
|
||||||
/>
|
/>
|
||||||
<AddFormActions submit_label="Create workspace" loading={loading} error={error} onCancel={onCancel} />
|
<AddFormActions submit_label="Create workspace" loading={loading} error={error} onCancel={onCancel} />
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import Form from "@moduletrace/twui/form/Form";
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
import Input from "@moduletrace/twui/form/Input";
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
|
||||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
import ImagePickField from "@/src/components/general/image-pick-field";
|
import ImagePickField from "@/src/components/general/image-pick-field";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
@@ -165,16 +165,14 @@ export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
|||||||
<BotHuePicker hue={hue} disabled={loading} onChange={setHue} />
|
<BotHuePicker hue={hue} disabled={loading} onChange={setHue} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Textarea
|
<CodeEditor
|
||||||
id="bot-form-instructions"
|
mode="markdown"
|
||||||
|
className="h-40"
|
||||||
title="Instructions"
|
title="Instructions"
|
||||||
label="Instructions"
|
|
||||||
showLabel
|
|
||||||
rows={6}
|
|
||||||
placeholder="You research topics for the user. Find good sources, read them, and report back with a short summary and links."
|
placeholder="You research topics for the user. Find good sources, read them, and report back with a short summary and links."
|
||||||
defaultValue={system_prompt}
|
content={system_prompt}
|
||||||
info="What it does and how. It refines these itself as it learns how you work."
|
info="What it does and how. It refines these itself as it learns how you work."
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
onChange={(value) => setSystemPrompt(value)}
|
||||||
/>
|
/>
|
||||||
{!bot && workspace?.id ? (
|
{!bot && workspace?.id ? (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Form from "@moduletrace/twui/form/Form";
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
import Input from "@moduletrace/twui/form/Input";
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
import type { CronJobWithNames } from "@/src/types";
|
import type { CronJobWithNames } from "@/src/types";
|
||||||
@@ -66,15 +66,13 @@ export default function RoutineForm({ cron_job, onCancel, onSaved }: Props) {
|
|||||||
info="Cron, in UTC: minute hour day month weekday, or @hourly, @daily, @weekly."
|
info="Cron, in UTC: minute hour day month weekday, or @hourly, @daily, @weekly."
|
||||||
onChange={(e) => setSchedule(e.target.value)}
|
onChange={(e) => setSchedule(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
id="routine-instructions"
|
mode="markdown"
|
||||||
|
className="h-40"
|
||||||
title="Brief"
|
title="Brief"
|
||||||
label="Brief"
|
content={instructions}
|
||||||
showLabel
|
|
||||||
rows={6}
|
|
||||||
defaultValue={instructions}
|
|
||||||
info="What the bot does at every run, and how it knows it's done."
|
info="What the bot does at every run, and how it knows it's done."
|
||||||
onChange={(e) => setInstructions(e.target.value)}
|
onChange={(value) => setInstructions(value)}
|
||||||
/>
|
/>
|
||||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
|
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||||
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
|
import SearchSelect from "@moduletrace/twui/form/SearchSelect";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { ExtensionAccess, ExtensionTargets } from "@/src/types";
|
||||||
|
|
||||||
|
type Props<T> = {
|
||||||
|
/**
|
||||||
|
* The MCP server's or tool's id and name.
|
||||||
|
*/
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
access: ExtensionAccess;
|
||||||
|
targets: ExtensionTargets;
|
||||||
|
/**
|
||||||
|
* Under `/api/admin/`.
|
||||||
|
*/
|
||||||
|
path: "mcps/set-mcp-access" | "tools/set-tool-access";
|
||||||
|
onSaved: (overview: T, msg: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who gets an MCP server or tool: every bot (super admins only), or the
|
||||||
|
* bots and workspaces picked here. A workspace gives it to every bot in
|
||||||
|
* it, there.
|
||||||
|
*/
|
||||||
|
export default function ExtensionAccessModal<T extends { [k: string]: any }>({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
access,
|
||||||
|
targets,
|
||||||
|
path,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: Props<T>) {
|
||||||
|
const [all_bots, setAllBots] = useState(access.all_bots);
|
||||||
|
const [bot_ids, setBotIds] = useState(access.bot_ids);
|
||||||
|
const [workspace_ids, setWorkspaceIds] = useState(access.workspace_ids);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (loading || !window.confirm(`Save who gets ${name}?`)) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<T>({
|
||||||
|
path,
|
||||||
|
body: { id, bot_ids, workspace_ids, all_bots: targets.can_edit ? all_bots : undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setError(res.msg || "Couldn't save who gets it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved(res.singleRes, res.msg || "Saved.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const ids = (value: unknown) => (Array.isArray(value) ? value : value ? [value] : []).map(Number);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddModalShell
|
||||||
|
open
|
||||||
|
setOpen={(next) => !next && onCancel()}
|
||||||
|
title={`Who gets ${name}`}
|
||||||
|
description={"A bot gets it everywhere it works; a workspace gives it to every bot in it, there."}
|
||||||
|
>
|
||||||
|
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||||
|
{targets.can_edit ? (
|
||||||
|
<Checkbox
|
||||||
|
title="Every bot"
|
||||||
|
label="Every bot, the coordinator included"
|
||||||
|
checked={all_bots}
|
||||||
|
changeHandler={setAllBots}
|
||||||
|
/>
|
||||||
|
) : access.all_bots ? (
|
||||||
|
<Checkbox title="Every bot" label="Every bot (only a super admin changes this)" checked readOnly />
|
||||||
|
) : null}
|
||||||
|
{all_bots ? null : (
|
||||||
|
<>
|
||||||
|
<SearchSelect
|
||||||
|
id={`${path}-${id}-bots`}
|
||||||
|
title="Bots"
|
||||||
|
label="Bots"
|
||||||
|
showLabel
|
||||||
|
multiple
|
||||||
|
placeholder="Pick the bots that get it"
|
||||||
|
options={targets.bots.map((bot) => ({
|
||||||
|
value: String(bot.id),
|
||||||
|
title: bot.role ? `${bot.name} (${bot.role})` : bot.name,
|
||||||
|
}))}
|
||||||
|
defaultValues={bot_ids.map(String)}
|
||||||
|
info="They get it everywhere they work."
|
||||||
|
changeHandler={(value) => setBotIds(ids(value))}
|
||||||
|
/>
|
||||||
|
<SearchSelect
|
||||||
|
id={`${path}-${id}-workspaces`}
|
||||||
|
title="Workspaces"
|
||||||
|
label="Workspaces"
|
||||||
|
showLabel
|
||||||
|
multiple
|
||||||
|
placeholder="Pick the workspaces that get it"
|
||||||
|
options={targets.workspaces.map((workspace) => ({
|
||||||
|
value: String(workspace.id),
|
||||||
|
title: workspace.name,
|
||||||
|
}))}
|
||||||
|
defaultValues={workspace_ids.map(String)}
|
||||||
|
info="Every bot in these gets it, there."
|
||||||
|
changeHandler={(value) => setWorkspaceIds(ids(value))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onCancel} />
|
||||||
|
</Form>
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ExtensionAccess } from "@/src/types";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
access: ExtensionAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who gets an MCP server or tool, in a few words: "Every bot",
|
||||||
|
* "2 bots · 1 workspace", or "No bots yet".
|
||||||
|
*/
|
||||||
|
export default function extensionAccessLabel({ access }: Params) {
|
||||||
|
if (access.all_bots) return "Every bot";
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
access.bot_ids.length ? `${access.bot_ids.length} ${access.bot_ids.length == 1 ? "bot" : "bots"}` : "",
|
||||||
|
access.workspace_ids.length
|
||||||
|
? `${access.workspace_ids.length} ${access.workspace_ids.length == 1 ? "workspace" : "workspaces"}`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return parts.join(" · ") || "No bots yet";
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
|
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||||
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import Select from "@moduletrace/twui/form/Select";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
|
||||||
|
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
|
||||||
|
import McpToolToggle from "./mcp-tool-toggle";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Edits this server; adds one when not given.
|
||||||
|
*/
|
||||||
|
server?: PublicMcpServer;
|
||||||
|
busy?: boolean;
|
||||||
|
/**
|
||||||
|
* Runs a stdio server's setup again.
|
||||||
|
*/
|
||||||
|
onReinstall?: () => void;
|
||||||
|
onSaved: (overview: McpServersOverviewPayload, msg: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or edits an MCP server (super admins): a remote one by URL and
|
||||||
|
* headers, or a stdio one by the command that starts it on this server,
|
||||||
|
* the setup that installs what it needs, and the env it gets. Values can
|
||||||
|
* use `{{env.KEY}}`. Saving connects and lists its tools; each can be
|
||||||
|
* left out.
|
||||||
|
*/
|
||||||
|
export default function McpServerModal({ server, busy, onReinstall, onSaved, onCancel }: Props) {
|
||||||
|
const [name, setName] = useState(server?.name || "");
|
||||||
|
const [description, setDescription] = useState(server?.description || "");
|
||||||
|
const [transport, setTransport] = useState<McpTransport>(server?.transport || "remote");
|
||||||
|
const [url, setUrl] = useState(server?.url || "");
|
||||||
|
const [headers, setHeaders] = useState(templateLines({ map: server?.headers, separator: ":" }));
|
||||||
|
const [command, setCommand] = useState(server?.command || "");
|
||||||
|
const [setup_command, setSetupCommand] = useState(server?.setup_command || "");
|
||||||
|
const [env, setEnv] = useState(templateLines({ map: server?.env, separator: "=" }));
|
||||||
|
const [excluded, setExcluded] = useState(server?.excluded_tools || []);
|
||||||
|
const [enabled, setEnabled] = useState(server?.enabled ?? true);
|
||||||
|
const [all_bots, setAllBots] = useState(server?.access.all_bots ?? false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const id = `mcp-${server?.id || "new"}`;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!window.confirm(server ? `Save ${server.name}?` : `Add ${name || "this MCP server"}?`)) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<McpServersOverviewPayload>({
|
||||||
|
path: "mcps/save-mcp",
|
||||||
|
body: {
|
||||||
|
mcp_server_id: server?.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
transport,
|
||||||
|
url,
|
||||||
|
headers: parseTemplateLines({ text: headers, separator: ":" }),
|
||||||
|
command,
|
||||||
|
setup_command,
|
||||||
|
env: parseTemplateLines({ text: env, separator: "=" }),
|
||||||
|
excluded_tools: excluded,
|
||||||
|
enabled,
|
||||||
|
all_bots,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setError(res.msg || "Couldn't save the MCP server.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved(res.singleRes, res.msg || "Saved.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddModalShell
|
||||||
|
open
|
||||||
|
setOpen={(next) => !next && onCancel()}
|
||||||
|
title={server ? `Edit ${server.name}` : "Add an MCP server"}
|
||||||
|
description={"Bots get its tools once it's attached to them. Saving connects and lists them."}
|
||||||
|
>
|
||||||
|
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||||
|
<Input
|
||||||
|
id={`${id}-name`}
|
||||||
|
title="Name"
|
||||||
|
label="Name"
|
||||||
|
showLabel
|
||||||
|
placeholder="GitHub"
|
||||||
|
defaultValue={name}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id={`${id}-description`}
|
||||||
|
title="Description"
|
||||||
|
label="Description"
|
||||||
|
showLabel
|
||||||
|
placeholder="Issues and pull requests in the company's repositories"
|
||||||
|
defaultValue={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id={`${id}-transport`}
|
||||||
|
title="How to reach it"
|
||||||
|
label="How to reach it"
|
||||||
|
showLabel
|
||||||
|
options={McpTransports.map((t) => ({ value: t.value, title: t.title, default: t.value == transport }))}
|
||||||
|
changeHandler={(value) => setTransport(String(value) as McpTransport)}
|
||||||
|
/>
|
||||||
|
{transport == "remote" ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
id={`${id}-url`}
|
||||||
|
title="URL"
|
||||||
|
label="URL"
|
||||||
|
showLabel
|
||||||
|
placeholder="https://mcp.example.com/mcp"
|
||||||
|
defaultValue={url}
|
||||||
|
autoComplete="off"
|
||||||
|
info="Streamable HTTP, or SSE for older servers."
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
mode="properties"
|
||||||
|
className="h-24"
|
||||||
|
title="Headers"
|
||||||
|
placeholder={"Authorization: Bearer {{env.GITHUB_TOKEN}}"}
|
||||||
|
content={headers}
|
||||||
|
info="One per line. Put secrets in Settings, under Environment, and use them as {{env.KEY}}."
|
||||||
|
onChange={(value) => setHeaders(value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CodeEditor
|
||||||
|
title="Command"
|
||||||
|
mode="sh"
|
||||||
|
className="h-24"
|
||||||
|
placeholder="bunx -y @modelcontextprotocol/server-github"
|
||||||
|
content={command}
|
||||||
|
info="Runs on this server with its shell, in the server's own folder, as the app's user. Only add commands you trust."
|
||||||
|
onChange={setCommand}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
title="Setup command"
|
||||||
|
mode="sh"
|
||||||
|
className="h-32"
|
||||||
|
placeholder="bun add @modelcontextprotocol/server-github"
|
||||||
|
content={setup_command}
|
||||||
|
info="Installs what the command needs, in the same folder (kept across updates). Runs before the first start and whenever it changes. Prefer installs into the folder: system packages go when the app is updated."
|
||||||
|
onChange={setSetupCommand}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
mode="properties"
|
||||||
|
className="h-24"
|
||||||
|
title="Env"
|
||||||
|
placeholder={"GITHUB_PERSONAL_ACCESS_TOKEN={{env.GITHUB_TOKEN}}"}
|
||||||
|
content={env}
|
||||||
|
info="One KEY=value per line: the only envs it gets, besides PATH and HOME."
|
||||||
|
onChange={(value) => setEnv(value)}
|
||||||
|
/>
|
||||||
|
{server?.id && onReinstall ? (
|
||||||
|
<Button
|
||||||
|
title="Run the setup command again"
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
className="self-start"
|
||||||
|
disabled={busy || loading}
|
||||||
|
onClick={onReinstall}
|
||||||
|
>
|
||||||
|
Reinstall
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{server?.status == "error" && server.last_error ? (
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words">
|
||||||
|
{server.last_error}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
{server?.tools.length ? (
|
||||||
|
<Stack className="gap-2! items-stretch">
|
||||||
|
<Span className="text-[13px] font-bold leading-5 text-ink">Tools bots get</Span>
|
||||||
|
{server.tools.map((tool) => (
|
||||||
|
<McpToolToggle
|
||||||
|
key={tool.name}
|
||||||
|
tool={tool}
|
||||||
|
on={!excluded.includes(tool.name)}
|
||||||
|
onChange={(on) =>
|
||||||
|
setExcluded((prev) =>
|
||||||
|
on ? prev.filter((name) => name != tool.name) : [...prev, tool.name],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
<Checkbox title="On" label="On: bots get its tools" checked={enabled} changeHandler={setEnabled} />
|
||||||
|
<Checkbox
|
||||||
|
title="Every bot"
|
||||||
|
label="Every bot, the coordinator included"
|
||||||
|
checked={all_bots}
|
||||||
|
changeHandler={setAllBots}
|
||||||
|
/>
|
||||||
|
<AddFormActions
|
||||||
|
submit_label={server ? "Save and connect" : "Add and connect"}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { Plug } from "lucide-react";
|
||||||
|
import IconTile from "@/src/components/general/icon-tile";
|
||||||
|
import { McpTransports } from "@/src/dict/mcp-dict";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { ExtensionTargets, McpServersOverviewPayload, PublicMcpServer } from "@/src/types";
|
||||||
|
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||||
|
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||||
|
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||||
|
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||||
|
import McpServerModal from "./mcp-server-modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
server: PublicMcpServer;
|
||||||
|
targets: ExtensionTargets;
|
||||||
|
onChanged: (overview: McpServersOverviewPayload, msg: string) => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One MCP server: how it's reached, whether it connected, and who gets
|
||||||
|
* it. Edit and Access open in a modal.
|
||||||
|
*/
|
||||||
|
export default function McpServerRow({ server, targets, onChanged, onError }: Props) {
|
||||||
|
const [open, setOpen] = useState<"edit" | "access">();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const post = async ({ path, body, confirm }: { path: string; body: Record<string, unknown>; confirm?: string }) => {
|
||||||
|
if (busy || (confirm && !window.confirm(confirm))) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
const res = await postAdminApi<McpServersOverviewPayload>({ path, body });
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Done.");
|
||||||
|
else onError(res.msg || "That didn't work.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saved = (overview: McpServersOverviewPayload, msg: string) => {
|
||||||
|
setOpen(undefined);
|
||||||
|
onChanged(overview, msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const status =
|
||||||
|
server.status == "ok"
|
||||||
|
? `${server.tools.length} ${server.tools.length == 1 ? "tool" : "tools"}`
|
||||||
|
: server.status == "error"
|
||||||
|
? `Error: ${server.last_error}`
|
||||||
|
: "Not connected yet";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||||
|
<SettingsListRow
|
||||||
|
avatar={<IconTile Icon={Plug} />}
|
||||||
|
title={server.name}
|
||||||
|
badge={server.enabled ? undefined : "Off"}
|
||||||
|
subtitle={[
|
||||||
|
McpTransports.find((t) => t.value == server.transport)?.title,
|
||||||
|
status,
|
||||||
|
extensionAccessLabel({ access: server.access }),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
>
|
||||||
|
{targets.can_edit ? (
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Edit ${server.name}`}
|
||||||
|
label="Edit"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setOpen(open == "edit" ? undefined : "edit")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Choose who gets ${server.name}`}
|
||||||
|
label="Access"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setOpen(open == "access" ? undefined : "access")}
|
||||||
|
/>
|
||||||
|
{targets.can_edit ? (
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`List ${server.name}'s tools again`}
|
||||||
|
label={busy ? "Connecting…" : "Refresh"}
|
||||||
|
disabled={busy || !server.enabled}
|
||||||
|
onClick={() => post({ path: "mcps/sync-mcp", body: { mcp_server_id: server.id } })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{targets.can_edit ? (
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Delete ${server.name}`}
|
||||||
|
label="Delete"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
post({
|
||||||
|
path: "mcps/delete-mcp",
|
||||||
|
body: { mcp_server_id: server.id },
|
||||||
|
confirm: `Delete ${server.name}? Bots lose its tools at once.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SettingsListRow>
|
||||||
|
{open == "edit" ? (
|
||||||
|
<McpServerModal
|
||||||
|
server={server}
|
||||||
|
busy={busy}
|
||||||
|
onReinstall={() =>
|
||||||
|
post({
|
||||||
|
path: "mcps/sync-mcp",
|
||||||
|
body: { mcp_server_id: server.id, reinstall: true },
|
||||||
|
confirm: `Run ${server.name}'s setup command again?`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSaved={saved}
|
||||||
|
onCancel={() => setOpen(undefined)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{open == "access" ? (
|
||||||
|
<ExtensionAccessModal<McpServersOverviewPayload>
|
||||||
|
id={server.id}
|
||||||
|
name={server.name}
|
||||||
|
access={server.access}
|
||||||
|
targets={targets}
|
||||||
|
path="mcps/set-mcp-access"
|
||||||
|
onSaved={saved}
|
||||||
|
onCancel={() => setOpen(undefined)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||||
|
import type { McpToolInfo } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tool: McpToolInfo;
|
||||||
|
on: boolean;
|
||||||
|
onChange: (on: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of a server's tools, ticked when bots get it.
|
||||||
|
*/
|
||||||
|
export default function McpToolToggle({ tool, on, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
title={tool.description}
|
||||||
|
label={`${tool.name}: ${tool.description.split("\n")[0]?.slice(0, 120) || ""}`}
|
||||||
|
checked={on}
|
||||||
|
changeHandler={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useContext, useState } from "react";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { McpServersOverviewPayload } from "@/src/types";
|
||||||
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
|
import SettingsList from "../../(partials)/settings-list";
|
||||||
|
import SettingsStatus from "../../(partials)/settings-status";
|
||||||
|
import McpServerModal from "../(partials)/mcp-server-modal";
|
||||||
|
import McpServerRow from "../(partials)/mcp-server-row";
|
||||||
|
|
||||||
|
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every MCP server with who gets it. Super admins add, edit, refresh and
|
||||||
|
* delete them; admins choose which bots and workspaces get each one.
|
||||||
|
*/
|
||||||
|
export default function McpsSettingsSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [overview, setOverview] = useState(pageProps?.mcps_overview);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
/**
|
||||||
|
* Remounts the rows when the data changes under them.
|
||||||
|
*/
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
if (!overview) return null;
|
||||||
|
|
||||||
|
const changed = (next: McpServersOverviewPayload, msg: string) => {
|
||||||
|
setOverview(next);
|
||||||
|
setStatus({ success: true, msg });
|
||||||
|
setVersion((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
title="MCP servers"
|
||||||
|
description="A server's tools go to the bots and workspaces it's attached to, or to every bot. Bots only connect when they call one."
|
||||||
|
>
|
||||||
|
{adding ? (
|
||||||
|
<McpServerModal
|
||||||
|
onSaved={(next, msg) => {
|
||||||
|
setAdding(false);
|
||||||
|
changed(next, msg);
|
||||||
|
}}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SettingsList is_empty={!overview.servers.length} empty="No MCP servers yet.">
|
||||||
|
{overview.servers.map((server) => (
|
||||||
|
<McpServerRow
|
||||||
|
key={`${server.id}-${version}`}
|
||||||
|
server={server}
|
||||||
|
targets={overview}
|
||||||
|
onChanged={changed}
|
||||||
|
onError={failed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsList>
|
||||||
|
{overview.can_edit && !adding ? (
|
||||||
|
<Button
|
||||||
|
title="Add an MCP server"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="self-start gap-1!"
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden />
|
||||||
|
New MCP server
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { PagePropsType } from "@/src/types";
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||||
|
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admins and admins: admins only choose who gets each server.
|
||||||
|
*/
|
||||||
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
|
const settings = await grabCrewSettingsServerProps(params);
|
||||||
|
|
||||||
|
if (!settings.props?.person?.id) return settings;
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...settings.props,
|
||||||
|
mcps_overview: await grabMcpsOverview({ user_id: settings.props.person.id }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import McpsSettingsSection from "./(sections)/mcps-settings-section";
|
||||||
|
import SettingsShell from "../(partials)/settings-shell";
|
||||||
|
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||||
|
|
||||||
|
export default function AdminSettingsMcpsPage() {
|
||||||
|
return (
|
||||||
|
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
|
||||||
|
<McpsSettingsSection />
|
||||||
|
</SettingsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meta = settingsPageMeta({ label: "MCPs" });
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a new tool's arguments schema starts as.
|
||||||
|
*/
|
||||||
|
export const DefaultToolParameters = JSON.stringify(
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
city: { type: "string", description: "The city to look up" },
|
||||||
|
},
|
||||||
|
required: ["city"],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a tool's config starts as for each kind.
|
||||||
|
*/
|
||||||
|
export const DefaultToolConfigs: {
|
||||||
|
http: CustomToolHttpConfig;
|
||||||
|
script: CustomToolScriptConfig;
|
||||||
|
prompt: CustomToolPromptConfig;
|
||||||
|
} = {
|
||||||
|
http: { method: "GET", url: "", headers: {}, body: "" },
|
||||||
|
script: {
|
||||||
|
language: "python",
|
||||||
|
code: `import json, os\n\nargs = json.loads(os.environ["COBBLE_ARGS"])\nprint(f"Hello from {args.get('city')}")\n`,
|
||||||
|
},
|
||||||
|
prompt: { prompt: "" },
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { CustomToolKind } from "@/src/dict/custom-tool-dict";
|
||||||
|
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
|
||||||
|
import HttpToolFields from "./http-tool-fields";
|
||||||
|
import PromptToolFields from "./prompt-tool-fields";
|
||||||
|
import ScriptToolFields from "./script-tool-fields";
|
||||||
|
|
||||||
|
export type ToolConfigs = {
|
||||||
|
http: CustomToolHttpConfig;
|
||||||
|
script: CustomToolScriptConfig;
|
||||||
|
prompt: CustomToolPromptConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
kind: CustomToolKind;
|
||||||
|
configs: ToolConfigs;
|
||||||
|
onChange: (configs: ToolConfigs) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields of the tool's kind. Each kind keeps its own config, so
|
||||||
|
* switching kinds back and forth loses nothing.
|
||||||
|
*/
|
||||||
|
export default function CustomToolKindFields({ id, kind, configs, onChange }: Props) {
|
||||||
|
switch (kind) {
|
||||||
|
case "http":
|
||||||
|
return <HttpToolFields id={id} config={configs.http} onChange={(http) => onChange({ ...configs, http })} />;
|
||||||
|
case "script":
|
||||||
|
return (
|
||||||
|
<ScriptToolFields id={id} config={configs.script} onChange={(script) => onChange({ ...configs, script })} />
|
||||||
|
);
|
||||||
|
case "prompt":
|
||||||
|
return (
|
||||||
|
<PromptToolFields id={id} config={configs.prompt} onChange={(prompt) => onChange({ ...configs, prompt })} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
|
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||||
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import Select from "@moduletrace/twui/form/Select";
|
||||||
|
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
|
import { CustomToolKinds, type CustomToolKind } from "@/src/dict/custom-tool-dict";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
|
||||||
|
import { DefaultToolConfigs, DefaultToolParameters } from "../(data)/tool-defaults";
|
||||||
|
import CustomToolKindFields, { type ToolConfigs } from "./custom-tool-kind-fields";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* Edits this tool; adds one when not given.
|
||||||
|
*/
|
||||||
|
tool?: PublicCustomTool;
|
||||||
|
onSaved: (overview: CustomToolsOverviewPayload, msg: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or edits a custom tool (super admins): the name and description
|
||||||
|
* the model reads, its arguments as a JSON schema, and what it does (an
|
||||||
|
* HTTP request, a script, or a prompt).
|
||||||
|
*/
|
||||||
|
export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
|
||||||
|
const [name, setName] = useState(tool?.name || "");
|
||||||
|
const [description, setDescription] = useState(tool?.description || "");
|
||||||
|
const [kind, setKind] = useState<CustomToolKind>(tool?.kind || "http");
|
||||||
|
const [parameters, setParameters] = useState(
|
||||||
|
tool?.parameters ? JSON.stringify(tool.parameters, null, 2) : DefaultToolParameters,
|
||||||
|
);
|
||||||
|
const [configs, setConfigs] = useState<ToolConfigs>({
|
||||||
|
...DefaultToolConfigs,
|
||||||
|
...(tool?.config ? { [tool.kind]: tool.config } : {}),
|
||||||
|
});
|
||||||
|
const [enabled, setEnabled] = useState(tool?.enabled ?? true);
|
||||||
|
const [all_bots, setAllBots] = useState(tool?.access.all_bots ?? false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const id = `tool-${tool?.id || "new"}`;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(parameters);
|
||||||
|
} catch {
|
||||||
|
setError("The arguments schema isn't valid JSON.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.confirm(tool ? `Save ${tool.name}?` : `Add ${name || "this tool"}?`)) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<CustomToolsOverviewPayload>({
|
||||||
|
path: "tools/save-tool",
|
||||||
|
body: {
|
||||||
|
custom_tool_id: tool?.id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
kind,
|
||||||
|
parameters: parsed,
|
||||||
|
config: configs[kind],
|
||||||
|
enabled,
|
||||||
|
all_bots,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setError(res.msg || "Couldn't save the tool.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSaved(res.singleRes, res.msg || "Saved.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddModalShell
|
||||||
|
open
|
||||||
|
setOpen={(next) => !next && onCancel()}
|
||||||
|
title={tool ? `Edit ${tool.name}` : "Add a tool"}
|
||||||
|
description={"Bots get it once it's attached to them."}
|
||||||
|
>
|
||||||
|
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||||
|
<Input
|
||||||
|
id={`${id}-name`}
|
||||||
|
title="Name"
|
||||||
|
label="Name"
|
||||||
|
showLabel
|
||||||
|
placeholder="get_weather"
|
||||||
|
defaultValue={name}
|
||||||
|
autoComplete="off"
|
||||||
|
info="What bots call it: lowercase letters, numbers and underscores."
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
id={`${id}-description`}
|
||||||
|
title="Description"
|
||||||
|
label="Description"
|
||||||
|
showLabel
|
||||||
|
rows={3}
|
||||||
|
placeholder="Gets the current weather for a city. Use it when the user asks about the weather."
|
||||||
|
defaultValue={description}
|
||||||
|
info="Bots read this to decide when to call it, so say what it does and when to use it."
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
mode="json"
|
||||||
|
className="h-52"
|
||||||
|
title="Arguments"
|
||||||
|
content={parameters}
|
||||||
|
info='An object schema. Describe each argument: bots fill them in from it. {"type": "object", "properties": {}} takes none.'
|
||||||
|
onChange={(value) => setParameters(value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id={`${id}-kind`}
|
||||||
|
title="What it does"
|
||||||
|
label="What it does"
|
||||||
|
showLabel
|
||||||
|
options={CustomToolKinds.map((k) => ({ value: k.value, title: k.title, default: k.value == kind }))}
|
||||||
|
changeHandler={(value) => setKind(String(value) as CustomToolKind)}
|
||||||
|
/>
|
||||||
|
<CustomToolKindFields key={kind} id={id} kind={kind} configs={configs} onChange={setConfigs} />
|
||||||
|
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
|
||||||
|
<Checkbox
|
||||||
|
title="Every bot"
|
||||||
|
label="Every bot, the coordinator included"
|
||||||
|
checked={all_bots}
|
||||||
|
changeHandler={setAllBots}
|
||||||
|
/>
|
||||||
|
<AddFormActions submit_label={tool ? "Save" : "Add tool"} loading={loading} error={error} onCancel={onCancel} />
|
||||||
|
</Form>
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { Wrench } from "lucide-react";
|
||||||
|
import IconTile from "@/src/components/general/icon-tile";
|
||||||
|
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { CustomToolsOverviewPayload, ExtensionTargets, PublicCustomTool } from "@/src/types";
|
||||||
|
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||||
|
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||||
|
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||||
|
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||||
|
import CustomToolModal from "./custom-tool-modal";
|
||||||
|
import CustomToolTryModal from "./custom-tool-try-modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tool: PublicCustomTool;
|
||||||
|
targets: ExtensionTargets;
|
||||||
|
onChanged: (overview: CustomToolsOverviewPayload, msg: string) => void;
|
||||||
|
onError: (msg: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One custom tool: its name, kind and who gets it. Edit, Access and Try
|
||||||
|
* open in a modal.
|
||||||
|
*/
|
||||||
|
export default function CustomToolRow({ tool, targets, onChanged, onError }: Props) {
|
||||||
|
const [open, setOpen] = useState<"edit" | "access" | "try">();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const toggle = (panel: "edit" | "access" | "try") => setOpen(open == panel ? undefined : panel);
|
||||||
|
|
||||||
|
const saved = (overview: CustomToolsOverviewPayload, msg: string) => {
|
||||||
|
setOpen(undefined);
|
||||||
|
onChanged(overview, msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (busy || !window.confirm(`Delete ${tool.name}? Bots lose it at once.`)) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
const res = await postAdminApi<CustomToolsOverviewPayload>({
|
||||||
|
path: "tools/delete-tool",
|
||||||
|
body: { custom_tool_id: tool.id },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
|
||||||
|
else onError(res.msg || "Couldn't delete the tool.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||||
|
<SettingsListRow
|
||||||
|
avatar={<IconTile Icon={Wrench} />}
|
||||||
|
title={tool.name}
|
||||||
|
badge={tool.enabled ? undefined : "Off"}
|
||||||
|
subtitle={[
|
||||||
|
CustomToolKinds.find((k) => k.value == tool.kind)?.title,
|
||||||
|
extensionAccessLabel({ access: tool.access }),
|
||||||
|
tool.description,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
>
|
||||||
|
{targets.can_edit ? (
|
||||||
|
<SettingsRowAction title={`Edit ${tool.name}`} label="Edit" disabled={busy} onClick={() => toggle("edit")} />
|
||||||
|
) : null}
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Choose who gets ${tool.name}`}
|
||||||
|
label="Access"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => toggle("access")}
|
||||||
|
/>
|
||||||
|
{targets.can_edit ? (
|
||||||
|
<SettingsRowAction title={`Try ${tool.name}`} label="Try" disabled={busy} onClick={() => toggle("try")} />
|
||||||
|
) : null}
|
||||||
|
{targets.can_edit ? (
|
||||||
|
<SettingsRowAction title={`Delete ${tool.name}`} label="Delete" disabled={busy} onClick={remove} />
|
||||||
|
) : null}
|
||||||
|
</SettingsListRow>
|
||||||
|
{open == "edit" ? (
|
||||||
|
<CustomToolModal tool={tool} onSaved={saved} onCancel={() => setOpen(undefined)} />
|
||||||
|
) : null}
|
||||||
|
{open == "access" ? (
|
||||||
|
<ExtensionAccessModal<CustomToolsOverviewPayload>
|
||||||
|
id={tool.id}
|
||||||
|
name={tool.name}
|
||||||
|
access={tool.access}
|
||||||
|
targets={targets}
|
||||||
|
path="tools/set-tool-access"
|
||||||
|
onSaved={saved}
|
||||||
|
onCancel={() => setOpen(undefined)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{open == "try" ? <CustomToolTryModal tool={tool} onClose={() => setOpen(undefined)} /> : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { CustomToolTestPayload, PublicCustomTool } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tool: PublicCustomTool;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a saved tool once with sample arguments and shows what a bot would
|
||||||
|
* read. A script runs on the main computer, with general envs.
|
||||||
|
*/
|
||||||
|
export default function CustomToolTryModal({ tool, onClose }: Props) {
|
||||||
|
const [args, setArgs] = useState("{}");
|
||||||
|
const [output, setOutput] = useState<string>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(args || "{}");
|
||||||
|
} catch {
|
||||||
|
setError("The arguments aren't valid JSON.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi<CustomToolTestPayload>({
|
||||||
|
path: "tools/test-tool",
|
||||||
|
body: { custom_tool_id: tool.id, args: parsed },
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes) {
|
||||||
|
setError(res.msg || "Couldn't run the tool.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutput(res.singleRes.output);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddModalShell
|
||||||
|
open
|
||||||
|
setOpen={(next) => !next && onClose()}
|
||||||
|
title={`Try ${tool.name}`}
|
||||||
|
description={"Runs it once and shows what a bot would read. A script runs on the main computer, with general envs."}
|
||||||
|
>
|
||||||
|
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||||
|
<CodeEditor
|
||||||
|
mode="json"
|
||||||
|
className="h-28"
|
||||||
|
title="Arguments"
|
||||||
|
content={args}
|
||||||
|
info="Saved changes only: save the tool first."
|
||||||
|
onChange={(value) => setArgs(value)}
|
||||||
|
/>
|
||||||
|
{output !== undefined ? (
|
||||||
|
<Span className="block font-mono text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words max-h-96 overflow-auto rounded-default-sm border border-solid border-hairline bg-paper p-3">
|
||||||
|
{output}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
<AddFormActions submit_label="Run it" loading={loading} error={error} onCancel={onClose} />
|
||||||
|
</Form>
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import Select from "@moduletrace/twui/form/Select";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
|
import { CustomToolHttpMethods, type CustomToolHttpMethod } from "@/src/dict/custom-tool-dict";
|
||||||
|
import type { CustomToolHttpConfig } from "@/src/types";
|
||||||
|
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
|
||||||
|
import TimeoutField from "./timeout-field";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
config: CustomToolHttpConfig;
|
||||||
|
onChange: (config: CustomToolHttpConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An HTTP tool's request: method, URL, headers and body, all templates.
|
||||||
|
*/
|
||||||
|
export default function HttpToolFields({ id, config, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
id={`${id}-method`}
|
||||||
|
title="Method"
|
||||||
|
label="Method"
|
||||||
|
showLabel
|
||||||
|
options={CustomToolHttpMethods.map((method) => ({
|
||||||
|
value: method,
|
||||||
|
title: method,
|
||||||
|
default: method == config.method,
|
||||||
|
}))}
|
||||||
|
changeHandler={(value) => onChange({ ...config, method: String(value) as CustomToolHttpMethod })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id={`${id}-url`}
|
||||||
|
title="URL"
|
||||||
|
label="URL"
|
||||||
|
showLabel
|
||||||
|
placeholder="https://api.example.com/weather?city={{args.city}}"
|
||||||
|
defaultValue={config.url}
|
||||||
|
autoComplete="off"
|
||||||
|
info="{{args.name}} fills in an argument (URL-encoded), {{env.KEY}} an env."
|
||||||
|
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
mode="properties"
|
||||||
|
className="h-24"
|
||||||
|
title="Headers"
|
||||||
|
placeholder={"Authorization: Bearer {{env.WEATHER_KEY}}"}
|
||||||
|
content={templateLines({ map: config.headers, separator: ":" })}
|
||||||
|
info="One per line. Put secrets in Settings, under Environment: bots never see them."
|
||||||
|
onChange={(value) =>
|
||||||
|
onChange({ ...config, headers: parseTemplateLines({ text: value, separator: ":" }) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{config.method == "GET" ? null : (
|
||||||
|
<CodeEditor
|
||||||
|
mode="json"
|
||||||
|
className="h-28"
|
||||||
|
title="Body"
|
||||||
|
placeholder={'{"city": "{{args.city}}"}'}
|
||||||
|
content={config.body || ""}
|
||||||
|
info="Empty sends the arguments as JSON."
|
||||||
|
onChange={(value) => onChange({ ...config, body: value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
|
import type { CustomToolPromptConfig } from "@/src/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
config: CustomToolPromptConfig;
|
||||||
|
onChange: (config: CustomToolPromptConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prompt tool's prompt, answered by the bot's text model.
|
||||||
|
*/
|
||||||
|
export default function PromptToolFields({ id, config, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<CodeEditor
|
||||||
|
mode="markdown"
|
||||||
|
className="h-52"
|
||||||
|
title="Prompt"
|
||||||
|
placeholder={"Summarise this contract for a busy founder. List the risks first.\n\n{{args.text}}"}
|
||||||
|
content={config.prompt}
|
||||||
|
info="{{args.name}} fills in an argument. The bot's text model answers it in one turn, without tools."
|
||||||
|
onChange={(value) => onChange({ ...config, prompt: value })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Select from "@moduletrace/twui/form/Select";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
|
import { CustomToolScriptLanguages, type CustomToolScriptLanguage } from "@/src/dict/custom-tool-dict";
|
||||||
|
import type { CustomToolScriptConfig } from "@/src/types";
|
||||||
|
import TimeoutField from "./timeout-field";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
config: CustomToolScriptConfig;
|
||||||
|
onChange: (config: CustomToolScriptConfig) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A script tool's code and language. It runs on the bot's computer.
|
||||||
|
*/
|
||||||
|
export default function ScriptToolFields({ id, config, onChange }: Props) {
|
||||||
|
const language =
|
||||||
|
CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
id={`${id}-language`}
|
||||||
|
title="Language"
|
||||||
|
label="Language"
|
||||||
|
showLabel
|
||||||
|
options={CustomToolScriptLanguages.map((language) => ({
|
||||||
|
value: language.value,
|
||||||
|
title: language.title,
|
||||||
|
default: language.value == config.language,
|
||||||
|
}))}
|
||||||
|
changeHandler={(value) => onChange({ ...config, language: String(value) as CustomToolScriptLanguage })}
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
title="Code"
|
||||||
|
mode={language.editor_mode}
|
||||||
|
className="h-72"
|
||||||
|
content={config.code}
|
||||||
|
info="Runs on the bot's current computer (the task's in a task) with its envs set. The arguments come as JSON in $COBBLE_ARGS and on stdin; what it prints goes back to the bot."
|
||||||
|
onChange={(code) => onChange({ ...config, code })}
|
||||||
|
/>
|
||||||
|
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
timeout_ms?: number;
|
||||||
|
onChange: (timeout_ms?: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a tool may run, in seconds. Empty uses the default.
|
||||||
|
*/
|
||||||
|
export default function TimeoutField({ id, timeout_ms, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={`${id}-timeout`}
|
||||||
|
title="Timeout (seconds)"
|
||||||
|
label="Timeout (seconds)"
|
||||||
|
showLabel
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={AppData.CustomToolMaxTimeoutMs / 1000}
|
||||||
|
placeholder={String(AppData.CustomToolDefaultTimeoutMs / 1000)}
|
||||||
|
defaultValue={timeout_ms ? String(timeout_ms / 1000) : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const seconds = Number(e.target.value);
|
||||||
|
onChange(seconds > 0 ? Math.round(seconds * 1000) : undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useContext, useState } from "react";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { CustomToolsOverviewPayload } from "@/src/types";
|
||||||
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
|
import SettingsList from "../../(partials)/settings-list";
|
||||||
|
import SettingsStatus from "../../(partials)/settings-status";
|
||||||
|
import CustomToolModal from "../(partials)/custom-tool-modal";
|
||||||
|
import CustomToolRow from "../(partials)/custom-tool-row";
|
||||||
|
|
||||||
|
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every custom tool with who gets it. Super admins add, edit, try and
|
||||||
|
* delete them; admins choose which bots and workspaces get each one.
|
||||||
|
*/
|
||||||
|
export default function ToolsSettingsSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [overview, setOverview] = useState(pageProps?.tools_overview);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
/**
|
||||||
|
* Remounts the rows when the data changes under them.
|
||||||
|
*/
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
if (!overview) return null;
|
||||||
|
|
||||||
|
const changed = (next: CustomToolsOverviewPayload, msg: string) => {
|
||||||
|
setOverview(next);
|
||||||
|
setStatus({ success: true, msg });
|
||||||
|
setVersion((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
title="Tools"
|
||||||
|
description="An API call, a script on the bot's computer, or a prompt. A tool goes to the bots and workspaces it's attached to, or to every bot."
|
||||||
|
>
|
||||||
|
{adding ? (
|
||||||
|
<CustomToolModal
|
||||||
|
onSaved={(next, msg) => {
|
||||||
|
setAdding(false);
|
||||||
|
changed(next, msg);
|
||||||
|
}}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<SettingsList is_empty={!overview.tools.length} empty="No tools yet.">
|
||||||
|
{overview.tools.map((tool) => (
|
||||||
|
<CustomToolRow
|
||||||
|
key={`${tool.id}-${version}`}
|
||||||
|
tool={tool}
|
||||||
|
targets={overview}
|
||||||
|
onChanged={changed}
|
||||||
|
onError={failed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsList>
|
||||||
|
{overview.can_edit && !adding ? (
|
||||||
|
<Button
|
||||||
|
title="Add a tool"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="self-start gap-1!"
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden />
|
||||||
|
New tool
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { PagePropsType } from "@/src/types";
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||||
|
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super admins and admins: admins only choose who gets each tool.
|
||||||
|
*/
|
||||||
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
|
const settings = await grabCrewSettingsServerProps(params);
|
||||||
|
|
||||||
|
if (!settings.props?.person?.id) return settings;
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...settings.props,
|
||||||
|
tools_overview: await grabToolsOverview({ user_id: settings.props.person.id }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import ToolsSettingsSection from "./(sections)/tools-settings-section";
|
||||||
|
import SettingsShell from "../(partials)/settings-shell";
|
||||||
|
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||||
|
|
||||||
|
export default function AdminSettingsToolsPage() {
|
||||||
|
return (
|
||||||
|
<SettingsShell title="Tools" description="Tools of your own your bots can call.">
|
||||||
|
<ToolsSettingsSection />
|
||||||
|
</SettingsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meta = settingsPageMeta({ label: "Tools" });
|
||||||
+11
-15
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Form from "@moduletrace/twui/form/Form";
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
import type { WorkspaceBot } from "@/src/types";
|
import type { WorkspaceBot } from "@/src/types";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
@@ -57,25 +57,21 @@ export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, o
|
|||||||
description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`}
|
description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`}
|
||||||
>
|
>
|
||||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
id="workspace-bot-instructions"
|
mode="markdown"
|
||||||
|
className="h-44"
|
||||||
title="Instructions here"
|
title="Instructions here"
|
||||||
label="Instructions here"
|
content={system_prompt}
|
||||||
showLabel
|
|
||||||
rows={7}
|
|
||||||
defaultValue={system_prompt}
|
|
||||||
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
|
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
onChange={(value) => setSystemPrompt(value)}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
id="workspace-bot-memory"
|
mode="markdown"
|
||||||
|
className="h-32"
|
||||||
title="Memory here"
|
title="Memory here"
|
||||||
label="Memory here"
|
content={memory}
|
||||||
showLabel
|
|
||||||
rows={5}
|
|
||||||
defaultValue={memory}
|
|
||||||
info="What it must remember in this workspace."
|
info="What it must remember in this workspace."
|
||||||
onChange={(e) => setMemory(e.target.value)}
|
onChange={(value) => setMemory(value)}
|
||||||
/>
|
/>
|
||||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onClose} />
|
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onClose} />
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
+6
-8
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Form from "@moduletrace/twui/form/Form";
|
import Form from "@moduletrace/twui/form/Form";
|
||||||
import Input from "@moduletrace/twui/form/Input";
|
import Input from "@moduletrace/twui/form/Input";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||||
@@ -80,15 +80,13 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
|
|||||||
defaultValue={description}
|
defaultValue={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
id="workspace-context"
|
mode="markdown"
|
||||||
|
className="h-52"
|
||||||
title="Context"
|
title="Context"
|
||||||
label="Context"
|
content={system_prompt}
|
||||||
showLabel
|
|
||||||
rows={8}
|
|
||||||
defaultValue={system_prompt}
|
|
||||||
info="Markdown. Goals, people, links, rules: whatever every bot here should know."
|
info="Markdown. Goals, people, links, rules: whatever every bot here should know."
|
||||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
onChange={(value) => setSystemPrompt(value)}
|
||||||
/>
|
/>
|
||||||
<Row className="w-full gap-3! flex-nowrap items-center justify-between">
|
<Row className="w-full gap-3! flex-nowrap items-center justify-between">
|
||||||
<SettingsStatus status={status} />
|
<SettingsStatus status={status} />
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import deleteMcpServer from "@/src/functions/backend/db/extensions/delete-mcp-server";
|
||||||
|
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||||
|
import { McpServerIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import type { McpServersOverviewPayload } 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: deletes an MCP server. Bots lose its tools at once.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<McpServersOverviewPayload>
|
||||||
|
> = 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 { mcp_server_id } = parseWithSchema({ schema: McpServerIdRequestSchema, data: body });
|
||||||
|
const server = await deleteMcpServer({ mcp_server_id });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "MCP server deleted",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} deleted the MCP server ${server.name} (#${server.id}).`,
|
||||||
|
metadata: { mcp_server_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Deleted ${server.name}.`,
|
||||||
|
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||||
|
import saveMcpServer from "@/src/functions/backend/db/extensions/save-mcp-server";
|
||||||
|
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||||
|
import { SaveMcpServerRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import type { McpServersOverviewPayload } 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: adds or edits an MCP server, then lists its tools
|
||||||
|
* (starting a stdio one after its setup) so bots can use them.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<McpServersOverviewPayload>
|
||||||
|
> = 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: SaveMcpServerRequestSchema, data: body });
|
||||||
|
const mcp_server_id = await saveMcpServer({ user_id: user.id, data });
|
||||||
|
const sync = data.enabled ? await syncMcpServer({ mcp_server_id }) : null;
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: data.mcp_server_id ? "MCP server changed" : "MCP server added",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} ${data.mcp_server_id ? "changed" : "added"} the MCP server ${data.name} (#${mcp_server_id}, ${data.transport}).`,
|
||||||
|
metadata: { mcp_server_id, transport: data.transport },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: !sync
|
||||||
|
? `Saved ${data.name}. It's off.`
|
||||||
|
: sync.ok
|
||||||
|
? `Saved ${data.name}: ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
||||||
|
: `Saved ${data.name}, but it didn't connect.`,
|
||||||
|
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||||
|
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import type { McpServersOverviewPayload } 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: attaches an MCP server to exactly these bots and
|
||||||
|
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<McpServersOverviewPayload>
|
||||||
|
> = 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 access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
|
||||||
|
|
||||||
|
await setExtensionAccess({ user_id: user.id, kind: "mcp", ...access });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "MCP server access changed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} changed who gets the MCP server #${access.id}.`,
|
||||||
|
metadata: access,
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Saved who gets it.`,
|
||||||
|
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||||
|
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||||
|
import { SyncMcpServerRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import type { McpServersOverviewPayload } 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: lists an MCP server's tools again, optionally running
|
||||||
|
* a stdio server's setup again first (Reinstall).
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<McpServersOverviewPayload>
|
||||||
|
> = 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 { mcp_server_id, reinstall } = parseWithSchema({ schema: SyncMcpServerRequestSchema, data: body });
|
||||||
|
const sync = await syncMcpServer({ mcp_server_id, reinstall });
|
||||||
|
|
||||||
|
if (reinstall) {
|
||||||
|
await logEvent({
|
||||||
|
title: "MCP server reinstalled",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} reinstalled the MCP server #${mcp_server_id}.`,
|
||||||
|
metadata: { mcp_server_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: sync.ok
|
||||||
|
? `Connected: ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
||||||
|
: `It didn't connect.`,
|
||||||
|
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import deleteCustomTool from "@/src/functions/backend/db/extensions/delete-custom-tool";
|
||||||
|
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||||
|
import { CustomToolIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import type { CustomToolsOverviewPayload } 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: deletes a custom tool. Bots lose it at once.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<CustomToolsOverviewPayload>
|
||||||
|
> = 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 { custom_tool_id } = parseWithSchema({ schema: CustomToolIdRequestSchema, data: body });
|
||||||
|
const tool = await deleteCustomTool({ custom_tool_id });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Tool deleted",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} deleted the tool ${tool.name} (#${tool.id}).`,
|
||||||
|
metadata: { custom_tool_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Deleted ${tool.name}.`,
|
||||||
|
singleRes: await grabToolsOverview({ user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||||
|
import saveCustomTool from "@/src/functions/backend/db/extensions/save-custom-tool";
|
||||||
|
import { SaveCustomToolRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import type { CustomToolsOverviewPayload } 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: adds or edits a custom tool.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<CustomToolsOverviewPayload>
|
||||||
|
> = 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: SaveCustomToolRequestSchema, data: body });
|
||||||
|
const custom_tool_id = await saveCustomTool({ user_id: user.id, data });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: data.custom_tool_id ? "Tool changed" : "Tool added",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} ${data.custom_tool_id ? "changed" : "added"} the tool ${data.name} (#${custom_tool_id}, ${data.kind}).`,
|
||||||
|
metadata: { custom_tool_id, kind: data.kind },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Saved ${data.name}.`,
|
||||||
|
singleRes: await grabToolsOverview({ user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||||
|
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import type { CustomToolsOverviewPayload } 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: attaches a custom tool to exactly these bots and
|
||||||
|
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<CustomToolsOverviewPayload>
|
||||||
|
> = 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 access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
|
||||||
|
|
||||||
|
await setExtensionAccess({ user_id: user.id, kind: "tool", ...access });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Tool access changed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} changed who gets the tool #${access.id}.`,
|
||||||
|
metadata: access,
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Saved who gets it.`,
|
||||||
|
singleRes: await grabToolsOverview({ user_id: user.id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||||
|
import testCustomTool from "@/src/functions/backend/db/extensions/test-custom-tool";
|
||||||
|
import { TestCustomToolRequestSchema } from "@/src/schemas/extension-schema";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import type { CustomToolTestPayload } 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: runs a custom tool once with sample arguments and
|
||||||
|
* returns what a bot would read.
|
||||||
|
*/
|
||||||
|
export const handler: BunextAPIRouteHandler<
|
||||||
|
APIResponseObject<CustomToolTestPayload>
|
||||||
|
> = 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 { custom_tool_id, args, computer_id } = parseWithSchema({ schema: TestCustomToolRequestSchema, data: body });
|
||||||
|
const output = await testCustomTool({ custom_tool_id, args, computer_id });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Tool tried",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} tried the tool #${custom_tool_id}.`,
|
||||||
|
metadata: { custom_tool_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, msg: "Ran it.", singleRes: { output } };
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { AppData } from "@/src/data/app-data";
|
||||||
|
import {
|
||||||
|
CustomToolHttpMethods,
|
||||||
|
CustomToolScriptLanguages,
|
||||||
|
type CustomToolScriptLanguage,
|
||||||
|
} from "@/src/dict/custom-tool-dict";
|
||||||
|
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
|
||||||
|
import checkCustomToolName from "@/src/utils/check-custom-tool-name";
|
||||||
|
|
||||||
|
const Id = z.coerce.number().int().positive();
|
||||||
|
const Ids = z.array(Id).max(1000).default([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header or env names to their (template) values.
|
||||||
|
*/
|
||||||
|
const TemplateMap = z
|
||||||
|
.record(z.string().trim().min(1).max(200), z.string().max(4000))
|
||||||
|
.default({});
|
||||||
|
|
||||||
|
const TimeoutMs = z.coerce.number().int().min(1000).max(AppData.CustomToolMaxTimeoutMs).optional();
|
||||||
|
|
||||||
|
export const SaveMcpServerRequestSchema = z
|
||||||
|
.object({
|
||||||
|
mcp_server_id: Id.optional(),
|
||||||
|
name: z.string().trim().min(1, "Enter a name").max(80),
|
||||||
|
description: z.string().trim().max(1000).default(""),
|
||||||
|
transport: z.enum(McpTransports.map((t) => t.value) as [McpTransport, ...McpTransport[]]),
|
||||||
|
url: z.string().trim().max(2000).default(""),
|
||||||
|
headers: TemplateMap,
|
||||||
|
command: z.string().trim().max(4000).default(""),
|
||||||
|
setup_command: z.string().trim().max(8000).default(""),
|
||||||
|
env: TemplateMap,
|
||||||
|
excluded_tools: z.array(z.string().max(200)).max(500).default([]),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
all_bots: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
if (value.transport == "remote" && !/^(https?:\/\/|\{\{\s*env\.)/.test(value.url)) {
|
||||||
|
ctx.addIssue({ code: "custom", path: ["url"], message: "Enter the server's http(s) URL." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.transport == "stdio" && !value.command) {
|
||||||
|
ctx.addIssue({ code: "custom", path: ["command"], message: "Enter the command that starts the server." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const McpServerIdRequestSchema = z.object({
|
||||||
|
mcp_server_id: Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SyncMcpServerRequestSchema = McpServerIdRequestSchema.extend({
|
||||||
|
/**
|
||||||
|
* Runs a stdio server's setup again first.
|
||||||
|
*/
|
||||||
|
reinstall: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SetExtensionAccessRequestSchema = z.object({
|
||||||
|
id: Id,
|
||||||
|
bot_ids: Ids,
|
||||||
|
workspace_ids: Ids,
|
||||||
|
all_bots: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tool's arguments: a JSON schema of an object.
|
||||||
|
*/
|
||||||
|
const ToolParameters = z
|
||||||
|
.record(z.string(), z.unknown())
|
||||||
|
.refine((value) => value.type == "object", { message: `The arguments schema must have "type": "object".` })
|
||||||
|
.refine((value) => JSON.stringify(value).length <= 20000, { message: "Keep the arguments schema under 20,000 characters." });
|
||||||
|
|
||||||
|
const ToolConfig = z.discriminatedUnion("kind", [
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("http"),
|
||||||
|
config: z.object({
|
||||||
|
method: z.enum(CustomToolHttpMethods),
|
||||||
|
url: z.string().trim().min(1, "Enter the URL").max(4000),
|
||||||
|
headers: TemplateMap,
|
||||||
|
body: z.string().max(20000).default(""),
|
||||||
|
timeout_ms: TimeoutMs,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("script"),
|
||||||
|
config: z.object({
|
||||||
|
language: z.enum(
|
||||||
|
CustomToolScriptLanguages.map((l) => l.value) as [CustomToolScriptLanguage, ...CustomToolScriptLanguage[]],
|
||||||
|
),
|
||||||
|
code: z.string().min(1, "Enter the code").max(60000, "Keep the code under 60,000 characters"),
|
||||||
|
timeout_ms: TimeoutMs,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("prompt"),
|
||||||
|
config: z.object({
|
||||||
|
prompt: z.string().trim().min(1, "Enter the prompt").max(20000),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SaveCustomToolRequestSchema = z
|
||||||
|
.object({
|
||||||
|
custom_tool_id: Id.optional(),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.superRefine((name, ctx) => {
|
||||||
|
const problem = checkCustomToolName({ name });
|
||||||
|
if (problem) ctx.addIssue({ code: "custom", message: problem });
|
||||||
|
}),
|
||||||
|
description: z.string().trim().min(1, "Describe what it does, for the bots").max(2000),
|
||||||
|
parameters: ToolParameters,
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
all_bots: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.and(ToolConfig);
|
||||||
|
|
||||||
|
export const CustomToolIdRequestSchema = z.object({
|
||||||
|
custom_tool_id: Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TestCustomToolRequestSchema = z.object({
|
||||||
|
custom_tool_id: Id,
|
||||||
|
args: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
/**
|
||||||
|
* Where a script tool runs. The main computer when not given.
|
||||||
|
*/
|
||||||
|
computer_id: Id.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
|
||||||
|
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
|
||||||
@@ -27,6 +27,12 @@ import type { HarnessProviderKind } from "../dict/harness-provider-dict";
|
|||||||
import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
|
import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
|
||||||
import type { ContainerStatus } from "../dict/computer-dict";
|
import type { ContainerStatus } from "../dict/computer-dict";
|
||||||
import type { BotFace } from "../dict/bot-dict";
|
import type { BotFace } from "../dict/bot-dict";
|
||||||
|
import type { McpServerStatus, McpTransport } from "../dict/mcp-dict";
|
||||||
|
import type {
|
||||||
|
CustomToolHttpMethod,
|
||||||
|
CustomToolKind,
|
||||||
|
CustomToolScriptLanguage,
|
||||||
|
} from "../dict/custom-tool-dict";
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -345,6 +351,141 @@ export type KnownEnvStatus = KnownEnv & {
|
|||||||
from_process: boolean;
|
from_process: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One tool an MCP server offers, as last listed (`mcp_servers.tools_cache`).
|
||||||
|
*/
|
||||||
|
export type McpToolInfo = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
input_schema: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who gets an MCP server or custom tool: every bot, or the bots and
|
||||||
|
* workspaces it's attached to.
|
||||||
|
*/
|
||||||
|
export type ExtensionAccess = {
|
||||||
|
all_bots: boolean;
|
||||||
|
bot_ids: number[];
|
||||||
|
workspace_ids: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `mcp_servers` row with its JSON read.
|
||||||
|
*/
|
||||||
|
export type McpServerRecord = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
transport: McpTransport;
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
command: string;
|
||||||
|
setup_command: string;
|
||||||
|
env: Record<string, string>;
|
||||||
|
excluded_tools: string[];
|
||||||
|
tools: McpToolInfo[];
|
||||||
|
all_bots: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
status: McpServerStatus;
|
||||||
|
last_error: string;
|
||||||
|
last_synced_at: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomToolHttpConfig = {
|
||||||
|
method: CustomToolHttpMethod;
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
timeout_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomToolScriptConfig = {
|
||||||
|
language: CustomToolScriptLanguage;
|
||||||
|
code: string;
|
||||||
|
timeout_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomToolPromptConfig = {
|
||||||
|
prompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `custom_tools` row with its JSON read, `config` matching its kind.
|
||||||
|
*/
|
||||||
|
export type CustomToolRecord = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
all_bots: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
} & (
|
||||||
|
| { kind: "http"; config: CustomToolHttpConfig }
|
||||||
|
| { kind: "script"; config: CustomToolScriptConfig }
|
||||||
|
| { kind: "prompt"; config: CustomToolPromptConfig }
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An MCP server as the MCPs settings page shows it. Only super admins get
|
||||||
|
* how it's reached (`url`, `headers`, `command`, `setup_command`, `env`).
|
||||||
|
*/
|
||||||
|
export type PublicMcpServer = Omit<
|
||||||
|
McpServerRecord,
|
||||||
|
"url" | "headers" | "command" | "setup_command" | "env" | "all_bots"
|
||||||
|
> & {
|
||||||
|
access: ExtensionAccess;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
command?: string;
|
||||||
|
setup_command?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom tool as the Tools settings page shows it. Only super admins get
|
||||||
|
* its `parameters` and `config`.
|
||||||
|
*/
|
||||||
|
export type PublicCustomTool = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
kind: CustomToolKind;
|
||||||
|
enabled: boolean;
|
||||||
|
access: ExtensionAccess;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
config?: CustomToolHttpConfig | CustomToolScriptConfig | CustomToolPromptConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bots and workspaces an MCP server or tool can be attached to. The
|
||||||
|
* coordinator is listed for the super admin only.
|
||||||
|
*/
|
||||||
|
export type ExtensionTargets = {
|
||||||
|
bots: { id: number; name: string; role: string; kind: string }[];
|
||||||
|
workspaces: { id: number; name: string }[];
|
||||||
|
/**
|
||||||
|
* A super admin: adds, edits and deletes, and sets "all bots".
|
||||||
|
*/
|
||||||
|
can_edit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type McpServersOverviewPayload = ExtensionTargets & {
|
||||||
|
servers: PublicMcpServer[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CustomToolsOverviewPayload = ExtensionTargets & {
|
||||||
|
tools: PublicCustomTool[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What trying a custom tool returned, as a bot would read it.
|
||||||
|
*/
|
||||||
|
export type CustomToolTestPayload = {
|
||||||
|
output: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type EnvsOverviewPayload = {
|
export type EnvsOverviewPayload = {
|
||||||
scopes: EnvScope[];
|
scopes: EnvScope[];
|
||||||
envs: PublicEnv[];
|
envs: PublicEnv[];
|
||||||
@@ -457,6 +598,8 @@ export type PagePropsType = {
|
|||||||
harness_overview?: HarnessOverviewPayload | null;
|
harness_overview?: HarnessOverviewPayload | null;
|
||||||
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
bot_harness_overview?: BotHarnessOverviewPayload | null;
|
||||||
envs_overview?: EnvsOverviewPayload | null;
|
envs_overview?: EnvsOverviewPayload | null;
|
||||||
|
mcps_overview?: McpServersOverviewPayload | null;
|
||||||
|
tools_overview?: CustomToolsOverviewPayload | null;
|
||||||
envs?: {
|
envs?: {
|
||||||
GOOGLE_CLIENT_ID?: string;
|
GOOGLE_CLIENT_ID?: string;
|
||||||
R2_PUBLIC_DOMAIN?: string;
|
R2_PUBLIC_DOMAIN?: string;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import botExtensionsSql from "./bot-extensions-sql";
|
||||||
|
|
||||||
|
const db = new Database(":memory:");
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled 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)`);
|
||||||
|
/**
|
||||||
|
* 1: every bot. 2: bot 5. 3: workspace 9. 4: every bot, off.
|
||||||
|
* 5: nobody.
|
||||||
|
*/
|
||||||
|
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1)`);
|
||||||
|
db.run(`INSERT INTO bot_custom_tools VALUES (5, 2)`);
|
||||||
|
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function ids(params: Parameters<typeof botExtensionsSql>[0]) {
|
||||||
|
const { sql, values } = botExtensionsSql(params);
|
||||||
|
return db
|
||||||
|
.query(sql)
|
||||||
|
.all(...values)
|
||||||
|
.map((row) => (row as { id: number }).id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("botExtensionsSql", () => {
|
||||||
|
test("a bot gets every-bot ones and its own, never disabled ones", () => {
|
||||||
|
expect(ids({ kind: "tool", bot_id: 5 })).toEqual([1, 2]);
|
||||||
|
expect(ids({ kind: "tool", bot_id: 6 })).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in a workspace, the workspace's too", () => {
|
||||||
|
expect(ids({ kind: "tool", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
|
||||||
|
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3]);
|
||||||
|
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* Which kind: MCP servers or custom tools.
|
||||||
|
*/
|
||||||
|
kind: "mcp" | "tool";
|
||||||
|
bot_id: number | string;
|
||||||
|
/**
|
||||||
|
* The workspace the run is in: what's attached to it counts too.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Tables = {
|
||||||
|
mcp: {
|
||||||
|
table: "mcp_servers",
|
||||||
|
bot_link: "bot_mcp_servers",
|
||||||
|
workspace_link: "workspace_mcp_servers",
|
||||||
|
column: "mcp_server_id",
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
table: "custom_tools",
|
||||||
|
bot_link: "bot_custom_tools",
|
||||||
|
workspace_link: "workspace_custom_tools",
|
||||||
|
column: "custom_tool_id",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The enabled MCP servers or custom tools a bot gets on one run: those
|
||||||
|
* for every bot, those attached to the bot, and in a workspace those
|
||||||
|
* attached to the workspace.
|
||||||
|
*/
|
||||||
|
export default function botExtensionsSql({ kind, bot_id, workspace_id }: Params) {
|
||||||
|
const { table, bot_link, workspace_link, column } = Tables[kind];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sql: `SELECT x.* FROM ${table} x WHERE x.enabled = 1 AND (
|
||||||
|
x.all_bots = 1
|
||||||
|
OR x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
|
||||||
|
${workspace_id ? `OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)` : ""}
|
||||||
|
) ORDER BY x.id ASC`,
|
||||||
|
values: workspace_id ? [Number(bot_id), Number(workspace_id)] : [Number(bot_id)],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ReservedToolNames, ReservedToolPrefixes } from "@/src/dict/custom-tool-dict";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a custom tool can't take this name, or null when it can: snake_case
|
||||||
|
* of 2 to 64 characters, never a built-in tool's name or a computer tool's
|
||||||
|
* prefix, and never with `__` (MCP tools use it, `<server>__<tool>`).
|
||||||
|
*/
|
||||||
|
export default function checkCustomToolName({ name }: Params): string | null {
|
||||||
|
if (!/^[a-z][a-z0-9_]{1,63}$/.test(name)) {
|
||||||
|
return `Use lowercase letters, numbers and underscores, starting with a letter (2 to 64 characters).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.includes("__")) return `Don't use a double underscore: MCP tools use it.`;
|
||||||
|
|
||||||
|
if ((ReservedToolNames as readonly string[]).includes(name)) {
|
||||||
|
return `${name} is a built-in tool's name.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = ReservedToolPrefixes.find((p) => name.startsWith(p));
|
||||||
|
|
||||||
|
if (prefix) return `Names starting with ${prefix} are kept for computers' tools.`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import checkCustomToolName from "./check-custom-tool-name";
|
||||||
|
import mcpServerSlug from "./mcp-server-slug";
|
||||||
|
import mcpToolName from "./mcp-tool-name";
|
||||||
|
|
||||||
|
describe("checkCustomToolName", () => {
|
||||||
|
test("takes snake_case", () => {
|
||||||
|
expect(checkCustomToolName({ name: "get_weather" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refuses built-ins, computer prefixes, double underscores and bad shapes", () => {
|
||||||
|
expect(checkCustomToolName({ name: "start_task" })).toContain("built-in");
|
||||||
|
expect(checkCustomToolName({ name: "sandbox_run" })).toContain("sandbox_");
|
||||||
|
expect(checkCustomToolName({ name: "a__b" })).toContain("double underscore");
|
||||||
|
expect(checkCustomToolName({ name: "Get-Weather" })).toContain("lowercase");
|
||||||
|
expect(checkCustomToolName({ name: "1abc" })).toContain("lowercase");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mcpToolName", () => {
|
||||||
|
test("prefixes with the slug, cleans and caps the name", () => {
|
||||||
|
expect(mcpToolName({ slug: "github", name: "create.issue" })).toBe("github__create_issue");
|
||||||
|
expect(mcpToolName({ slug: "s", name: "x".repeat(80) })).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mcpServerSlug", () => {
|
||||||
|
test("slugs a name", () => {
|
||||||
|
expect(mcpServerSlug({ name: "GitHub (work)" })).toBe("github_work");
|
||||||
|
expect(mcpServerSlug({ name: "!!!" })).toBe("mcp");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import fillTemplate from "./fill-template";
|
||||||
|
|
||||||
|
describe("fillTemplate", () => {
|
||||||
|
test("fills args and envs", () => {
|
||||||
|
expect(
|
||||||
|
fillTemplate({
|
||||||
|
template: "https://api.test/{{args.city}}?key={{ env.API_KEY }}",
|
||||||
|
args: { city: "Lagos" },
|
||||||
|
envs: [{ key: "API_KEY", value: "abc" }],
|
||||||
|
}),
|
||||||
|
).toBe("https://api.test/Lagos?key=abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("objects become JSON, missing args empty, dotted paths reach in", () => {
|
||||||
|
expect(
|
||||||
|
fillTemplate({
|
||||||
|
template: "{{args.filter}}|{{args.missing}}|{{args.filter.tag}}",
|
||||||
|
args: { filter: { tag: "x" } },
|
||||||
|
}),
|
||||||
|
).toBe('{"tag":"x"}||x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encodes filled arguments only", () => {
|
||||||
|
expect(
|
||||||
|
fillTemplate({
|
||||||
|
template: "{{env.BASE}}/q?s={{args.q}}&x=a b",
|
||||||
|
args: { q: "a b&c" },
|
||||||
|
envs: [{ key: "BASE", value: "https://x.test/v1" }],
|
||||||
|
encode: encodeURIComponent,
|
||||||
|
}),
|
||||||
|
).toBe("https://x.test/v1/q?s=a%20b%26c&x=a b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unset env throws, naming it", () => {
|
||||||
|
expect(() => fillTemplate({ template: "{{env.NOPE}}" })).toThrow("NOPE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves other braces alone", () => {
|
||||||
|
expect(fillTemplate({ template: '{"a": "{{other}}"}' })).toBe('{"a": "{{other}}"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
type Params = {
|
||||||
|
template: string;
|
||||||
|
/**
|
||||||
|
* `{{args.name}}`, dotted paths reach into objects. Missing ones fill
|
||||||
|
* in empty; objects and arrays as JSON.
|
||||||
|
*/
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* `{{env.KEY}}`. A key that isn't set throws, naming it.
|
||||||
|
*/
|
||||||
|
envs?: { key: string; value: string }[];
|
||||||
|
/**
|
||||||
|
* Applied to every filled-in argument, e.g. `encodeURIComponent` in a
|
||||||
|
* URL. Envs go in as they are (one may hold a whole base URL).
|
||||||
|
*/
|
||||||
|
encode?: (value: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Pattern = /\{\{\s*(args|env)\.([A-Za-z0-9_.-]+)\s*\}\}/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills a custom tool's or MCP server's template: `{{args.x}}` from the
|
||||||
|
* model's arguments, `{{env.KEY}}` from the envs. Anything else is left as
|
||||||
|
* written.
|
||||||
|
*/
|
||||||
|
export default function fillTemplate({ template, args = {}, envs = [], encode = (v) => v }: Params) {
|
||||||
|
return template.replace(Pattern, (_match, source: string, path: string) => {
|
||||||
|
if (source == "env") {
|
||||||
|
const env = envs.find((e) => e.key == path);
|
||||||
|
|
||||||
|
if (!env) throw new Error(`The env ${path} isn't set. Add it in Settings, under Environment.`);
|
||||||
|
|
||||||
|
return env.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return encode(stringify({ value: pick({ value: args, path }) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick({ value, path }: { value: unknown; path: string }) {
|
||||||
|
return path.split(".").reduce<unknown>((current, key) => {
|
||||||
|
if (current && typeof current == "object") return (current as Record<string, unknown>)[key];
|
||||||
|
return undefined;
|
||||||
|
}, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify({ value }: { value: unknown }) {
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
if (typeof value == "string") return value;
|
||||||
|
if (typeof value == "number" || typeof value == "boolean") return String(value);
|
||||||
|
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const AceScripts = [
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js",
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
let loading: Promise<void> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the ACE editor's scripts once per page, in order (the language
|
||||||
|
* tools need ACE itself), so only pages with an editor download them.
|
||||||
|
* twui's `AceEditor` waits for `window.ace` on its own.
|
||||||
|
*/
|
||||||
|
export default function loadAceScripts() {
|
||||||
|
if (typeof window == "undefined") return Promise.resolve();
|
||||||
|
|
||||||
|
loading ||= AceScripts.reduce(
|
||||||
|
(previous, src) =>
|
||||||
|
previous.then(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = src;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error(`Couldn't load ${src}`));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Promise.resolve(),
|
||||||
|
).catch((error) => {
|
||||||
|
loading = undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
type Params = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A server's slug from its name: lowercase letters, numbers and single
|
||||||
|
* underscores, at most 24 characters, so its tools' names stay short.
|
||||||
|
*/
|
||||||
|
export default function mcpServerSlug({ name }: Params) {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.slice(0, 24)
|
||||||
|
.replace(/_+$/g, "");
|
||||||
|
|
||||||
|
return slug || "mcp";
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The server's slug (`mcp_servers.slug`).
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
/**
|
||||||
|
* The tool's name on the server.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a bot calls an MCP server's tool: `<slug>__<tool>`, only letters,
|
||||||
|
* numbers, `_` and `-`, at most 64 characters (what providers accept).
|
||||||
|
*/
|
||||||
|
export default function mcpToolName({ slug, name }: Params) {
|
||||||
|
return `${slug}__${name.replace(/[^A-Za-z0-9_-]/g, "_")}`.slice(0, 64);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import parseTemplateLines, { templateLines } from "./parse-template-lines";
|
||||||
|
|
||||||
|
describe("parseTemplateLines", () => {
|
||||||
|
test("reads headers, keeping colons in values", () => {
|
||||||
|
expect(
|
||||||
|
parseTemplateLines({ text: "Authorization: Bearer {{env.T}}\n\nX-Url: http://a:1\nbad line", separator: ":" }),
|
||||||
|
).toEqual({ Authorization: "Bearer {{env.T}}", "X-Url": "http://a:1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads envs and writes them back", () => {
|
||||||
|
const map = parseTemplateLines({ text: "A=1\nB = x=y", separator: "=" });
|
||||||
|
expect(map).toEqual({ A: "1", B: "x=y" });
|
||||||
|
expect(templateLines({ map, separator: "=" })).toBe("A=1\nB=x=y");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
type Params = {
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* What separates a name from its value: `:` for headers, `=` for envs.
|
||||||
|
*/
|
||||||
|
separator: ":" | "=";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads one `Name: value` (or `NAME=value`) per line into a map, for the
|
||||||
|
* header and env fields of an MCP server or tool. Blank lines and lines
|
||||||
|
* without the separator are skipped; the value is everything after the
|
||||||
|
* first one, trimmed.
|
||||||
|
*/
|
||||||
|
export default function parseTemplateLines({ text, separator }: Params): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const at = line.indexOf(separator);
|
||||||
|
const name = at > 0 ? line.slice(0, at).trim() : "";
|
||||||
|
|
||||||
|
if (name) map[name] = line.slice(at + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other way: a map as one `Name: value` (or `NAME=value`) per line.
|
||||||
|
*/
|
||||||
|
export function templateLines({ map, separator }: { map?: Record<string, string>; separator: ":" | "=" }) {
|
||||||
|
return Object.entries(map || {})
|
||||||
|
.map(([name, value]) => `${name}${separator == ":" ? ": " : "="}${value}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user