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:
2026-10-19 15:35:13 +01:00
co-authored by Claude Opus 5.5
parent c6ec7d77a2
commit 6294b911e6
69 changed files with 2026 additions and 271 deletions
@@ -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),
};
}
}