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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
Icon: LucideIcon;
|
||||
};
|
||||
|
||||
/**
|
||||
* An avatar in lists for what has no picture: its icon on a small tile.
|
||||
*/
|
||||
export default function IconTile({ Icon }: Props) {
|
||||
return (
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
|
||||
<Icon size={16} aria-hidden />
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import cronHeartbeat from "./heartbeat";
|
||||
import pruneBotsQueue from "./prune-bots-queue";
|
||||
import 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: "* * * * *",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||
|
||||
/**
|
||||
* Lists every enabled remote MCP server's tools again (`syncMcpServer`),
|
||||
* so bots get tools the server added and lose ones it dropped. stdio
|
||||
* servers are left alone: listing them means starting them, and they're
|
||||
* refreshed when saved or from Settings, under MCPs.
|
||||
*/
|
||||
export default async function syncMcpServers() {
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `SELECT id FROM mcp_servers WHERE enabled = 1 AND transport = 'remote'`,
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
for (const row of rows) {
|
||||
await syncMcpServer({ mcp_server_id: row.id });
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,22 @@ export const AppData = {
|
||||
* One MCP tool call gives up after this long without progress.
|
||||
*/
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* What a custom tool does when a bot calls it. `http` calls an API from the
|
||||
* app, `script` runs code on the run's computer, `prompt` runs one model
|
||||
* turn with a filled-in prompt.
|
||||
*/
|
||||
export const CustomToolKinds = [
|
||||
{
|
||||
title: "HTTP request",
|
||||
value: "http",
|
||||
description: "Calls an API from the app and returns the response.",
|
||||
},
|
||||
{
|
||||
title: "Script on a computer",
|
||||
value: "script",
|
||||
description: "Runs code on the bot's computer and returns what it prints.",
|
||||
},
|
||||
{
|
||||
title: "Prompt",
|
||||
value: "prompt",
|
||||
description: "Runs one model turn with a filled-in prompt and returns the answer.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type CustomToolKind = (typeof CustomToolKinds)[number]["value"];
|
||||
|
||||
export const CustomToolHttpMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
|
||||
export type CustomToolHttpMethod = (typeof CustomToolHttpMethods)[number];
|
||||
|
||||
/**
|
||||
* Languages a script tool runs in, with the interpreter on the computer
|
||||
* and the code editor's mode.
|
||||
*/
|
||||
export const CustomToolScriptLanguages = [
|
||||
{ title: "Bash", value: "bash", interpreter: "bash", extension: "sh", editor_mode: "sh" },
|
||||
{ title: "Python", value: "python", interpreter: "python3", extension: "py", editor_mode: "python" },
|
||||
{ title: "JavaScript", value: "javascript", interpreter: "node", extension: "js", editor_mode: "javascript" },
|
||||
] as const;
|
||||
|
||||
export type CustomToolScriptLanguage = (typeof CustomToolScriptLanguages)[number]["value"];
|
||||
|
||||
/**
|
||||
* Names the built-in bot tools use. A custom tool can't take one, and
|
||||
* names starting with a computer's tool prefixes are kept for them too.
|
||||
*/
|
||||
export const ReservedToolNames = [
|
||||
"add_bot_to_workspace",
|
||||
"ask_user_to_take_over",
|
||||
"assign_computer",
|
||||
"create_bot",
|
||||
"create_schedule",
|
||||
"create_workspace",
|
||||
"delegate_task",
|
||||
"delete_schedule",
|
||||
"list_schedules",
|
||||
"search_computers",
|
||||
"set_bot_workspace_instructions",
|
||||
"set_workspace_computer",
|
||||
"start_task",
|
||||
"update_instructions",
|
||||
"update_memory",
|
||||
"update_schedule",
|
||||
"update_workspace",
|
||||
] as const;
|
||||
|
||||
export const ReservedToolPrefixes = ["sandbox_", "browser_"] as const;
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* How the app reaches an MCP server. `remote`: over HTTP (Streamable HTTP,
|
||||
* SSE fallback). `stdio`: a command the app starts on its own machine (the
|
||||
* host in dev, the app's container in production) and talks to over its
|
||||
* stdin and stdout.
|
||||
*/
|
||||
export const McpTransports = [
|
||||
{
|
||||
title: "Remote (URL)",
|
||||
value: "remote",
|
||||
},
|
||||
{
|
||||
title: "Command on this server (stdio)",
|
||||
value: "stdio",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type McpTransport = (typeof McpTransports)[number]["value"];
|
||||
|
||||
/**
|
||||
* The last time the app listed a server's tools: `ok`, `error` (see its
|
||||
* `last_error`), or `untested` before the first time.
|
||||
*/
|
||||
export const McpServerStatuses = [
|
||||
{
|
||||
title: "Untested",
|
||||
value: "untested",
|
||||
},
|
||||
{
|
||||
title: "Connected",
|
||||
value: "ok",
|
||||
},
|
||||
{
|
||||
title: "Error",
|
||||
value: "error",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type McpServerStatus = (typeof McpServerStatuses)[number]["value"];
|
||||
@@ -2,7 +2,7 @@ import type { TableType } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Tables holding secrets (password hashes, pending login codes, model
|
||||
* 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,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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabDirNames from "@/src/utils/grab-dir-names";
|
||||
|
||||
type Params = {
|
||||
server_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a stdio MCP server runs and its setup installs:
|
||||
* `DATA_DIR/<AppData.McpDataDir>/<id>`, created when missing. On the data
|
||||
* volume in production, so installs survive app updates.
|
||||
*/
|
||||
export default async function grabMcpDir({ server_id }: Params) {
|
||||
const { DATA_DIR } = grabDirNames();
|
||||
const dir = path.join(DATA_DIR, AppData.McpDataDir, String(server_id));
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
return dir;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import type { McpServerRecord } from "@/src/types";
|
||||
import fillTemplate from "@/src/utils/fill-template";
|
||||
|
||||
type Params = {
|
||||
server: McpServerRecord;
|
||||
envs: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The only environment a stdio MCP server (and its setup) gets: the
|
||||
* basics a shell needs (`PATH`, `HOME`, `SHELL`, `USER`, ...) plus its own
|
||||
* `env`, filled from the envs. Never the app's own environment, which
|
||||
* holds its encryption key.
|
||||
*/
|
||||
export default function grabMcpStdioEnv({ server, envs }: Params): Record<string, string> {
|
||||
return {
|
||||
...getDefaultEnvironment(),
|
||||
...Object.fromEntries(
|
||||
Object.entries(server.env).map(([key, value]) => [key, fillTemplate({ template: value, envs })]),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { 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 }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The host's default shell, which runs stdio MCP servers and their setup
|
||||
* (`<shell> -c <command>`).
|
||||
*/
|
||||
export default function hostShell() {
|
||||
return process.env.SHELL || "/bin/bash";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { McpToolInfo } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
client: Client;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tool an MCP server offers, page by page.
|
||||
*/
|
||||
export default async function listMcpTools({ client }: Params): Promise<McpToolInfo[]> {
|
||||
const tools: McpToolInfo[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : undefined, {
|
||||
timeout: AppData.McpConnectTimeoutMs,
|
||||
});
|
||||
|
||||
for (const tool of page.tools) {
|
||||
tools.push({
|
||||
name: tool.name,
|
||||
description: tool.description || tool.title || tool.name,
|
||||
input_schema: tool.inputSchema as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
return tools;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { HarnessTool } from "../types";
|
||||
import mcpResultToText, { type McpImage } from "./mcp-result-to-text";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* What the model calls it. Defaults to the server's name for it.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* The tool as the server lists it.
|
||||
*/
|
||||
tool: { name: string; description?: string; input_schema: Record<string, unknown> };
|
||||
/**
|
||||
* The connected client, or how to get it on the first call.
|
||||
*/
|
||||
client: Client | (() => Promise<Client>);
|
||||
describeImage?: (image: McpImage) => Promise<string>;
|
||||
redact?: (text: string) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One MCP server tool as a harness tool. Arguments go to the server as the
|
||||
* model wrote them (the server checks them against its own schema);
|
||||
* results come back as text via `mcpResultToText`, redacted.
|
||||
*/
|
||||
export default function mcpHarnessTool({
|
||||
name,
|
||||
tool,
|
||||
client,
|
||||
describeImage,
|
||||
redact = (text) => text,
|
||||
}: Params): HarnessTool {
|
||||
return {
|
||||
name: name || tool.name,
|
||||
description: tool.description || tool.name,
|
||||
parameters: tool.input_schema,
|
||||
run: async ({ args, signal }) => {
|
||||
try {
|
||||
const connected = typeof client == "function" ? await client() : client;
|
||||
|
||||
return redact(
|
||||
await mcpResultToText({
|
||||
result: await connected.callTool({ name: tool.name, arguments: args }, undefined, {
|
||||
signal,
|
||||
timeout: AppData.McpToolTimeoutMs,
|
||||
resetTimeoutOnProgress: true,
|
||||
}),
|
||||
describeImage,
|
||||
}),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) error.message = redact(error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import type { McpServerRecord } from "@/src/types";
|
||||
import grabMcpDir from "./grab-mcp-dir";
|
||||
import grabMcpStdioEnv from "./grab-mcp-stdio-env";
|
||||
import hostShell from "./host-shell";
|
||||
import runMcpSetup from "./run-mcp-setup";
|
||||
|
||||
type Entry = {
|
||||
/**
|
||||
* The command, setup and environment it was started with: a change
|
||||
* restarts it.
|
||||
*/
|
||||
key: string;
|
||||
client: Promise<Client>;
|
||||
transport?: StdioClientTransport;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The stdio MCP servers running in this process, one per server, shared by
|
||||
* every run.
|
||||
*/
|
||||
const pool = new Map<number, Entry>();
|
||||
|
||||
type Params = {
|
||||
server: McpServerRecord;
|
||||
envs: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A connected client for a stdio MCP server, started on first use (after
|
||||
* its setup, `runMcpSetup`) with the host's shell in its directory, and
|
||||
* kept running for later calls until it's idle for `AppData.McpStdioIdleMs`,
|
||||
* its configuration changes, or it exits. Its stderr's last lines explain
|
||||
* a failed start.
|
||||
*/
|
||||
export default async function grabStdioMcpClient({ server, envs }: Params): Promise<Client> {
|
||||
const env = grabMcpStdioEnv({ server, envs });
|
||||
const key = String(Bun.hash(JSON.stringify([server.command, server.setup_command, env])));
|
||||
const existing = pool.get(server.id);
|
||||
|
||||
if (existing?.key == key) {
|
||||
touch({ server_id: server.id, entry: existing });
|
||||
return existing.client;
|
||||
}
|
||||
|
||||
if (existing) stopStdioMcpServer({ server_id: server.id });
|
||||
|
||||
const entry = { key } as Entry;
|
||||
|
||||
entry.client = start({ server, env, entry }).catch((error) => {
|
||||
if (pool.get(server.id) == entry) pool.delete(server.id);
|
||||
throw error;
|
||||
});
|
||||
|
||||
pool.set(server.id, entry);
|
||||
touch({ server_id: server.id, entry });
|
||||
|
||||
return entry.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a stdio MCP server running in this process, if it is (on a
|
||||
* change, a delete, or idle).
|
||||
*/
|
||||
export function stopStdioMcpServer({ server_id }: { server_id: number }) {
|
||||
const entry = pool.get(server_id);
|
||||
|
||||
if (!entry) return;
|
||||
|
||||
pool.delete(server_id);
|
||||
clearTimeout(entry.timer);
|
||||
entry.client.then((client) => client.close()).catch(() => {});
|
||||
}
|
||||
|
||||
async function start({ server, env, entry }: { server: McpServerRecord; env: Record<string, string>; entry: Entry }) {
|
||||
await runMcpSetup({ server, env });
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: hostShell(),
|
||||
args: ["-c", server.command],
|
||||
cwd: await grabMcpDir({ server_id: server.id }),
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
|
||||
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr = `${stderr}${chunk.toString()}`.slice(-4000);
|
||||
});
|
||||
|
||||
entry.transport = transport;
|
||||
|
||||
const client = new Client({ name: SiteData["SiteSlug"], version: "1.0.0" });
|
||||
|
||||
client.onclose = () => {
|
||||
if (pool.get(server.id) == entry) {
|
||||
pool.delete(server.id);
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await client.connect(transport, { timeout: AppData.McpConnectTimeoutMs * 4 });
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
throw new Error(
|
||||
`Couldn't start ${server.name}: ${error instanceof Error ? error.message : String(error)}${
|
||||
stderr.trim() ? `\n${stderr.trim().slice(-2000)}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function touch({ server_id, entry }: { server_id: number; entry: Entry }) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = setTimeout(() => {
|
||||
if (pool.get(server_id) == entry) stopStdioMcpServer({ server_id });
|
||||
}, AppData.McpStdioIdleMs);
|
||||
entry.timer.unref?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Children go with the app.
|
||||
*/
|
||||
process.once("exit", () => {
|
||||
for (const entry of pool.values()) {
|
||||
const pid = entry.transport?.pid;
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import type { McpServerRecord } from "@/src/types";
|
||||
import fillTemplate from "@/src/utils/fill-template";
|
||||
import connectMcpServer from "./connect-mcp-server";
|
||||
import grabStdioMcpClient from "./mcp-stdio-pool";
|
||||
|
||||
type Params = {
|
||||
server: McpServerRecord;
|
||||
/**
|
||||
* What its templates (`{{env.KEY}}`) read.
|
||||
*/
|
||||
envs: { key: string; value: string }[];
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* How one run reaches an MCP server, without connecting yet: `client()`
|
||||
* connects on the first call and is reused after. A remote server gets
|
||||
* its own connection for the run (closed by `close`); a stdio server is
|
||||
* shared through the process's pool and keeps running.
|
||||
*/
|
||||
export default function openMcpServer({ server, envs, signal }: Params) {
|
||||
let remote: Promise<Client> | undefined;
|
||||
|
||||
const client = () => {
|
||||
if (server.transport == "stdio") return grabStdioMcpClient({ server, envs });
|
||||
|
||||
remote ||= connectMcpServer({
|
||||
url: fillTemplate({ template: server.url, envs }),
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(server.headers).map(([key, value]) => [key, fillTemplate({ template: value, envs })]),
|
||||
),
|
||||
signal,
|
||||
}).catch((error) => {
|
||||
remote = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return remote;
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
if (!remote) return;
|
||||
await (await remote.catch(() => null))?.close().catch(() => {});
|
||||
};
|
||||
|
||||
return { client, close };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readdir, rm, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { McpServerRecord } from "@/src/types";
|
||||
import grabMcpDir from "./grab-mcp-dir";
|
||||
import hostShell from "./host-shell";
|
||||
|
||||
type Params = {
|
||||
server: McpServerRecord;
|
||||
/**
|
||||
* Its environment (`grabMcpStdioEnv`).
|
||||
*/
|
||||
env: Record<string, string>;
|
||||
/**
|
||||
* Runs it even when this setup already ran.
|
||||
*/
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
const MarkerPrefix = ".cobble-setup-";
|
||||
|
||||
/**
|
||||
* Runs a stdio MCP server's setup command with the host's shell in its
|
||||
* directory, once per setup command: a marker file named after it is left
|
||||
* behind, so it runs again when the command changes, when `force` is set,
|
||||
* or when the directory was cleared. The output goes to `setup.log`; a
|
||||
* failure throws with its last lines.
|
||||
*/
|
||||
export default async function runMcpSetup({ server, env, force }: Params) {
|
||||
const command = server.setup_command.trim();
|
||||
|
||||
if (!command) return;
|
||||
|
||||
const dir = await grabMcpDir({ server_id: server.id });
|
||||
const marker = `${MarkerPrefix}${Bun.hash(command).toString(36)}`;
|
||||
const existing = (await readdir(dir)).filter((file) => file.startsWith(MarkerPrefix));
|
||||
|
||||
if (!force && existing.includes(marker)) return;
|
||||
|
||||
await Promise.all(existing.map((file) => rm(path.join(dir, file), { force: true })));
|
||||
|
||||
const proc = Bun.spawn([hostShell(), "-c", command], {
|
||||
cwd: dir,
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
timeout: AppData.McpSetupTimeoutMs,
|
||||
});
|
||||
|
||||
const [stdout, stderr, exit_code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
const output = `${stdout}${stderr ? `\n${stderr}` : ""}`.trim();
|
||||
|
||||
await writeFile(path.join(dir, "setup.log"), `$ ${command}\n${output}\nExit code: ${exit_code}\n`);
|
||||
|
||||
if (exit_code !== 0) {
|
||||
throw new Error(`The setup command failed (exit code ${exit_code}): ${output.slice(-2000) || "no output"}`);
|
||||
}
|
||||
|
||||
await writeFile(path.join(dir, marker), command);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CustomToolRecord } from "@/src/types";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import checkRequiredArgs from "./check-required-args";
|
||||
import runHttpTool from "./run-http-tool";
|
||||
import runPromptTool from "./run-prompt-tool";
|
||||
import runScriptTool from "./run-script-tool";
|
||||
|
||||
type Params = {
|
||||
tool: CustomToolRecord;
|
||||
/**
|
||||
* What `{{env.KEY}}` reads (`grabRunEnvs`). Secret values are blanked
|
||||
* out of every result and error.
|
||||
*/
|
||||
envs: { key: string; value: string; secret: boolean }[];
|
||||
/**
|
||||
* Where a script tool runs. Without one, a script tool isn't offered.
|
||||
*/
|
||||
computer_id?: number | string | null;
|
||||
bot_id?: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom tool as a harness tool: its name, description and JSON schema
|
||||
* as the model sees them, and `run` dispatching on its kind (an HTTP
|
||||
* request, a script on the computer, or a prompt). Null for a script tool
|
||||
* when the run has no computer.
|
||||
*/
|
||||
export default function buildCustomTool({
|
||||
tool,
|
||||
envs,
|
||||
computer_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): HarnessTool<Record<string, unknown>> | null {
|
||||
if (tool.kind == "script" && !computer_id) return null;
|
||||
|
||||
const secrets = envs.filter((env) => env.secret);
|
||||
const redact = (text: string) => redactSecrets({ text, secrets });
|
||||
|
||||
const call = ({ args, signal }: { args: Record<string, unknown>; signal: AbortSignal }) => {
|
||||
switch (tool.kind) {
|
||||
case "http":
|
||||
return runHttpTool({ config: tool.config, args, envs, signal });
|
||||
case "script":
|
||||
return runScriptTool({ config: tool.config, args, computer_id: computer_id!, workspace_id, signal });
|
||||
case "prompt":
|
||||
return runPromptTool({ config: tool.config, args, envs, bot_id, workspace_id, signal });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: tool.name,
|
||||
description:
|
||||
tool.kind == "script"
|
||||
? `${tool.description}\n(Runs on your current computer.)`
|
||||
: tool.description,
|
||||
parameters: tool.parameters,
|
||||
run: async ({ args, signal }) => {
|
||||
try {
|
||||
checkRequiredArgs({ parameters: tool.parameters, args });
|
||||
return redact(await call({ args, signal }));
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) error.message = redact(error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The tool's JSON schema.
|
||||
*/
|
||||
parameters: Record<string, unknown>;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws when the model left out an argument the tool's schema requires.
|
||||
* The rest of the schema is the model's to follow: custom tools fill
|
||||
* templates, so a wrong type does no harm the call wouldn't show.
|
||||
*/
|
||||
export default function checkRequiredArgs({ parameters, args }: Params) {
|
||||
const required = Array.isArray(parameters.required) ? parameters.required.map(String) : [];
|
||||
const missing = required.filter((key) => args[key] === undefined || args[key] === null || args[key] === "");
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`Missing required arguments: ${missing.join(", ")}.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { CustomToolHttpConfig } from "@/src/types";
|
||||
import fillTemplate from "@/src/utils/fill-template";
|
||||
|
||||
type Params = {
|
||||
config: CustomToolHttpConfig;
|
||||
args: Record<string, unknown>;
|
||||
envs: { key: string; value: string }[];
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls the tool's API from the app: URL (arguments URL-encoded), headers
|
||||
* and body filled in from the arguments and envs. With no body template,
|
||||
* anything but GET sends the arguments as JSON. Returns the response (JSON
|
||||
* pretty-printed); a non-2xx answer throws with its status and body.
|
||||
*/
|
||||
export default async function runHttpTool({ config, args, envs, signal }: Params) {
|
||||
const fill = (template: string, encode?: (value: string) => string) =>
|
||||
fillTemplate({ template, args, envs, encode });
|
||||
|
||||
const method = config.method || "GET";
|
||||
const headers = new Headers(
|
||||
Object.entries(config.headers || {}).map(([key, value]) => [key, fill(value)] as [string, string]),
|
||||
);
|
||||
const body =
|
||||
method == "GET" ? undefined : config.body?.trim() ? fill(config.body) : JSON.stringify(args);
|
||||
|
||||
if (body && !headers.has("content-type")) headers.set("content-type", "application/json");
|
||||
|
||||
const res = await fetch(fill(config.url, encodeURIComponent), {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.any([
|
||||
signal,
|
||||
AbortSignal.timeout(
|
||||
Math.min(config.timeout_ms || AppData.CustomToolDefaultTimeoutMs, AppData.CustomToolMaxTimeoutMs),
|
||||
),
|
||||
]),
|
||||
});
|
||||
|
||||
const text = prettify({ text: await res.text() });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text.slice(0, 4000)}` : ""}`);
|
||||
}
|
||||
|
||||
return text || `HTTP ${res.status}, no body.`;
|
||||
}
|
||||
|
||||
function prettify({ text }: { text: string }) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CustomToolPromptConfig } from "@/src/types";
|
||||
import fillTemplate from "@/src/utils/fill-template";
|
||||
import generateText from "../../text/generate-text";
|
||||
|
||||
type Params = {
|
||||
config: CustomToolPromptConfig;
|
||||
args: Record<string, unknown>;
|
||||
envs: { key: string; value: string }[];
|
||||
/**
|
||||
* Its models are used, like the run's.
|
||||
*/
|
||||
bot_id?: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills the tool's prompt in and answers it with one text model turn (no
|
||||
* tools). Returns the answer.
|
||||
*/
|
||||
export default async function runPromptTool({ config, args, envs, bot_id, workspace_id, signal }: Params) {
|
||||
const result = await generateText({
|
||||
messages: [{ role: "user", content: fillTemplate({ template: config.prompt || "", args, envs }) }],
|
||||
bot_id,
|
||||
workspace_id,
|
||||
signal,
|
||||
});
|
||||
|
||||
return result.text || "(no answer)";
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { CustomToolScriptLanguages } from "@/src/dict/custom-tool-dict";
|
||||
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
|
||||
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
|
||||
import type { CustomToolScriptConfig } from "@/src/types";
|
||||
import computerNotReadyMessage from "@/src/utils/computer-not-ready-message";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import runComputerCommand from "../computer/run-computer-command";
|
||||
import writeComputerEnv from "../computer/write-computer-env";
|
||||
|
||||
type Params = {
|
||||
config: CustomToolScriptConfig;
|
||||
args: Record<string, unknown>;
|
||||
computer_id: number | string;
|
||||
/**
|
||||
* The workspace the run is in: its envs apply on the computer.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the tool's code on a computer over the sandbox's REST API, with the
|
||||
* computer's envs set (`writeComputerEnv`) and the arguments as JSON in
|
||||
* `COBBLE_ARGS` and on stdin. Code and arguments travel base64-encoded,
|
||||
* so nothing in them needs quoting. Returns what it printed (stdout and
|
||||
* stderr), secrets redacted; a non-zero exit throws with it.
|
||||
*/
|
||||
export default async function runScriptTool({ config, args, computer_id, workspace_id, signal }: Params) {
|
||||
const target = await grabComputerMcpUrl({ computer_id });
|
||||
|
||||
if (!target) throw new Error(`Computer #${computer_id} has no container to run on.`);
|
||||
if (!target.ready) throw new Error(computerNotReadyMessage(target));
|
||||
|
||||
const envs = await grabComputerEnvs({ computer_id, workspace_id });
|
||||
const { shell_path } = await writeComputerEnv({ base_url: target.base_url, envs, signal });
|
||||
const language =
|
||||
CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0];
|
||||
|
||||
const code = Buffer.from(config.code || "").toString("base64");
|
||||
const input = Buffer.from(JSON.stringify(args)).toString("base64");
|
||||
|
||||
const result = await runComputerCommand({
|
||||
base_url: target.base_url,
|
||||
/**
|
||||
* In a subshell: `exit` in the sandbox's own shell would end its
|
||||
* session.
|
||||
*/
|
||||
command: `(${[
|
||||
`f=$(mktemp --suffix=.${language.extension})`,
|
||||
`echo ${code} | base64 -d > "$f"`,
|
||||
`[ -f ${shell_path} ] && . ${shell_path}`,
|
||||
`export COBBLE_ARGS="$(echo ${input} | base64 -d)"`,
|
||||
`printf '%s' "$COBBLE_ARGS" | ${language.interpreter} "$f" 2>&1`,
|
||||
`status=$?`,
|
||||
`rm -f "$f"`,
|
||||
`exit $status`,
|
||||
].join("; ")})`,
|
||||
timeout_ms: Math.min(config.timeout_ms || AppData.CustomToolDefaultTimeoutMs, AppData.CustomToolMaxTimeoutMs),
|
||||
signal,
|
||||
});
|
||||
|
||||
const output = redactSecrets({
|
||||
text: result.output.trim(),
|
||||
secrets: envs.filter((env) => env.secret),
|
||||
});
|
||||
|
||||
if (result.exit_code) {
|
||||
throw new Error(`Exit code ${result.exit_code}${output ? `:\n${output}` : "."}`);
|
||||
}
|
||||
|
||||
return output || "(no output)";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import grabBotExtensions from "@/src/functions/backend/db/extensions/grab-bot-extensions";
|
||||
import grabRunEnvs from "@/src/functions/backend/db/extensions/grab-run-envs";
|
||||
import mcpToolName from "@/src/utils/mcp-tool-name";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import mcpHarnessTool from "../../mcp/mcp-harness-tool";
|
||||
import openMcpServer from "../../mcp/open-mcp-server";
|
||||
import readImage from "../../media/read-image";
|
||||
import type { HarnessTool } from "../../types";
|
||||
import buildCustomTool from "../custom/build-custom-tool";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Where script tools run: the task's computer, or in chat the bot's
|
||||
* current one. None leaves script tools out.
|
||||
*/
|
||||
computer_id?: number | string | null;
|
||||
/**
|
||||
* Names the run's built-in tools already use: they win.
|
||||
*/
|
||||
taken?: string[];
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type OpenBotExtensions = {
|
||||
tools: HarnessTool[];
|
||||
/**
|
||||
* For the system prompt, only when something's wrong (an MCP server
|
||||
* with no tools listed), so the bot can say so. Empty otherwise.
|
||||
*/
|
||||
note: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The MCP servers' and custom tools' tools a bot gets on one run
|
||||
* (`grabBotExtensions`), as harness tools. MCP tools are built from each
|
||||
* server's last listing (`<slug>__<tool>`) and connect on their first call,
|
||||
* so a run that doesn't use them never waits on them. Secret env values are
|
||||
* blanked out of every result. Call `close` when the run ends.
|
||||
*/
|
||||
export default async function openBotExtensions({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
taken = [],
|
||||
signal,
|
||||
}: Params): Promise<OpenBotExtensions> {
|
||||
const { servers, tools } = await grabBotExtensions({ bot_id, workspace_id });
|
||||
|
||||
if (!servers.length && !tools.length) return { tools: [], note: "", close: async () => {} };
|
||||
|
||||
const envs = await grabRunEnvs({ workspace_id });
|
||||
const secrets = envs.filter((env) => env.secret);
|
||||
const redact = (text: string) => redactSecrets({ text, secrets });
|
||||
const names = new Set(taken);
|
||||
const result: HarnessTool[] = [];
|
||||
const notes: string[] = [];
|
||||
const opened: ReturnType<typeof openMcpServer>[] = [];
|
||||
|
||||
const add = (tool: HarnessTool | null) => {
|
||||
if (!tool || names.has(tool.name)) return;
|
||||
names.add(tool.name);
|
||||
result.push(tool);
|
||||
};
|
||||
|
||||
for (const tool of tools) {
|
||||
add(buildCustomTool({ tool, envs, computer_id, bot_id, workspace_id }));
|
||||
}
|
||||
|
||||
for (const server of servers) {
|
||||
const listed = server.tools.filter((tool) => !server.excluded_tools.includes(tool.name));
|
||||
|
||||
if (!listed.length) {
|
||||
notes.push(
|
||||
`- ${server.name}: ${server.status == "error" ? `unavailable (${redact(server.last_error).slice(0, 300)})` : "no tools listed yet"}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const open = openMcpServer({ server, envs, signal });
|
||||
opened.push(open);
|
||||
|
||||
for (const tool of listed) {
|
||||
add(
|
||||
mcpHarnessTool({
|
||||
name: mcpToolName({ slug: server.slug, name: tool.name }),
|
||||
tool: { ...tool, description: `${server.name}: ${tool.description}` },
|
||||
client: open.client,
|
||||
redact,
|
||||
describeImage: ({ data, mime_type }) =>
|
||||
readImage({ url: `data:${mime_type};base64,${data}`, signal, bot_id, workspace_id }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools: result,
|
||||
note: notes.length
|
||||
? `These MCP servers were set up for you but you can't use them right now; if the user needs them, say so (an admin fixes them in Settings, under MCPs):\n${notes.join("\n")}`
|
||||
: "",
|
||||
close: async () => {
|
||||
await Promise.all(opened.map((open) => open.close()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import 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>
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import McpsSettingsSection from "./(sections)/mcps-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsMcpsPage() {
|
||||
return (
|
||||
<SettingsShell title="MCPs" description="MCP servers whose tools your bots can use.">
|
||||
<McpsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "MCPs" });
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
|
||||
|
||||
/**
|
||||
* What a new tool's arguments schema starts as.
|
||||
*/
|
||||
export const DefaultToolParameters = JSON.stringify(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string", description: "The city to look up" },
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
/**
|
||||
* What a tool's config starts as for each kind.
|
||||
*/
|
||||
export const DefaultToolConfigs: {
|
||||
http: CustomToolHttpConfig;
|
||||
script: CustomToolScriptConfig;
|
||||
prompt: CustomToolPromptConfig;
|
||||
} = {
|
||||
http: { method: "GET", url: "", headers: {}, body: "" },
|
||||
script: {
|
||||
language: "python",
|
||||
code: `import json, os\n\nargs = json.loads(os.environ["COBBLE_ARGS"])\nprint(f"Hello from {args.get('city')}")\n`,
|
||||
},
|
||||
prompt: { prompt: "" },
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CustomToolKind } from "@/src/dict/custom-tool-dict";
|
||||
import type { CustomToolHttpConfig, CustomToolPromptConfig, CustomToolScriptConfig } from "@/src/types";
|
||||
import HttpToolFields from "./http-tool-fields";
|
||||
import PromptToolFields from "./prompt-tool-fields";
|
||||
import ScriptToolFields from "./script-tool-fields";
|
||||
|
||||
export type ToolConfigs = {
|
||||
http: CustomToolHttpConfig;
|
||||
script: CustomToolScriptConfig;
|
||||
prompt: CustomToolPromptConfig;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
kind: CustomToolKind;
|
||||
configs: ToolConfigs;
|
||||
onChange: (configs: ToolConfigs) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The fields of the tool's kind. Each kind keeps its own config, so
|
||||
* switching kinds back and forth loses nothing.
|
||||
*/
|
||||
export default function CustomToolKindFields({ id, kind, configs, onChange }: Props) {
|
||||
switch (kind) {
|
||||
case "http":
|
||||
return <HttpToolFields id={id} config={configs.http} onChange={(http) => onChange({ ...configs, http })} />;
|
||||
case "script":
|
||||
return (
|
||||
<ScriptToolFields id={id} config={configs.script} onChange={(script) => onChange({ ...configs, script })} />
|
||||
);
|
||||
case "prompt":
|
||||
return (
|
||||
<PromptToolFields id={id} config={configs.prompt} onChange={(prompt) => onChange({ ...configs, prompt })} />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { CustomToolKinds, type CustomToolKind } from "@/src/dict/custom-tool-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { CustomToolsOverviewPayload, PublicCustomTool } from "@/src/types";
|
||||
import { DefaultToolConfigs, DefaultToolParameters } from "../(data)/tool-defaults";
|
||||
import CustomToolKindFields, { type ToolConfigs } from "./custom-tool-kind-fields";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Edits this tool; adds one when not given.
|
||||
*/
|
||||
tool?: PublicCustomTool;
|
||||
onSaved: (overview: CustomToolsOverviewPayload, msg: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or edits a custom tool (super admins): the name and description
|
||||
* the model reads, its arguments as a JSON schema, and what it does (an
|
||||
* HTTP request, a script, or a prompt).
|
||||
*/
|
||||
export default function CustomToolModal({ tool, onSaved, onCancel }: Props) {
|
||||
const [name, setName] = useState(tool?.name || "");
|
||||
const [description, setDescription] = useState(tool?.description || "");
|
||||
const [kind, setKind] = useState<CustomToolKind>(tool?.kind || "http");
|
||||
const [parameters, setParameters] = useState(
|
||||
tool?.parameters ? JSON.stringify(tool.parameters, null, 2) : DefaultToolParameters,
|
||||
);
|
||||
const [configs, setConfigs] = useState<ToolConfigs>({
|
||||
...DefaultToolConfigs,
|
||||
...(tool?.config ? { [tool.kind]: tool.config } : {}),
|
||||
});
|
||||
const [enabled, setEnabled] = useState(tool?.enabled ?? true);
|
||||
const [all_bots, setAllBots] = useState(tool?.access.all_bots ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const id = `tool-${tool?.id || "new"}`;
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(parameters);
|
||||
} catch {
|
||||
setError("The arguments schema isn't valid JSON.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(tool ? `Save ${tool.name}?` : `Add ${name || "this tool"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<CustomToolsOverviewPayload>({
|
||||
path: "tools/save-tool",
|
||||
body: {
|
||||
custom_tool_id: tool?.id,
|
||||
name,
|
||||
description,
|
||||
kind,
|
||||
parameters: parsed,
|
||||
config: configs[kind],
|
||||
enabled,
|
||||
all_bots,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't save the tool.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.singleRes, res.msg || "Saved.");
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onCancel()}
|
||||
title={tool ? `Edit ${tool.name}` : "Add a tool"}
|
||||
description={"Bots get it once it's attached to them."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Input
|
||||
id={`${id}-name`}
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
placeholder="get_weather"
|
||||
defaultValue={name}
|
||||
autoComplete="off"
|
||||
info="What bots call it: lowercase letters, numbers and underscores."
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id={`${id}-description`}
|
||||
title="Description"
|
||||
label="Description"
|
||||
showLabel
|
||||
rows={3}
|
||||
placeholder="Gets the current weather for a city. Use it when the user asks about the weather."
|
||||
defaultValue={description}
|
||||
info="Bots read this to decide when to call it, so say what it does and when to use it."
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="json"
|
||||
className="h-52"
|
||||
title="Arguments"
|
||||
content={parameters}
|
||||
info='An object schema. Describe each argument: bots fill them in from it. {"type": "object", "properties": {}} takes none.'
|
||||
onChange={(value) => setParameters(value)}
|
||||
/>
|
||||
<Select
|
||||
id={`${id}-kind`}
|
||||
title="What it does"
|
||||
label="What it does"
|
||||
showLabel
|
||||
options={CustomToolKinds.map((k) => ({ value: k.value, title: k.title, default: k.value == kind }))}
|
||||
changeHandler={(value) => setKind(String(value) as CustomToolKind)}
|
||||
/>
|
||||
<CustomToolKindFields key={kind} id={id} kind={kind} configs={configs} onChange={setConfigs} />
|
||||
<Checkbox title="On" label="On: bots get it" checked={enabled} changeHandler={setEnabled} />
|
||||
<Checkbox
|
||||
title="Every bot"
|
||||
label="Every bot, the coordinator included"
|
||||
checked={all_bots}
|
||||
changeHandler={setAllBots}
|
||||
/>
|
||||
<AddFormActions submit_label={tool ? "Save" : "Add tool"} loading={loading} error={error} onCancel={onCancel} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Wrench } from "lucide-react";
|
||||
import IconTile from "@/src/components/general/icon-tile";
|
||||
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { CustomToolsOverviewPayload, ExtensionTargets, PublicCustomTool } from "@/src/types";
|
||||
import ExtensionAccessModal from "../../(partials)/extension-access-modal";
|
||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||
import extensionAccessLabel from "../../(utils)/extension-access-label";
|
||||
import CustomToolModal from "./custom-tool-modal";
|
||||
import CustomToolTryModal from "./custom-tool-try-modal";
|
||||
|
||||
type Props = {
|
||||
tool: PublicCustomTool;
|
||||
targets: ExtensionTargets;
|
||||
onChanged: (overview: CustomToolsOverviewPayload, msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One custom tool: its name, kind and who gets it. Edit, Access and Try
|
||||
* open in a modal.
|
||||
*/
|
||||
export default function CustomToolRow({ tool, targets, onChanged, onError }: Props) {
|
||||
const [open, setOpen] = useState<"edit" | "access" | "try">();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggle = (panel: "edit" | "access" | "try") => setOpen(open == panel ? undefined : panel);
|
||||
|
||||
const saved = (overview: CustomToolsOverviewPayload, msg: string) => {
|
||||
setOpen(undefined);
|
||||
onChanged(overview, msg);
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (busy || !window.confirm(`Delete ${tool.name}? Bots lose it at once.`)) return;
|
||||
|
||||
setBusy(true);
|
||||
const res = await postAdminApi<CustomToolsOverviewPayload>({
|
||||
path: "tools/delete-tool",
|
||||
body: { custom_tool_id: tool.id },
|
||||
});
|
||||
setBusy(false);
|
||||
|
||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Deleted.");
|
||||
else onError(res.msg || "Couldn't delete the tool.");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-0! items-stretch border-0 border-b border-solid border-hairline last:border-b-0">
|
||||
<SettingsListRow
|
||||
avatar={<IconTile Icon={Wrench} />}
|
||||
title={tool.name}
|
||||
badge={tool.enabled ? undefined : "Off"}
|
||||
subtitle={[
|
||||
CustomToolKinds.find((k) => k.value == tool.kind)?.title,
|
||||
extensionAccessLabel({ access: tool.access }),
|
||||
tool.description,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Edit ${tool.name}`} label="Edit" disabled={busy} onClick={() => toggle("edit")} />
|
||||
) : null}
|
||||
<SettingsRowAction
|
||||
title={`Choose who gets ${tool.name}`}
|
||||
label="Access"
|
||||
disabled={busy}
|
||||
onClick={() => toggle("access")}
|
||||
/>
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Try ${tool.name}`} label="Try" disabled={busy} onClick={() => toggle("try")} />
|
||||
) : null}
|
||||
{targets.can_edit ? (
|
||||
<SettingsRowAction title={`Delete ${tool.name}`} label="Delete" disabled={busy} onClick={remove} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
{open == "edit" ? (
|
||||
<CustomToolModal tool={tool} onSaved={saved} onCancel={() => setOpen(undefined)} />
|
||||
) : null}
|
||||
{open == "access" ? (
|
||||
<ExtensionAccessModal<CustomToolsOverviewPayload>
|
||||
id={tool.id}
|
||||
name={tool.name}
|
||||
access={tool.access}
|
||||
targets={targets}
|
||||
path="tools/set-tool-access"
|
||||
onSaved={saved}
|
||||
onCancel={() => setOpen(undefined)}
|
||||
/>
|
||||
) : null}
|
||||
{open == "try" ? <CustomToolTryModal tool={tool} onClose={() => setOpen(undefined)} /> : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import type { CustomToolTestPayload, PublicCustomTool } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
tool: PublicCustomTool;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs a saved tool once with sample arguments and shows what a bot would
|
||||
* read. A script runs on the main computer, with general envs.
|
||||
*/
|
||||
export default function CustomToolTryModal({ tool, onClose }: Props) {
|
||||
const [args, setArgs] = useState("{}");
|
||||
const [output, setOutput] = useState<string>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(args || "{}");
|
||||
} catch {
|
||||
setError("The arguments aren't valid JSON.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<CustomToolTestPayload>({
|
||||
path: "tools/test-tool",
|
||||
body: { custom_tool_id: tool.id, args: parsed },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Couldn't run the tool.");
|
||||
return;
|
||||
}
|
||||
|
||||
setOutput(res.singleRes.output);
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(next) => !next && onClose()}
|
||||
title={`Try ${tool.name}`}
|
||||
description={"Runs it once and shows what a bot would read. A script runs on the main computer, with general envs."}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<CodeEditor
|
||||
mode="json"
|
||||
className="h-28"
|
||||
title="Arguments"
|
||||
content={args}
|
||||
info="Saved changes only: save the tool first."
|
||||
onChange={(value) => setArgs(value)}
|
||||
/>
|
||||
{output !== undefined ? (
|
||||
<Span className="block font-mono text-[13px] leading-[18px] text-ink whitespace-pre-wrap break-words max-h-96 overflow-auto rounded-default-sm border border-solid border-hairline bg-paper p-3">
|
||||
{output}
|
||||
</Span>
|
||||
) : null}
|
||||
<AddFormActions submit_label="Run it" loading={loading} error={error} onCancel={onClose} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { CustomToolHttpMethods, type CustomToolHttpMethod } from "@/src/dict/custom-tool-dict";
|
||||
import type { CustomToolHttpConfig } from "@/src/types";
|
||||
import parseTemplateLines, { templateLines } from "@/src/utils/parse-template-lines";
|
||||
import TimeoutField from "./timeout-field";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
config: CustomToolHttpConfig;
|
||||
onChange: (config: CustomToolHttpConfig) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* An HTTP tool's request: method, URL, headers and body, all templates.
|
||||
*/
|
||||
export default function HttpToolFields({ id, config, onChange }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
id={`${id}-method`}
|
||||
title="Method"
|
||||
label="Method"
|
||||
showLabel
|
||||
options={CustomToolHttpMethods.map((method) => ({
|
||||
value: method,
|
||||
title: method,
|
||||
default: method == config.method,
|
||||
}))}
|
||||
changeHandler={(value) => onChange({ ...config, method: String(value) as CustomToolHttpMethod })}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}-url`}
|
||||
title="URL"
|
||||
label="URL"
|
||||
showLabel
|
||||
placeholder="https://api.example.com/weather?city={{args.city}}"
|
||||
defaultValue={config.url}
|
||||
autoComplete="off"
|
||||
info="{{args.name}} fills in an argument (URL-encoded), {{env.KEY}} an env."
|
||||
onChange={(e) => onChange({ ...config, url: e.target.value })}
|
||||
/>
|
||||
<CodeEditor
|
||||
mode="properties"
|
||||
className="h-24"
|
||||
title="Headers"
|
||||
placeholder={"Authorization: Bearer {{env.WEATHER_KEY}}"}
|
||||
content={templateLines({ map: config.headers, separator: ":" })}
|
||||
info="One per line. Put secrets in Settings, under Environment: bots never see them."
|
||||
onChange={(value) =>
|
||||
onChange({ ...config, headers: parseTemplateLines({ text: value, separator: ":" }) })
|
||||
}
|
||||
/>
|
||||
{config.method == "GET" ? null : (
|
||||
<CodeEditor
|
||||
mode="json"
|
||||
className="h-28"
|
||||
title="Body"
|
||||
placeholder={'{"city": "{{args.city}}"}'}
|
||||
content={config.body || ""}
|
||||
info="Empty sends the arguments as JSON."
|
||||
onChange={(value) => onChange({ ...config, body: value })}
|
||||
/>
|
||||
)}
|
||||
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import type { CustomToolPromptConfig } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
config: CustomToolPromptConfig;
|
||||
onChange: (config: CustomToolPromptConfig) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A prompt tool's prompt, answered by the bot's text model.
|
||||
*/
|
||||
export default function PromptToolFields({ id, config, onChange }: Props) {
|
||||
return (
|
||||
<CodeEditor
|
||||
mode="markdown"
|
||||
className="h-52"
|
||||
title="Prompt"
|
||||
placeholder={"Summarise this contract for a busy founder. List the risks first.\n\n{{args.text}}"}
|
||||
content={config.prompt}
|
||||
info="{{args.name}} fills in an argument. The bot's text model answers it in one turn, without tools."
|
||||
onChange={(value) => onChange({ ...config, prompt: value })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import CodeEditor from "@/src/components/general/code-editor";
|
||||
import { CustomToolScriptLanguages, type CustomToolScriptLanguage } from "@/src/dict/custom-tool-dict";
|
||||
import type { CustomToolScriptConfig } from "@/src/types";
|
||||
import TimeoutField from "./timeout-field";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
config: CustomToolScriptConfig;
|
||||
onChange: (config: CustomToolScriptConfig) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A script tool's code and language. It runs on the bot's computer.
|
||||
*/
|
||||
export default function ScriptToolFields({ id, config, onChange }: Props) {
|
||||
const language =
|
||||
CustomToolScriptLanguages.find((l) => l.value == config.language) || CustomToolScriptLanguages[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
id={`${id}-language`}
|
||||
title="Language"
|
||||
label="Language"
|
||||
showLabel
|
||||
options={CustomToolScriptLanguages.map((language) => ({
|
||||
value: language.value,
|
||||
title: language.title,
|
||||
default: language.value == config.language,
|
||||
}))}
|
||||
changeHandler={(value) => onChange({ ...config, language: String(value) as CustomToolScriptLanguage })}
|
||||
/>
|
||||
<CodeEditor
|
||||
title="Code"
|
||||
mode={language.editor_mode}
|
||||
className="h-72"
|
||||
content={config.code}
|
||||
info="Runs on the bot's current computer (the task's in a task) with its envs set. The arguments come as JSON in $COBBLE_ARGS and on stdin; what it prints goes back to the bot."
|
||||
onChange={(code) => onChange({ ...config, code })}
|
||||
/>
|
||||
<TimeoutField id={id} timeout_ms={config.timeout_ms} onChange={(timeout_ms) => onChange({ ...config, timeout_ms })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
timeout_ms?: number;
|
||||
onChange: (timeout_ms?: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* How long a tool may run, in seconds. Empty uses the default.
|
||||
*/
|
||||
export default function TimeoutField({ id, timeout_ms, onChange }: Props) {
|
||||
return (
|
||||
<Input
|
||||
id={`${id}-timeout`}
|
||||
title="Timeout (seconds)"
|
||||
label="Timeout (seconds)"
|
||||
showLabel
|
||||
type="number"
|
||||
min={1}
|
||||
max={AppData.CustomToolMaxTimeoutMs / 1000}
|
||||
placeholder={String(AppData.CustomToolDefaultTimeoutMs / 1000)}
|
||||
defaultValue={timeout_ms ? String(timeout_ms / 1000) : ""}
|
||||
onChange={(e) => {
|
||||
const seconds = Number(e.target.value);
|
||||
onChange(seconds > 0 ? Math.round(seconds * 1000) : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { CustomToolsOverviewPayload } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SettingsList from "../../(partials)/settings-list";
|
||||
import SettingsStatus from "../../(partials)/settings-status";
|
||||
import CustomToolModal from "../(partials)/custom-tool-modal";
|
||||
import CustomToolRow from "../(partials)/custom-tool-row";
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* Every custom tool with who gets it. Super admins add, edit, try and
|
||||
* delete them; admins choose which bots and workspaces get each one.
|
||||
*/
|
||||
export default function ToolsSettingsSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [overview, setOverview] = useState(pageProps?.tools_overview);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
/**
|
||||
* Remounts the rows when the data changes under them.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
if (!overview) return null;
|
||||
|
||||
const changed = (next: CustomToolsOverviewPayload, msg: string) => {
|
||||
setOverview(next);
|
||||
setStatus({ success: true, msg });
|
||||
setVersion((prev) => prev + 1);
|
||||
};
|
||||
const failed = (msg: string) => setStatus({ error: true, msg });
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Tools"
|
||||
description="An API call, a script on the bot's computer, or a prompt. A tool goes to the bots and workspaces it's attached to, or to every bot."
|
||||
>
|
||||
{adding ? (
|
||||
<CustomToolModal
|
||||
onSaved={(next, msg) => {
|
||||
setAdding(false);
|
||||
changed(next, msg);
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
) : null}
|
||||
<SettingsList is_empty={!overview.tools.length} empty="No tools yet.">
|
||||
{overview.tools.map((tool) => (
|
||||
<CustomToolRow
|
||||
key={`${tool.id}-${version}`}
|
||||
tool={tool}
|
||||
targets={overview}
|
||||
onChanged={changed}
|
||||
onError={failed}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
{overview.can_edit && !adding ? (
|
||||
<Button
|
||||
title="Add a tool"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => setAdding(true)}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New tool
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
|
||||
/**
|
||||
* Super admins and admins: admins only choose who gets each tool.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
|
||||
if (!settings.props?.person?.id) return settings;
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
tools_overview: await grabToolsOverview({ user_id: settings.props.person.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import ToolsSettingsSection from "./(sections)/tools-settings-section";
|
||||
import SettingsShell from "../(partials)/settings-shell";
|
||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||
|
||||
export default function AdminSettingsToolsPage() {
|
||||
return (
|
||||
<SettingsShell title="Tools" description="Tools of your own your bots can call.">
|
||||
<ToolsSettingsSection />
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
|
||||
export const meta = settingsPageMeta({ label: "Tools" });
|
||||
+11
-15
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import 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>
|
||||
|
||||
+6
-8
@@ -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} />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import deleteMcpServer from "@/src/functions/backend/db/extensions/delete-mcp-server";
|
||||
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||
import { McpServerIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { McpServersOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: deletes an MCP server. Bots lose its tools at once.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<McpServersOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { mcp_server_id } = parseWithSchema({ schema: McpServerIdRequestSchema, data: body });
|
||||
const server = await deleteMcpServer({ mcp_server_id });
|
||||
|
||||
await logEvent({
|
||||
title: "MCP server deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the MCP server ${server.name} (#${server.id}).`,
|
||||
metadata: { mcp_server_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Deleted ${server.name}.`,
|
||||
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||
import saveMcpServer from "@/src/functions/backend/db/extensions/save-mcp-server";
|
||||
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||
import { SaveMcpServerRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { McpServersOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: adds or edits an MCP server, then lists its tools
|
||||
* (starting a stdio one after its setup) so bots can use them.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<McpServersOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const data = parseWithSchema({ schema: SaveMcpServerRequestSchema, data: body });
|
||||
const mcp_server_id = await saveMcpServer({ user_id: user.id, data });
|
||||
const sync = data.enabled ? await syncMcpServer({ mcp_server_id }) : null;
|
||||
|
||||
await logEvent({
|
||||
title: data.mcp_server_id ? "MCP server changed" : "MCP server added",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} ${data.mcp_server_id ? "changed" : "added"} the MCP server ${data.name} (#${mcp_server_id}, ${data.transport}).`,
|
||||
metadata: { mcp_server_id, transport: data.transport },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: !sync
|
||||
? `Saved ${data.name}. It's off.`
|
||||
: sync.ok
|
||||
? `Saved ${data.name}: ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
||||
: `Saved ${data.name}, but it didn't connect.`,
|
||||
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { McpServersOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admins and admins: attaches an MCP server to exactly these bots and
|
||||
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<McpServersOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
|
||||
|
||||
await setExtensionAccess({ user_id: user.id, kind: "mcp", ...access });
|
||||
|
||||
await logEvent({
|
||||
title: "MCP server access changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} changed who gets the MCP server #${access.id}.`,
|
||||
metadata: access,
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved who gets it.`,
|
||||
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
|
||||
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
|
||||
import { SyncMcpServerRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { McpServersOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: lists an MCP server's tools again, optionally running
|
||||
* a stdio server's setup again first (Reinstall).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<McpServersOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { mcp_server_id, reinstall } = parseWithSchema({ schema: SyncMcpServerRequestSchema, data: body });
|
||||
const sync = await syncMcpServer({ mcp_server_id, reinstall });
|
||||
|
||||
if (reinstall) {
|
||||
await logEvent({
|
||||
title: "MCP server reinstalled",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} reinstalled the MCP server #${mcp_server_id}.`,
|
||||
metadata: { mcp_server_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: sync.ok
|
||||
? `Connected: ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
|
||||
: `It didn't connect.`,
|
||||
singleRes: await grabMcpsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import deleteCustomTool from "@/src/functions/backend/db/extensions/delete-custom-tool";
|
||||
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||
import { CustomToolIdRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { CustomToolsOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: deletes a custom tool. Bots lose it at once.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<CustomToolsOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { custom_tool_id } = parseWithSchema({ schema: CustomToolIdRequestSchema, data: body });
|
||||
const tool = await deleteCustomTool({ custom_tool_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Tool deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the tool ${tool.name} (#${tool.id}).`,
|
||||
metadata: { custom_tool_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Deleted ${tool.name}.`,
|
||||
singleRes: await grabToolsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||
import saveCustomTool from "@/src/functions/backend/db/extensions/save-custom-tool";
|
||||
import { SaveCustomToolRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { CustomToolsOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: adds or edits a custom tool.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<CustomToolsOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const data = parseWithSchema({ schema: SaveCustomToolRequestSchema, data: body });
|
||||
const custom_tool_id = await saveCustomTool({ user_id: user.id, data });
|
||||
|
||||
await logEvent({
|
||||
title: data.custom_tool_id ? "Tool changed" : "Tool added",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} ${data.custom_tool_id ? "changed" : "added"} the tool ${data.name} (#${custom_tool_id}, ${data.kind}).`,
|
||||
metadata: { custom_tool_id, kind: data.kind },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved ${data.name}.`,
|
||||
singleRes: await grabToolsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
|
||||
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import type { CustomToolsOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admins and admins: attaches a custom tool to exactly these bots and
|
||||
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<CustomToolsOverviewPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
|
||||
|
||||
await setExtensionAccess({ user_id: user.id, kind: "tool", ...access });
|
||||
|
||||
await logEvent({
|
||||
title: "Tool access changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} changed who gets the tool #${access.id}.`,
|
||||
metadata: access,
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved who gets it.`,
|
||||
singleRes: await grabToolsOverview({ user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import testCustomTool from "@/src/functions/backend/db/extensions/test-custom-tool";
|
||||
import { TestCustomToolRequestSchema } from "@/src/schemas/extension-schema";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { CustomToolTestPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: runs a custom tool once with sample arguments and
|
||||
* returns what a bot would read.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<CustomToolTestPayload>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { custom_tool_id, args, computer_id } = parseWithSchema({ schema: TestCustomToolRequestSchema, data: body });
|
||||
const output = await testCustomTool({ custom_tool_id, args, computer_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Tool tried",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} tried the tool #${custom_tool_id}.`,
|
||||
metadata: { custom_tool_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: "Ran it.", singleRes: { output } };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import {
|
||||
CustomToolHttpMethods,
|
||||
CustomToolScriptLanguages,
|
||||
type CustomToolScriptLanguage,
|
||||
} from "@/src/dict/custom-tool-dict";
|
||||
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
|
||||
import checkCustomToolName from "@/src/utils/check-custom-tool-name";
|
||||
|
||||
const Id = z.coerce.number().int().positive();
|
||||
const Ids = z.array(Id).max(1000).default([]);
|
||||
|
||||
/**
|
||||
* Header or env names to their (template) values.
|
||||
*/
|
||||
const TemplateMap = z
|
||||
.record(z.string().trim().min(1).max(200), z.string().max(4000))
|
||||
.default({});
|
||||
|
||||
const TimeoutMs = z.coerce.number().int().min(1000).max(AppData.CustomToolMaxTimeoutMs).optional();
|
||||
|
||||
export const SaveMcpServerRequestSchema = z
|
||||
.object({
|
||||
mcp_server_id: Id.optional(),
|
||||
name: z.string().trim().min(1, "Enter a name").max(80),
|
||||
description: z.string().trim().max(1000).default(""),
|
||||
transport: z.enum(McpTransports.map((t) => t.value) as [McpTransport, ...McpTransport[]]),
|
||||
url: z.string().trim().max(2000).default(""),
|
||||
headers: TemplateMap,
|
||||
command: z.string().trim().max(4000).default(""),
|
||||
setup_command: z.string().trim().max(8000).default(""),
|
||||
env: TemplateMap,
|
||||
excluded_tools: z.array(z.string().max(200)).max(500).default([]),
|
||||
enabled: z.boolean().default(true),
|
||||
all_bots: z.boolean().default(false),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.transport == "remote" && !/^(https?:\/\/|\{\{\s*env\.)/.test(value.url)) {
|
||||
ctx.addIssue({ code: "custom", path: ["url"], message: "Enter the server's http(s) URL." });
|
||||
}
|
||||
|
||||
if (value.transport == "stdio" && !value.command) {
|
||||
ctx.addIssue({ code: "custom", path: ["command"], message: "Enter the command that starts the server." });
|
||||
}
|
||||
});
|
||||
|
||||
export const McpServerIdRequestSchema = z.object({
|
||||
mcp_server_id: Id,
|
||||
});
|
||||
|
||||
export const SyncMcpServerRequestSchema = McpServerIdRequestSchema.extend({
|
||||
/**
|
||||
* Runs a stdio server's setup again first.
|
||||
*/
|
||||
reinstall: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const SetExtensionAccessRequestSchema = z.object({
|
||||
id: Id,
|
||||
bot_ids: Ids,
|
||||
workspace_ids: Ids,
|
||||
all_bots: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A tool's arguments: a JSON schema of an object.
|
||||
*/
|
||||
const ToolParameters = z
|
||||
.record(z.string(), z.unknown())
|
||||
.refine((value) => value.type == "object", { message: `The arguments schema must have "type": "object".` })
|
||||
.refine((value) => JSON.stringify(value).length <= 20000, { message: "Keep the arguments schema under 20,000 characters." });
|
||||
|
||||
const ToolConfig = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("http"),
|
||||
config: z.object({
|
||||
method: z.enum(CustomToolHttpMethods),
|
||||
url: z.string().trim().min(1, "Enter the URL").max(4000),
|
||||
headers: TemplateMap,
|
||||
body: z.string().max(20000).default(""),
|
||||
timeout_ms: TimeoutMs,
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("script"),
|
||||
config: z.object({
|
||||
language: z.enum(
|
||||
CustomToolScriptLanguages.map((l) => l.value) as [CustomToolScriptLanguage, ...CustomToolScriptLanguage[]],
|
||||
),
|
||||
code: z.string().min(1, "Enter the code").max(60000, "Keep the code under 60,000 characters"),
|
||||
timeout_ms: TimeoutMs,
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prompt"),
|
||||
config: z.object({
|
||||
prompt: z.string().trim().min(1, "Enter the prompt").max(20000),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const SaveCustomToolRequestSchema = z
|
||||
.object({
|
||||
custom_tool_id: Id.optional(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((name, ctx) => {
|
||||
const problem = checkCustomToolName({ name });
|
||||
if (problem) ctx.addIssue({ code: "custom", message: problem });
|
||||
}),
|
||||
description: z.string().trim().min(1, "Describe what it does, for the bots").max(2000),
|
||||
parameters: ToolParameters,
|
||||
enabled: z.boolean().default(true),
|
||||
all_bots: z.boolean().default(false),
|
||||
})
|
||||
.and(ToolConfig);
|
||||
|
||||
export const CustomToolIdRequestSchema = z.object({
|
||||
custom_tool_id: Id,
|
||||
});
|
||||
|
||||
export const TestCustomToolRequestSchema = z.object({
|
||||
custom_tool_id: Id,
|
||||
args: z.record(z.string(), z.unknown()).default({}),
|
||||
/**
|
||||
* Where a script tool runs. The main computer when not given.
|
||||
*/
|
||||
computer_id: Id.optional(),
|
||||
});
|
||||
|
||||
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
|
||||
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;
|
||||
@@ -27,6 +27,12 @@ import type { HarnessProviderKind } from "../dict/harness-provider-dict";
|
||||
import type { EnvParadigm, KnownEnv } from "../dict/env-dict";
|
||||
import type { 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;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import botExtensionsSql from "./bot-extensions-sql";
|
||||
|
||||
const db = new Database(":memory:");
|
||||
|
||||
beforeAll(() => {
|
||||
db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
|
||||
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
|
||||
db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`);
|
||||
/**
|
||||
* 1: every bot. 2: bot 5. 3: workspace 9. 4: every bot, off.
|
||||
* 5: nobody.
|
||||
*/
|
||||
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1)`);
|
||||
db.run(`INSERT INTO bot_custom_tools VALUES (5, 2)`);
|
||||
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
|
||||
});
|
||||
|
||||
function ids(params: Parameters<typeof botExtensionsSql>[0]) {
|
||||
const { sql, values } = botExtensionsSql(params);
|
||||
return db
|
||||
.query(sql)
|
||||
.all(...values)
|
||||
.map((row) => (row as { id: number }).id);
|
||||
}
|
||||
|
||||
describe("botExtensionsSql", () => {
|
||||
test("a bot gets every-bot ones and its own, never disabled ones", () => {
|
||||
expect(ids({ kind: "tool", bot_id: 5 })).toEqual([1, 2]);
|
||||
expect(ids({ kind: "tool", bot_id: 6 })).toEqual([1]);
|
||||
});
|
||||
|
||||
test("in a workspace, the workspace's too", () => {
|
||||
expect(ids({ kind: "tool", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
|
||||
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3]);
|
||||
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
type Params = {
|
||||
/**
|
||||
* Which kind: MCP servers or custom tools.
|
||||
*/
|
||||
kind: "mcp" | "tool";
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the run is in: what's attached to it counts too.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
const Tables = {
|
||||
mcp: {
|
||||
table: "mcp_servers",
|
||||
bot_link: "bot_mcp_servers",
|
||||
workspace_link: "workspace_mcp_servers",
|
||||
column: "mcp_server_id",
|
||||
},
|
||||
tool: {
|
||||
table: "custom_tools",
|
||||
bot_link: "bot_custom_tools",
|
||||
workspace_link: "workspace_custom_tools",
|
||||
column: "custom_tool_id",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The enabled MCP servers or custom tools a bot gets on one run: those
|
||||
* for every bot, those attached to the bot, and in a workspace those
|
||||
* attached to the workspace.
|
||||
*/
|
||||
export default function botExtensionsSql({ kind, bot_id, workspace_id }: Params) {
|
||||
const { table, bot_link, workspace_link, column } = Tables[kind];
|
||||
|
||||
return {
|
||||
sql: `SELECT x.* FROM ${table} x WHERE x.enabled = 1 AND (
|
||||
x.all_bots = 1
|
||||
OR x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
|
||||
${workspace_id ? `OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)` : ""}
|
||||
) ORDER BY x.id ASC`,
|
||||
values: workspace_id ? [Number(bot_id), Number(workspace_id)] : [Number(bot_id)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ReservedToolNames, ReservedToolPrefixes } from "@/src/dict/custom-tool-dict";
|
||||
|
||||
type Params = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Why a custom tool can't take this name, or null when it can: snake_case
|
||||
* of 2 to 64 characters, never a built-in tool's name or a computer tool's
|
||||
* prefix, and never with `__` (MCP tools use it, `<server>__<tool>`).
|
||||
*/
|
||||
export default function checkCustomToolName({ name }: Params): string | null {
|
||||
if (!/^[a-z][a-z0-9_]{1,63}$/.test(name)) {
|
||||
return `Use lowercase letters, numbers and underscores, starting with a letter (2 to 64 characters).`;
|
||||
}
|
||||
|
||||
if (name.includes("__")) return `Don't use a double underscore: MCP tools use it.`;
|
||||
|
||||
if ((ReservedToolNames as readonly string[]).includes(name)) {
|
||||
return `${name} is a built-in tool's name.`;
|
||||
}
|
||||
|
||||
const prefix = ReservedToolPrefixes.find((p) => name.startsWith(p));
|
||||
|
||||
if (prefix) return `Names starting with ${prefix} are kept for computers' tools.`;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import checkCustomToolName from "./check-custom-tool-name";
|
||||
import mcpServerSlug from "./mcp-server-slug";
|
||||
import mcpToolName from "./mcp-tool-name";
|
||||
|
||||
describe("checkCustomToolName", () => {
|
||||
test("takes snake_case", () => {
|
||||
expect(checkCustomToolName({ name: "get_weather" })).toBeNull();
|
||||
});
|
||||
|
||||
test("refuses built-ins, computer prefixes, double underscores and bad shapes", () => {
|
||||
expect(checkCustomToolName({ name: "start_task" })).toContain("built-in");
|
||||
expect(checkCustomToolName({ name: "sandbox_run" })).toContain("sandbox_");
|
||||
expect(checkCustomToolName({ name: "a__b" })).toContain("double underscore");
|
||||
expect(checkCustomToolName({ name: "Get-Weather" })).toContain("lowercase");
|
||||
expect(checkCustomToolName({ name: "1abc" })).toContain("lowercase");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpToolName", () => {
|
||||
test("prefixes with the slug, cleans and caps the name", () => {
|
||||
expect(mcpToolName({ slug: "github", name: "create.issue" })).toBe("github__create_issue");
|
||||
expect(mcpToolName({ slug: "s", name: "x".repeat(80) })).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpServerSlug", () => {
|
||||
test("slugs a name", () => {
|
||||
expect(mcpServerSlug({ name: "GitHub (work)" })).toBe("github_work");
|
||||
expect(mcpServerSlug({ name: "!!!" })).toBe("mcp");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import fillTemplate from "./fill-template";
|
||||
|
||||
describe("fillTemplate", () => {
|
||||
test("fills args and envs", () => {
|
||||
expect(
|
||||
fillTemplate({
|
||||
template: "https://api.test/{{args.city}}?key={{ env.API_KEY }}",
|
||||
args: { city: "Lagos" },
|
||||
envs: [{ key: "API_KEY", value: "abc" }],
|
||||
}),
|
||||
).toBe("https://api.test/Lagos?key=abc");
|
||||
});
|
||||
|
||||
test("objects become JSON, missing args empty, dotted paths reach in", () => {
|
||||
expect(
|
||||
fillTemplate({
|
||||
template: "{{args.filter}}|{{args.missing}}|{{args.filter.tag}}",
|
||||
args: { filter: { tag: "x" } },
|
||||
}),
|
||||
).toBe('{"tag":"x"}||x');
|
||||
});
|
||||
|
||||
test("encodes filled arguments only", () => {
|
||||
expect(
|
||||
fillTemplate({
|
||||
template: "{{env.BASE}}/q?s={{args.q}}&x=a b",
|
||||
args: { q: "a b&c" },
|
||||
envs: [{ key: "BASE", value: "https://x.test/v1" }],
|
||||
encode: encodeURIComponent,
|
||||
}),
|
||||
).toBe("https://x.test/v1/q?s=a%20b%26c&x=a b");
|
||||
});
|
||||
|
||||
test("an unset env throws, naming it", () => {
|
||||
expect(() => fillTemplate({ template: "{{env.NOPE}}" })).toThrow("NOPE");
|
||||
});
|
||||
|
||||
test("leaves other braces alone", () => {
|
||||
expect(fillTemplate({ template: '{"a": "{{other}}"}' })).toBe('{"a": "{{other}}"}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
type Params = {
|
||||
template: string;
|
||||
/**
|
||||
* `{{args.name}}`, dotted paths reach into objects. Missing ones fill
|
||||
* in empty; objects and arrays as JSON.
|
||||
*/
|
||||
args?: Record<string, unknown>;
|
||||
/**
|
||||
* `{{env.KEY}}`. A key that isn't set throws, naming it.
|
||||
*/
|
||||
envs?: { key: string; value: string }[];
|
||||
/**
|
||||
* Applied to every filled-in argument, e.g. `encodeURIComponent` in a
|
||||
* URL. Envs go in as they are (one may hold a whole base URL).
|
||||
*/
|
||||
encode?: (value: string) => string;
|
||||
};
|
||||
|
||||
const Pattern = /\{\{\s*(args|env)\.([A-Za-z0-9_.-]+)\s*\}\}/g;
|
||||
|
||||
/**
|
||||
* Fills a custom tool's or MCP server's template: `{{args.x}}` from the
|
||||
* model's arguments, `{{env.KEY}}` from the envs. Anything else is left as
|
||||
* written.
|
||||
*/
|
||||
export default function fillTemplate({ template, args = {}, envs = [], encode = (v) => v }: Params) {
|
||||
return template.replace(Pattern, (_match, source: string, path: string) => {
|
||||
if (source == "env") {
|
||||
const env = envs.find((e) => e.key == path);
|
||||
|
||||
if (!env) throw new Error(`The env ${path} isn't set. Add it in Settings, under Environment.`);
|
||||
|
||||
return env.value;
|
||||
}
|
||||
|
||||
return encode(stringify({ value: pick({ value: args, path }) }));
|
||||
});
|
||||
}
|
||||
|
||||
function pick({ value, path }: { value: unknown; path: string }) {
|
||||
return path.split(".").reduce<unknown>((current, key) => {
|
||||
if (current && typeof current == "object") return (current as Record<string, unknown>)[key];
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function stringify({ value }: { value: unknown }) {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value == "string") return value;
|
||||
if (typeof value == "number" || typeof value == "boolean") return String(value);
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const AceScripts = [
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js",
|
||||
"https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js",
|
||||
];
|
||||
|
||||
let loading: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Loads the ACE editor's scripts once per page, in order (the language
|
||||
* tools need ACE itself), so only pages with an editor download them.
|
||||
* twui's `AceEditor` waits for `window.ace` on its own.
|
||||
*/
|
||||
export default function loadAceScripts() {
|
||||
if (typeof window == "undefined") return Promise.resolve();
|
||||
|
||||
loading ||= AceScripts.reduce(
|
||||
(previous, src) =>
|
||||
previous.then(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) return resolve();
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Couldn't load ${src}`));
|
||||
document.head.appendChild(script);
|
||||
}),
|
||||
),
|
||||
Promise.resolve(),
|
||||
).catch((error) => {
|
||||
loading = undefined;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return loading;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
type Params = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A server's slug from its name: lowercase letters, numbers and single
|
||||
* underscores, at most 24 characters, so its tools' names stay short.
|
||||
*/
|
||||
export default function mcpServerSlug({ name }: Params) {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 24)
|
||||
.replace(/_+$/g, "");
|
||||
|
||||
return slug || "mcp";
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The server's slug (`mcp_servers.slug`).
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* The tool's name on the server.
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a bot calls an MCP server's tool: `<slug>__<tool>`, only letters,
|
||||
* numbers, `_` and `-`, at most 64 characters (what providers accept).
|
||||
*/
|
||||
export default function mcpToolName({ slug, name }: Params) {
|
||||
return `${slug}__${name.replace(/[^A-Za-z0-9_-]/g, "_")}`.slice(0, 64);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import parseTemplateLines, { templateLines } from "./parse-template-lines";
|
||||
|
||||
describe("parseTemplateLines", () => {
|
||||
test("reads headers, keeping colons in values", () => {
|
||||
expect(
|
||||
parseTemplateLines({ text: "Authorization: Bearer {{env.T}}\n\nX-Url: http://a:1\nbad line", separator: ":" }),
|
||||
).toEqual({ Authorization: "Bearer {{env.T}}", "X-Url": "http://a:1" });
|
||||
});
|
||||
|
||||
test("reads envs and writes them back", () => {
|
||||
const map = parseTemplateLines({ text: "A=1\nB = x=y", separator: "=" });
|
||||
expect(map).toEqual({ A: "1", B: "x=y" });
|
||||
expect(templateLines({ map, separator: "=" })).toBe("A=1\nB=x=y");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
type Params = {
|
||||
text: string;
|
||||
/**
|
||||
* What separates a name from its value: `:` for headers, `=` for envs.
|
||||
*/
|
||||
separator: ":" | "=";
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads one `Name: value` (or `NAME=value`) per line into a map, for the
|
||||
* header and env fields of an MCP server or tool. Blank lines and lines
|
||||
* without the separator are skipped; the value is everything after the
|
||||
* first one, trimmed.
|
||||
*/
|
||||
export default function parseTemplateLines({ text, separator }: Params): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
const at = line.indexOf(separator);
|
||||
const name = at > 0 ? line.slice(0, at).trim() : "";
|
||||
|
||||
if (name) map[name] = line.slice(at + 1).trim();
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The other way: a map as one `Name: value` (or `NAME=value`) per line.
|
||||
*/
|
||||
export function templateLines({ map, separator }: { map?: Record<string, string>; separator: ":" | "=" }) {
|
||||
return Object.entries(map || {})
|
||||
.map(([name, value]) => `${name}${separator == ":" ? ": " : "="}${value}`)
|
||||
.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user