Give bots tools and MCP servers per bot and workspace, with status and tests
- Bots and workspaces get tools and MCP servers from their settings pages; bots also from chips under the chat composer, removable there - "Every bot" now gives one to every bot now and every new bot; each can still drop it (attach-every-bot-extensions.ts moves old installs over) - Status lines: MCP servers record whether they connect, tools whether their last run worked - Test connection in the MCP form and a Test panel in the tool form check the form as it is, without saving - Hide Google sign-in on the login form Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { PagePropsType } from "@/src/types";
|
||||
import grabBots from "../bots/grab-bots";
|
||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||
import grabBotMessages from "../bots/grab-bot-messages";
|
||||
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
|
||||
import grabHarnessOverview from "../harness/grab-harness-overview";
|
||||
@@ -23,7 +24,8 @@ type Params = {
|
||||
* The page props of a conversation (`/admin/bots/<id>` or
|
||||
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||
* in a workspace) and workspaces, the bot, its active session (marked
|
||||
* read) and its messages, its models, computers and active task there.
|
||||
* read) and its messages, its models, computers, tools and MCP servers
|
||||
* and active task there.
|
||||
* Null when the user can't chat with the bot there.
|
||||
*/
|
||||
export default async function grabConversationProps({
|
||||
@@ -62,7 +64,7 @@ export default async function grabConversationProps({
|
||||
* workspace the workspace's own models come first, so what runs now
|
||||
* shows its pick.
|
||||
*/
|
||||
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
|
||||
const [bot_messages, bot_harness_overview, bot_computers, bot_task, bot_extensions] = await Promise.all([
|
||||
grabBotMessages({ session_id: session.id! }),
|
||||
(bot.kind == "coordinator" && !scope
|
||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||
@@ -73,6 +75,7 @@ export default async function grabConversationProps({
|
||||
).catch(() => null),
|
||||
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
||||
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -85,5 +88,6 @@ export default async function grabConversationProps({
|
||||
bot_harness_overview,
|
||||
bot_computers,
|
||||
bot_task,
|
||||
bot_extensions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import assertPromptLength from "@/src/utils/assert-prompt-length";
|
||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
@@ -53,5 +54,7 @@ export default async function createBot({ user_id, name, role, hue, system_promp
|
||||
throw new Error(`Couldn't add the bot: ${res.msg}`);
|
||||
}
|
||||
|
||||
await attachDefaultExtensions({ bot_id });
|
||||
|
||||
return Number(bot_id);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
|
||||
import { CoordinatorBotName } from "@/src/dict/bot-dict";
|
||||
import { CoordinatorSystemPrompt } from "@/src/data/coordinator-system-prompt";
|
||||
import attachDefaultExtensions from "../extensions/attach-default-extensions";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
@@ -66,5 +67,7 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
|
||||
throw new Error(`Couldn't create the coordinator: ${res.msg}`);
|
||||
}
|
||||
|
||||
await attachDefaultExtensions({ bot_id });
|
||||
|
||||
return bot_id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabCrewRole from "../access/grab-crew-role";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws unless the user manages this bot's MCP servers and tools: super
|
||||
* admins and admins, the super admin alone for the coordinator. Returns
|
||||
* the bot.
|
||||
*/
|
||||
export default async function assertBotExtensionManager({ user_id, bot_id }: Params) {
|
||||
const role = await grabCrewRole({ user_id });
|
||||
const bot = (
|
||||
await BunSQLite.sql<{ id: number; kind: string; name: string }>({
|
||||
sql: `SELECT id, kind, name FROM bots WHERE id = ?`,
|
||||
values: [bot_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!bot) throw new AppError(`That bot doesn't exist.`);
|
||||
|
||||
if (role == "member" || (bot.kind == "coordinator" && role != "super_admin")) {
|
||||
throw new AppError(`Only an admin can change a bot's tools.`);
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives a new bot every MCP server and tool set to "every bot". Called
|
||||
* when a bot is created; it can drop any of them afterwards.
|
||||
*/
|
||||
export default async function attachDefaultExtensions({ bot_id }: Params) {
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO bot_mcp_servers (created_at, updated_at, bot_id, mcp_server_id)
|
||||
SELECT ?, ?, ?, id FROM mcp_servers WHERE all_bots = 1`,
|
||||
values: [now, now, Number(bot_id)],
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO bot_custom_tools (created_at, updated_at, bot_id, custom_tool_id)
|
||||
SELECT ?, ?, ?, id FROM custom_tools WHERE all_bots = 1`,
|
||||
values: [now, now, Number(bot_id)],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||
|
||||
type Params = {
|
||||
kind: ExtensionKind;
|
||||
id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives an MCP server or tool set to "every bot" to every bot there is,
|
||||
* the coordinator included. Each can still drop it afterwards.
|
||||
*/
|
||||
export default async function attachToAllBots({ kind, id }: Params) {
|
||||
const { bot_link, column } = ExtensionTables[kind];
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO ${bot_link} (created_at, updated_at, bot_id, ${column})
|
||||
SELECT ?, ?, id, ? FROM bots`,
|
||||
values: [now, now, id],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BotExtensionsPayload } from "@/src/types";
|
||||
import grabCrewRole from "../access/grab-crew-role";
|
||||
import grabExtensionsOverview from "./grab-extensions-overview";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Who's looking: only super admins and admins (the super admin alone
|
||||
* for the coordinator) get what can be added.
|
||||
*/
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the bot is seen in: its MCP servers and tools show
|
||||
* too, not removable from the bot.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot's MCP servers and tools (given to it, and its workspace's there),
|
||||
* and the rest of them, which can be added.
|
||||
*/
|
||||
export default async function grabBotExtensionsOverview({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<BotExtensionsPayload> {
|
||||
const [role, bot] = await Promise.all([
|
||||
grabCrewRole({ user_id }),
|
||||
BunSQLite.sql<{ kind: string }>({ sql: `SELECT kind FROM bots WHERE id = ?`, values: [bot_id] }),
|
||||
]);
|
||||
|
||||
return grabExtensionsOverview({
|
||||
can_manage: role == "super_admin" || (role == "admin" && bot.payload?.[0]?.kind != "coordinator"),
|
||||
bot_id,
|
||||
workspace_id,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BUN_SQLITE_COBBLE_MCP_SERVERS } from "@/db/types/db";
|
||||
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
|
||||
import { McpTransports } from "@/src/dict/mcp-dict";
|
||||
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
|
||||
import parseCustomToolRow from "./parse-custom-tool-row";
|
||||
import parseMcpServerRow from "./parse-mcp-server-row";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Whether the viewer can give and take them: only then is what can be
|
||||
* added listed.
|
||||
*/
|
||||
can_manage: boolean;
|
||||
/**
|
||||
* A bot's: given to it, and (with `workspace_id`) that workspace's
|
||||
* too, not removable from the bot. Without it, only the workspace's.
|
||||
*/
|
||||
bot_id?: number | string | null;
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
type Linked<T> = T & { source: "bot" | "workspace" };
|
||||
|
||||
/**
|
||||
* The MCP servers and tools a bot or a workspace has, and the rest of
|
||||
* them, which can be added (`grabBotExtensionsOverview`,
|
||||
* `grabWorkspaceExtensionsOverview`).
|
||||
*/
|
||||
export default async function grabExtensionsOverview({
|
||||
can_manage,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<BotExtensionsPayload> {
|
||||
const bot = Number(bot_id || 0);
|
||||
const workspace = Number(workspace_id || 0);
|
||||
|
||||
const linked = (column: string, bot_link: string, workspace_link: string) => ({
|
||||
sql: `SELECT x.*, CASE WHEN x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?) THEN 'bot' ELSE 'workspace' END AS source
|
||||
FROM %TABLE% x WHERE x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
|
||||
OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)
|
||||
ORDER BY x.name COLLATE NOCASE ASC`,
|
||||
values: [bot, bot, workspace],
|
||||
});
|
||||
|
||||
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
|
||||
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
|
||||
|
||||
const [servers, tools, all_servers, all_tools] = await Promise.all([
|
||||
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
|
||||
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
|
||||
values: mcp_query.values,
|
||||
}),
|
||||
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>>({
|
||||
sql: tool_query.sql.replace("%TABLE%", "custom_tools"),
|
||||
values: tool_query.values,
|
||||
}),
|
||||
can_manage
|
||||
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
|
||||
: null,
|
||||
can_manage
|
||||
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` })
|
||||
: null,
|
||||
]);
|
||||
|
||||
const attached: BotExtension[] = [
|
||||
...(servers.payload || []).map((row) => {
|
||||
const server = parseMcpServerRow({ row });
|
||||
const transport = McpTransports.find((t) => t.value == server.transport)?.value == "stdio" ? "Command" : "Remote";
|
||||
const count = server.tools.filter((tool) => !server.excluded_tools.includes(tool.name)).length;
|
||||
|
||||
return {
|
||||
kind: "mcp" as const,
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
detail: `MCP · ${transport}${server.status == "ok" ? ` · ${count} ${count == 1 ? "tool" : "tools"}` : ""}`,
|
||||
state: server.status,
|
||||
enabled: server.enabled,
|
||||
source: row.source,
|
||||
};
|
||||
}),
|
||||
...(tools.payload || []).map((row) => {
|
||||
const tool = parseCustomToolRow({ row });
|
||||
|
||||
return {
|
||||
kind: "tool" as const,
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
detail: `Tool · ${CustomToolKinds.find((k) => k.value == tool.kind)?.title || tool.kind}`,
|
||||
state: tool.status,
|
||||
enabled: tool.enabled,
|
||||
source: row.source,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* A bot can be given one its workspace has; a workspace only what it
|
||||
* hasn't.
|
||||
*/
|
||||
const has = (kind: "mcp" | "tool", id: number) =>
|
||||
attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot"));
|
||||
|
||||
return {
|
||||
attached,
|
||||
addable: [
|
||||
...(all_servers?.payload || [])
|
||||
.filter((row) => !has("mcp", Number(row.id)))
|
||||
.map((row) => ({ kind: "mcp" as const, id: Number(row.id), name: row.name })),
|
||||
...(all_tools?.payload || [])
|
||||
.filter((row) => !has("tool", Number(row.id)))
|
||||
.map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })),
|
||||
],
|
||||
can_manage,
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,9 @@ export default async function grabToolsOverview({ user_id }: Params): Promise<Cu
|
||||
description: tool.description,
|
||||
kind: tool.kind,
|
||||
enabled: tool.enabled,
|
||||
status: tool.status,
|
||||
last_error: tool.last_error,
|
||||
last_checked_at: tool.last_checked_at,
|
||||
access: { all_bots: tool.all_bots, ...access(tool.id) },
|
||||
...(targets.can_edit ? { parameters: tool.parameters, config: tool.config } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { BotExtensionsPayload } from "@/src/types";
|
||||
import grabCrewRole from "../access/grab-crew-role";
|
||||
import grabExtensionsOverview from "./grab-extensions-overview";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Who's looking: only super admins and admins get what can be added.
|
||||
*/
|
||||
user_id: number | string;
|
||||
workspace_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The MCP servers and tools every bot working in a workspace gets there,
|
||||
* and the rest of them, which can be added.
|
||||
*/
|
||||
export default async function grabWorkspaceExtensionsOverview({
|
||||
user_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<BotExtensionsPayload> {
|
||||
const role = await grabCrewRole({ user_id });
|
||||
|
||||
return grabExtensionsOverview({ can_manage: role != "member", workspace_id });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS } from "@/db/types/db";
|
||||
import type { CustomToolStatus } from "@/src/dict/custom-tool-dict";
|
||||
import type { CustomToolRecord } from "@/src/types";
|
||||
import parseJsonColumn from "./parse-json-column";
|
||||
|
||||
@@ -20,6 +21,9 @@ export default function parseCustomToolRow({ row }: Params): CustomToolRecord {
|
||||
}),
|
||||
all_bots: row.all_bots == 1,
|
||||
enabled: row.enabled != 0,
|
||||
status: (row.status || "untested") as CustomToolStatus,
|
||||
last_error: row.last_error || "",
|
||||
last_checked_at: typeof row.last_checked_at == "number" ? row.last_checked_at : null,
|
||||
kind: row.kind || "prompt",
|
||||
config: parseJsonColumn<Record<string, unknown>>({ text: row.config, fallback: {} }),
|
||||
} as CustomToolRecord;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
custom_tool_id: number;
|
||||
/**
|
||||
* Left out when it worked.
|
||||
*/
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves how a custom tool's run went (`status`, `last_error`,
|
||||
* `last_checked_at`), for its row in Settings, under Tools. Never throws:
|
||||
* a run doesn't fail over its bookkeeping.
|
||||
*/
|
||||
export default async function recordCustomToolRun({ custom_tool_id, error }: Params) {
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE custom_tools SET status = ?, last_error = ?, last_checked_at = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, custom_tool_id],
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
mcp_server_id: number;
|
||||
/**
|
||||
* Left out when it connected.
|
||||
*/
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves whether a bot's run could connect to an MCP server, so its row in
|
||||
* Settings, under MCPs, shows a server that stopped answering (or came
|
||||
* back). Its tools listing is left as it was. Never throws.
|
||||
*/
|
||||
export default async function recordMcpServerConnection({ mcp_server_id, error }: Params) {
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE mcp_servers SET status = ?, last_error = ?, last_synced_at = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [error ? "error" : "ok", (error || "").slice(0, 4000), now, now, mcp_server_id],
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import attachToAllBots from "./attach-to-all-bots";
|
||||
import type { BUN_SQLITE_COBBLE_CUSTOM_TOOLS, BunSQLiteTables } from "@/db/types/db";
|
||||
import type { SaveCustomToolRequest } from "@/src/schemas/extension-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
@@ -33,6 +34,11 @@ export default async function saveCustomTool({ user_id, data }: Params) {
|
||||
config: JSON.stringify(data.config),
|
||||
enabled: data.enabled ? 1 : 0,
|
||||
all_bots: data.all_bots ? 1 : 0,
|
||||
/**
|
||||
* Whatever its last run showed was about the old version.
|
||||
*/
|
||||
status: "untested",
|
||||
last_error: "",
|
||||
} as const;
|
||||
|
||||
if (data.custom_tool_id) {
|
||||
@@ -45,6 +51,8 @@ export default async function saveCustomTool({ user_id, data }: Params) {
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the tool: ${res.msg}`);
|
||||
|
||||
if (data.all_bots && !tool.all_bots) await attachToAllBots({ kind: "tool", id: tool.id });
|
||||
|
||||
return tool.id;
|
||||
}
|
||||
|
||||
@@ -57,5 +65,7 @@ export default async function saveCustomTool({ user_id, data }: Params) {
|
||||
|
||||
if (!res.success || !id) throw new Error(`Couldn't add the tool: ${res.msg}`);
|
||||
|
||||
if (data.all_bots) await attachToAllBots({ kind: "tool", id: Number(id) });
|
||||
|
||||
return Number(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import attachToAllBots from "./attach-to-all-bots";
|
||||
import type { BUN_SQLITE_COBBLE_MCP_SERVERS, BunSQLiteTables } from "@/db/types/db";
|
||||
import { stopStdioMcpServer } from "@/src/harness/mcp/mcp-stdio-pool";
|
||||
import type { SaveMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
@@ -47,6 +48,8 @@ export default async function saveMcpServer({ user_id, data }: Params) {
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the MCP server: ${res.msg}`);
|
||||
|
||||
if (data.all_bots && !server.all_bots) await attachToAllBots({ kind: "mcp", id: server.id });
|
||||
|
||||
return server.id;
|
||||
}
|
||||
|
||||
@@ -67,6 +70,8 @@ export default async function saveMcpServer({ user_id, data }: Params) {
|
||||
|
||||
if (!res.success || !id) throw new Error(`Couldn't add the MCP server: ${res.msg}`);
|
||||
|
||||
if (data.all_bots) await attachToAllBots({ kind: "mcp", id: Number(id) });
|
||||
|
||||
return Number(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertBotExtensionManager from "./assert-bot-extension-manager";
|
||||
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
kind: ExtensionKind;
|
||||
id: number;
|
||||
/**
|
||||
* Gives it to the bot, or takes it away.
|
||||
*/
|
||||
attached: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives a bot an MCP server or tool, or takes it away, even one set to
|
||||
* "every bot" (that only decides what bots start with). What its
|
||||
* workspaces have stays theirs. Returns the bot and the item's name.
|
||||
*/
|
||||
export default async function setBotExtension({ user_id, bot_id, kind, id, attached }: Params) {
|
||||
const bot = await assertBotExtensionManager({ user_id, bot_id });
|
||||
const { table, bot_link, column, label } = ExtensionTables[kind];
|
||||
|
||||
const item = (
|
||||
await BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM ${table} WHERE id = ?`, values: [id] })
|
||||
).payload?.[0];
|
||||
|
||||
if (!item) throw new AppError(`That ${label} doesn't exist.`);
|
||||
|
||||
const now = Date.now();
|
||||
const res = attached
|
||||
? await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO ${bot_link} (created_at, updated_at, bot_id, ${column}) VALUES (?, ?, ?, ?)`,
|
||||
values: [now, now, bot.id, id],
|
||||
})
|
||||
: await BunSQLite.sql({
|
||||
sql: `DELETE FROM ${bot_link} WHERE bot_id = ? AND ${column} = ?`,
|
||||
values: [bot.id, id],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't change the bot's ${label}s: ${res.error}`);
|
||||
|
||||
return { bot, name: item.name };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertCrewManager from "../access/assert-crew-manager";
|
||||
import attachToAllBots from "./attach-to-all-bots";
|
||||
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||
|
||||
type Params = {
|
||||
@@ -18,13 +19,18 @@ type Params = {
|
||||
/**
|
||||
* Attaches an MCP server or tool to exactly these bots and workspaces, for
|
||||
* super admins and admins. An admin never sees the coordinator, so its
|
||||
* attachment is kept as it is; only a super admin sets "all bots".
|
||||
* attachment is kept as it is; only a super admin sets "every bot", which
|
||||
* when turned on gives it to every bot (`attachToAllBots`) and every new
|
||||
* one. Turning it off takes it from no one.
|
||||
*/
|
||||
export default async function setExtensionAccess({ user_id, kind, id, bot_ids, workspace_ids, all_bots }: Params) {
|
||||
const role = await assertCrewManager({ user_id });
|
||||
const { table, bot_link, workspace_link, column, label } = ExtensionTables[kind];
|
||||
|
||||
const found = await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM ${table} WHERE id = ?`, values: [id] });
|
||||
const found = await BunSQLite.sql<{ id: number; all_bots: number }>({
|
||||
sql: `SELECT id, all_bots FROM ${table} WHERE id = ?`,
|
||||
values: [id],
|
||||
});
|
||||
|
||||
if (!found.payload?.[0]) throw new AppError(`That ${label} doesn't exist.`);
|
||||
|
||||
@@ -55,5 +61,7 @@ export default async function setExtensionAccess({ user_id, kind, id, bot_ids, w
|
||||
sql: `UPDATE ${table} SET all_bots = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [all_bots ? 1 : 0, now, id],
|
||||
});
|
||||
|
||||
if (all_bots && found.payload?.[0]?.all_bots != 1) await attachToAllBots({ kind, id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import assertCrewManager from "../access/assert-crew-manager";
|
||||
import { ExtensionTables, type ExtensionKind } from "./extension-tables";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
workspace_id: number | string;
|
||||
kind: ExtensionKind;
|
||||
id: number;
|
||||
/**
|
||||
* Gives it to the workspace, or takes it away.
|
||||
*/
|
||||
attached: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives a workspace an MCP server or tool (every bot working there gets
|
||||
* it, there), or takes it away; for super admins and admins. Returns the
|
||||
* workspace and the item's name.
|
||||
*/
|
||||
export default async function setWorkspaceExtension({ user_id, workspace_id, kind, id, attached }: Params) {
|
||||
await assertCrewManager({ user_id });
|
||||
|
||||
const { table, workspace_link, column, label } = ExtensionTables[kind];
|
||||
|
||||
const [workspace, item] = await Promise.all([
|
||||
BunSQLite.sql<{ id: number; name: string }>({
|
||||
sql: `SELECT id, name FROM workspaces WHERE id = ?`,
|
||||
values: [workspace_id],
|
||||
}).then((res) => res.payload?.[0]),
|
||||
BunSQLite.sql<{ id: number; name: string }>({
|
||||
sql: `SELECT id, name FROM ${table} WHERE id = ?`,
|
||||
values: [id],
|
||||
}).then((res) => res.payload?.[0]),
|
||||
]);
|
||||
|
||||
if (!workspace) throw new AppError(`That workspace doesn't exist.`);
|
||||
if (!item) throw new AppError(`That ${label} doesn't exist.`);
|
||||
|
||||
const now = Date.now();
|
||||
const res = attached
|
||||
? await BunSQLite.sql({
|
||||
sql: `INSERT OR IGNORE INTO ${workspace_link} (created_at, updated_at, workspace_id, ${column}) VALUES (?, ?, ?, ?)`,
|
||||
values: [now, now, workspace.id, id],
|
||||
})
|
||||
: await BunSQLite.sql({
|
||||
sql: `DELETE FROM ${workspace_link} WHERE workspace_id = ? AND ${column} = ?`,
|
||||
values: [workspace.id, id],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't change the workspace's ${label}s: ${res.error}`);
|
||||
|
||||
return { workspace, name: item.name };
|
||||
}
|
||||
@@ -2,11 +2,20 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import buildCustomTool from "@/src/harness/tools/custom/build-custom-tool";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import type { DraftCustomTool } from "@/src/schemas/extension-schema";
|
||||
import type { CustomToolRecord } from "@/src/types";
|
||||
import grabCustomTool from "./grab-custom-tool";
|
||||
import grabRunEnvs from "./grab-run-envs";
|
||||
|
||||
type Params = {
|
||||
custom_tool_id: number | string;
|
||||
/**
|
||||
* A saved tool: the run counts as its last one.
|
||||
*/
|
||||
custom_tool_id?: number | string;
|
||||
/**
|
||||
* The tool as its form has it, saved or not: nothing is recorded.
|
||||
*/
|
||||
draft?: DraftCustomTool;
|
||||
args: Record<string, unknown>;
|
||||
/**
|
||||
* Where a script tool runs. The main computer when not given.
|
||||
@@ -17,23 +26,38 @@ type Params = {
|
||||
/**
|
||||
* Runs a custom tool once with these arguments, as a bot would outside a
|
||||
* workspace (general envs, the main computer for a script), and returns
|
||||
* what the bot would read: its result, or `Error: ...`.
|
||||
* what the bot would read (its result, or `Error: ...`) and whether it
|
||||
* worked. A saved tool's run counts as its last one (`buildCustomTool`);
|
||||
* a draft's isn't recorded.
|
||||
*/
|
||||
export default async function testCustomTool({ custom_tool_id, args, computer_id }: Params) {
|
||||
const tool = await grabCustomTool({ custom_tool_id });
|
||||
export default async function testCustomTool({ custom_tool_id, draft, args, computer_id }: Params) {
|
||||
const tool: CustomToolRecord = draft
|
||||
? ({
|
||||
...draft,
|
||||
id: 0,
|
||||
name: "draft",
|
||||
description: "",
|
||||
all_bots: false,
|
||||
enabled: true,
|
||||
status: "untested",
|
||||
last_error: "",
|
||||
last_checked_at: null,
|
||||
} as CustomToolRecord)
|
||||
: await grabCustomTool({ custom_tool_id: custom_tool_id || 0 });
|
||||
const main =
|
||||
computer_id ||
|
||||
(await BunSQLite.sql<{ id: number }>({ sql: `SELECT id FROM computers WHERE kind = 'main' ORDER BY id LIMIT 1` }))
|
||||
.payload?.[0]?.id;
|
||||
|
||||
const harness_tool = buildCustomTool({ tool, envs: await grabRunEnvs(), computer_id: main });
|
||||
const harness_tool = buildCustomTool({ tool, envs: await grabRunEnvs(), computer_id: main, record: !draft });
|
||||
|
||||
if (!harness_tool) throw new AppError(`There's no computer to run this script on.`);
|
||||
|
||||
try {
|
||||
return await harness_tool.run({ args, signal: AbortSignal.timeout(AppData.CustomToolMaxTimeoutMs) });
|
||||
const output = await harness_tool.run({ args, signal: AbortSignal.timeout(AppData.CustomToolMaxTimeoutMs) });
|
||||
return { output, ok: true };
|
||||
} catch (error: unknown) {
|
||||
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
return { output: `Error: ${error instanceof Error ? error.message : String(error)}`, ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import testMcpServer from "@/src/harness/mcp/test-mcp-server";
|
||||
import type { TestMcpServerRequest } from "@/src/schemas/extension-schema";
|
||||
import type { McpToolInfo } from "@/src/types";
|
||||
import mcpServerSlug from "@/src/utils/mcp-server-slug";
|
||||
import redactSecrets from "@/src/utils/redact-secrets";
|
||||
import grabRunEnvs from "./grab-run-envs";
|
||||
|
||||
type Params = {
|
||||
data: TestMcpServerRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Connects to an MCP server as its form has it (saved or not) and lists
|
||||
* its tools, saving nothing. Never throws for the server's own failure:
|
||||
* returns why, secrets blanked out.
|
||||
*/
|
||||
export default async function testMcpDraft({
|
||||
data,
|
||||
}: Params): Promise<{ ok: true; tools: McpToolInfo[] } | { ok: false; error: string }> {
|
||||
const envs = await grabRunEnvs();
|
||||
|
||||
try {
|
||||
const tools = await testMcpServer({
|
||||
server: {
|
||||
id: data.mcp_server_id || 0,
|
||||
name: data.name || "The MCP server",
|
||||
slug: mcpServerSlug({ name: data.name }),
|
||||
description: data.description,
|
||||
transport: data.transport,
|
||||
url: data.url,
|
||||
headers: data.headers,
|
||||
command: data.command,
|
||||
setup_command: data.setup_command,
|
||||
env: data.env,
|
||||
excluded_tools: data.excluded_tools,
|
||||
tools: [],
|
||||
all_bots: data.all_bots,
|
||||
enabled: data.enabled,
|
||||
status: "untested",
|
||||
last_error: "",
|
||||
last_synced_at: null,
|
||||
},
|
||||
envs,
|
||||
});
|
||||
|
||||
return { ok: true, tools };
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
ok: false,
|
||||
error: redactSecrets({
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
secrets: envs.filter((env) => env.secret),
|
||||
}).slice(0, 4000),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user