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:
2026-10-19 15:01:39 +01:00
co-authored by Claude Opus 5.5
parent 550aaeabac
commit c6ec7d77a2
95 changed files with 4733 additions and 164 deletions
+44 -28
View File
@@ -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 runTextLoop from "@/src/harness/text/run-text-loop";
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 composeComputersNote from "./compose-computers-note";
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
* happened in the session before (`grabSessionCarryOver`) and its
* 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
* earlier reply answered everything.
*/
@@ -82,6 +85,14 @@ export default async function runBotReply({
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.
*/
@@ -92,33 +103,38 @@ export default async function runBotReply({
? CoordinatorReplyNote
: CoordinatorSoloNote;
const result = await runTextLoop({
system: [
prompt,
carry_over,
ChatReplyNote,
workspace_id ? WorkspaceLearningNote : "",
composeComputersNote({
computers,
is_coordinator: bot.kind == "coordinator",
in_workspace: Boolean(workspace_id),
try {
const result = await runTextLoop({
system: [
prompt,
carry_over,
ChatReplyNote,
workspace_id ? WorkspaceLearningNote : "",
composeComputersNote({
computers,
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,
`Now: ${new Date().toUTCString()}.`,
]
.filter(Boolean)
.join("\n\n"),
messages: botMessagesToHarness({
messages: history,
parts: await loadAttachmentParts({ messages: history }),
}),
tools,
max_steps: AppData.BotReplyMaxToolSteps,
bot_id: bot.id,
workspace_id,
signal,
onStep: report,
});
tools: [...tools, ...extensions.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();
}
}
+46 -30
View File
@@ -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 runTextLoop from "@/src/harness/text/run-text-loop";
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 createWorkspaceTool from "@/src/harness/tools/bot/create-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
* follows its brief with its full prompt,
* its own tools, search_computers across all its computers and, when the
* job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
* its own tools, search_computers across all its computers, its MCP
* 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
* take over (`ask_user_to_take_over`) and the report needs them.
* Every model turn is reported as a step (`report`).
@@ -53,6 +55,8 @@ export default async function runBotTask({
})
: null;
let extensions: OpenBotExtensions | undefined;
try {
const session_id = await jobSessionId({ job });
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),
];
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({
system: [
prompt,
@@ -104,6 +145,7 @@ export default async function runBotTask({
.map((env) => `- ${env.key}${env.description ? `: ${env.description}` : ""}`)
.join("\n")}`
: "",
extensions.note,
]
.filter(Boolean)
.join("\n\n"),
@@ -117,34 +159,7 @@ export default async function runBotTask({
}),
},
],
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 || []),
],
tools: [...tools, ...extensions.tools],
bot_id: bot.id,
workspace_id,
signal,
@@ -157,6 +172,7 @@ export default async function runBotTask({
refs: take_overs,
});
} finally {
await extensions?.close();
await computer?.close();
}
}
+61
View File
@@ -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 IconTile from "./icon-tile";
/**
* A computer's avatar in lists: the monitor icon on a small tile.
*/
export default function ComputerIconTile() {
return (
<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>
);
return <IconTile Icon={Monitor} />;
}
+17
View File
@@ -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>
);
}
+7
View File
@@ -3,6 +3,7 @@ import cronHeartbeat from "./heartbeat";
import pruneBotsQueue from "./prune-bots-queue";
import cronSyncHarnessModels from "./sync-harness-models";
import syncComputers from "./sync-computers";
import syncMcpServers from "./sync-mcp-servers";
import runCronJobs from "./run-cron-jobs";
import registerCronJob from "./register-cron-job";
@@ -40,6 +41,12 @@ export default function cron() {
run_on_start: true,
});
registerCronJob({
title: "sync-mcp-servers",
schedule: "@hourly",
task: syncMcpServers,
});
registerCronJob({
title: "run-cron-jobs",
schedule: "* * * * *",
+21
View File
@@ -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 });
}
}
+16
View File
@@ -181,6 +181,22 @@ export const AppData = {
* One MCP tool call gives up after this long without progress.
*/
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,
* and how much of each computer's output the bot reads.
+66
View File
@@ -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;
+39
View File
@@ -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 -1
View File
@@ -2,7 +2,7 @@ import type { TableType } from "@/src/types";
/**
* 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.
*/
export const PrivateTables: TableType[] = [
@@ -10,4 +10,5 @@ export const PrivateTables: TableType[] = [
"sso_login_codes",
"harness_providers",
"envs",
"mcp_servers",
];
+4
View File
@@ -4,10 +4,12 @@ import {
KeyRound,
Layers,
Monitor,
Plug,
Users,
SlidersHorizontal,
UserRound,
Variable,
Wrench,
type LucideIcon,
} from "lucide-react";
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: "Workspaces", url: "/admin/settings/workspaces", Icon: Layers, 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",
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)}`;
}
}
+22
View File
@@ -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;
}
+23
View File
@@ -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 })]),
),
};
}
+8 -49
View File
@@ -1,7 +1,8 @@
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";
import listMcpTools from "./list-mcp-tools";
import mcpHarnessTool from "./mcp-harness-tool";
import type { McpImage } from "./mcp-result-to-text";
type Params = {
client: Client;
@@ -18,57 +19,15 @@ type Params = {
};
/**
* Every tool an MCP server offers, as harness tools. 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`.
* Every tool an MCP server offers, as harness tools (`mcpHarnessTool`).
*/
export default async function grabMcpTools({
client,
exclude = [],
describeImage,
redact = (text) => text,
redact,
}: Params): Promise<HarnessTool[]> {
const tools: HarnessTool[] = [];
let cursor: string | undefined;
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;
return (await listMcpTools({ client }))
.filter((tool) => !exclude.includes(tool.name))
.map((tool) => mcpHarnessTool({ tool, client, describeImage, redact }));
}
+7
View File
@@ -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";
}
+33
View File
@@ -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;
}
+59
View File
@@ -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;
}
},
};
}
+140
View File
@@ -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 {}
}
}
});
+48
View File
@@ -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 };
}
+64
View File
@@ -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(", ")}.`);
}
}
+58
View File
@@ -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 Form from "@moduletrace/twui/form/Form";
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 WorkspaceAvatar from "@/src/components/general/workspace-avatar";
import setProfileImage from "@/src/functions/frontend/set-profile-image";
@@ -82,16 +82,14 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
defaultValue={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Textarea
id="add-workspace-instructions"
<CodeEditor
mode="markdown"
className="h-32"
title="Context"
label="Context"
showLabel
rows={5}
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."
onChange={(e) => setSystemPrompt(e.target.value)}
onChange={(value) => setSystemPrompt(value)}
/>
<AddFormActions submit_label="Create workspace" loading={loading} error={error} onCancel={onCancel} />
</Form>
+6 -8
View File
@@ -1,8 +1,8 @@
import { useContext, useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
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 ImagePickField from "@/src/components/general/image-pick-field";
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} />
</>
)}
<Textarea
id="bot-form-instructions"
<CodeEditor
mode="markdown"
className="h-40"
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."
defaultValue={system_prompt}
content={system_prompt}
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 ? (
<Checkbox
@@ -1,7 +1,7 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
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 AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
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."
onChange={(e) => setSchedule(e.target.value)}
/>
<Textarea
id="routine-instructions"
<CodeEditor
mode="markdown"
className="h-40"
title="Brief"
label="Brief"
showLabel
rows={6}
defaultValue={instructions}
content={instructions}
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} />
</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 }),
},
};
};
+13
View File
@@ -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 }),
},
};
};
+13
View File
@@ -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" });
@@ -1,6 +1,6 @@
import { useState } from "react";
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 postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
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.`}
>
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
<Textarea
id="workspace-bot-instructions"
<CodeEditor
mode="markdown"
className="h-44"
title="Instructions here"
label="Instructions here"
showLabel
rows={7}
defaultValue={system_prompt}
content={system_prompt}
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
onChange={(e) => setSystemPrompt(e.target.value)}
onChange={(value) => setSystemPrompt(value)}
/>
<Textarea
id="workspace-bot-memory"
<CodeEditor
mode="markdown"
className="h-32"
title="Memory here"
label="Memory here"
showLabel
rows={5}
defaultValue={memory}
content={memory}
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} />
</Form>
@@ -1,9 +1,9 @@
import { useState } from "react";
import Form from "@moduletrace/twui/form/Form";
import Input from "@moduletrace/twui/form/Input";
import Textarea from "@moduletrace/twui/form/Textarea";
import Button from "@moduletrace/twui/layout/Button";
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 postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
@@ -80,15 +80,13 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
defaultValue={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Textarea
id="workspace-context"
<CodeEditor
mode="markdown"
className="h-52"
title="Context"
label="Context"
showLabel
rows={8}
defaultValue={system_prompt}
content={system_prompt}
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">
<SettingsStatus status={status} />
+52
View File
@@ -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 }) };
}
};
+59
View File
@@ -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 }) };
}
};
+57
View File
@@ -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 }) };
}
};
+52
View File
@@ -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 }) };
}
};
+52
View File
@@ -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 }) };
}
};
+48
View File
@@ -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 }) };
}
};
+134
View File
@@ -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>;
+143
View File
@@ -27,6 +27,12 @@ import type { HarnessProviderKind } from "../dict/harness-provider-dict";
import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
import type { ContainerStatus } from "../dict/computer-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 = {
id: number;
@@ -345,6 +351,141 @@ export type KnownEnvStatus = KnownEnv & {
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 = {
scopes: EnvScope[];
envs: PublicEnv[];
@@ -457,6 +598,8 @@ export type PagePropsType = {
harness_overview?: HarnessOverviewPayload | null;
bot_harness_overview?: BotHarnessOverviewPayload | null;
envs_overview?: EnvsOverviewPayload | null;
mcps_overview?: McpServersOverviewPayload | null;
tools_overview?: CustomToolsOverviewPayload | null;
envs?: {
GOOGLE_CLIENT_ID?: string;
R2_PUBLIC_DOMAIN?: string;
+39
View File
@@ -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]);
});
});
+44
View File
@@ -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)],
};
}
+28
View File
@@ -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;
}
+32
View File
@@ -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");
});
});
+42
View File
@@ -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}}"}');
});
});
+53
View File
@@ -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);
}
+37
View File
@@ -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;
}
+18
View File
@@ -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";
}
+18
View File
@@ -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);
}
+16
View File
@@ -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");
});
});
+35
View File
@@ -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");
}