Add MCP servers and custom tools for bots

Two new settings pages, MCPs and Tools. Super admins define them; super
admins and admins attach them to bots, to workspaces (every bot there),
or to every bot. Bots get them in chat replies and tasks.

- Custom tools: a JSON-schema of arguments plus an HTTP request, a script
  on the bot's computer, or a prompt, templated with {{args.x}} and
  {{env.KEY}}, secrets redacted from results.
- MCP servers: remote (URL and headers) or stdio commands run on the host
  with a setup command and an isolated env, in a per-process pool. Tools
  are listed into a cache and connect on first call.
- Add, edit, access and try open in modals.
- Markdown, JSON and command fields use twui's AceEditor (CodeEditor),
  its scripts loaded on demand.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 15:01:39 +01:00
co-authored by Claude Opus 5.5
parent 550aaeabac
commit c6ec7d77a2
95 changed files with 4733 additions and 164 deletions
@@ -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)}`;
}
}