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:
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user