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
+39
View File
@@ -0,0 +1,39 @@
import { Database } from "bun:sqlite";
import { beforeAll, describe, expect, test } from "bun:test";
import botExtensionsSql from "./bot-extensions-sql";
const db = new Database(":memory:");
beforeAll(() => {
db.run(`CREATE TABLE custom_tools (id INTEGER PRIMARY KEY, all_bots INTEGER, enabled INTEGER)`);
db.run(`CREATE TABLE bot_custom_tools (bot_id INTEGER, custom_tool_id INTEGER)`);
db.run(`CREATE TABLE workspace_custom_tools (workspace_id INTEGER, custom_tool_id INTEGER)`);
/**
* 1: every bot. 2: bot 5. 3: workspace 9. 4: every bot, off.
* 5: nobody.
*/
db.run(`INSERT INTO custom_tools VALUES (1, 1, 1), (2, 0, 1), (3, 0, 1), (4, 1, 0), (5, 0, 1)`);
db.run(`INSERT INTO bot_custom_tools VALUES (5, 2)`);
db.run(`INSERT INTO workspace_custom_tools VALUES (9, 3)`);
});
function ids(params: Parameters<typeof botExtensionsSql>[0]) {
const { sql, values } = botExtensionsSql(params);
return db
.query(sql)
.all(...values)
.map((row) => (row as { id: number }).id);
}
describe("botExtensionsSql", () => {
test("a bot gets every-bot ones and its own, never disabled ones", () => {
expect(ids({ kind: "tool", bot_id: 5 })).toEqual([1, 2]);
expect(ids({ kind: "tool", bot_id: 6 })).toEqual([1]);
});
test("in a workspace, the workspace's too", () => {
expect(ids({ kind: "tool", bot_id: 6, workspace_id: 9 })).toEqual([1, 3]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 9 })).toEqual([1, 2, 3]);
expect(ids({ kind: "tool", bot_id: 5, workspace_id: 8 })).toEqual([1, 2]);
});
});
+44
View File
@@ -0,0 +1,44 @@
type Params = {
/**
* Which kind: MCP servers or custom tools.
*/
kind: "mcp" | "tool";
bot_id: number | string;
/**
* The workspace the run is in: what's attached to it counts too.
*/
workspace_id?: number | string | null;
};
const Tables = {
mcp: {
table: "mcp_servers",
bot_link: "bot_mcp_servers",
workspace_link: "workspace_mcp_servers",
column: "mcp_server_id",
},
tool: {
table: "custom_tools",
bot_link: "bot_custom_tools",
workspace_link: "workspace_custom_tools",
column: "custom_tool_id",
},
} as const;
/**
* The enabled MCP servers or custom tools a bot gets on one run: those
* for every bot, those attached to the bot, and in a workspace those
* attached to the workspace.
*/
export default function botExtensionsSql({ kind, bot_id, workspace_id }: Params) {
const { table, bot_link, workspace_link, column } = Tables[kind];
return {
sql: `SELECT x.* FROM ${table} x WHERE x.enabled = 1 AND (
x.all_bots = 1
OR x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
${workspace_id ? `OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)` : ""}
) ORDER BY x.id ASC`,
values: workspace_id ? [Number(bot_id), Number(workspace_id)] : [Number(bot_id)],
};
}
+28
View File
@@ -0,0 +1,28 @@
import { ReservedToolNames, ReservedToolPrefixes } from "@/src/dict/custom-tool-dict";
type Params = {
name: string;
};
/**
* Why a custom tool can't take this name, or null when it can: snake_case
* of 2 to 64 characters, never a built-in tool's name or a computer tool's
* prefix, and never with `__` (MCP tools use it, `<server>__<tool>`).
*/
export default function checkCustomToolName({ name }: Params): string | null {
if (!/^[a-z][a-z0-9_]{1,63}$/.test(name)) {
return `Use lowercase letters, numbers and underscores, starting with a letter (2 to 64 characters).`;
}
if (name.includes("__")) return `Don't use a double underscore: MCP tools use it.`;
if ((ReservedToolNames as readonly string[]).includes(name)) {
return `${name} is a built-in tool's name.`;
}
const prefix = ReservedToolPrefixes.find((p) => name.startsWith(p));
if (prefix) return `Names starting with ${prefix} are kept for computers' tools.`;
return null;
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import checkCustomToolName from "./check-custom-tool-name";
import mcpServerSlug from "./mcp-server-slug";
import mcpToolName from "./mcp-tool-name";
describe("checkCustomToolName", () => {
test("takes snake_case", () => {
expect(checkCustomToolName({ name: "get_weather" })).toBeNull();
});
test("refuses built-ins, computer prefixes, double underscores and bad shapes", () => {
expect(checkCustomToolName({ name: "start_task" })).toContain("built-in");
expect(checkCustomToolName({ name: "sandbox_run" })).toContain("sandbox_");
expect(checkCustomToolName({ name: "a__b" })).toContain("double underscore");
expect(checkCustomToolName({ name: "Get-Weather" })).toContain("lowercase");
expect(checkCustomToolName({ name: "1abc" })).toContain("lowercase");
});
});
describe("mcpToolName", () => {
test("prefixes with the slug, cleans and caps the name", () => {
expect(mcpToolName({ slug: "github", name: "create.issue" })).toBe("github__create_issue");
expect(mcpToolName({ slug: "s", name: "x".repeat(80) })).toHaveLength(64);
});
});
describe("mcpServerSlug", () => {
test("slugs a name", () => {
expect(mcpServerSlug({ name: "GitHub (work)" })).toBe("github_work");
expect(mcpServerSlug({ name: "!!!" })).toBe("mcp");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import fillTemplate from "./fill-template";
describe("fillTemplate", () => {
test("fills args and envs", () => {
expect(
fillTemplate({
template: "https://api.test/{{args.city}}?key={{ env.API_KEY }}",
args: { city: "Lagos" },
envs: [{ key: "API_KEY", value: "abc" }],
}),
).toBe("https://api.test/Lagos?key=abc");
});
test("objects become JSON, missing args empty, dotted paths reach in", () => {
expect(
fillTemplate({
template: "{{args.filter}}|{{args.missing}}|{{args.filter.tag}}",
args: { filter: { tag: "x" } },
}),
).toBe('{"tag":"x"}||x');
});
test("encodes filled arguments only", () => {
expect(
fillTemplate({
template: "{{env.BASE}}/q?s={{args.q}}&x=a b",
args: { q: "a b&c" },
envs: [{ key: "BASE", value: "https://x.test/v1" }],
encode: encodeURIComponent,
}),
).toBe("https://x.test/v1/q?s=a%20b%26c&x=a b");
});
test("an unset env throws, naming it", () => {
expect(() => fillTemplate({ template: "{{env.NOPE}}" })).toThrow("NOPE");
});
test("leaves other braces alone", () => {
expect(fillTemplate({ template: '{"a": "{{other}}"}' })).toBe('{"a": "{{other}}"}');
});
});
+53
View File
@@ -0,0 +1,53 @@
type Params = {
template: string;
/**
* `{{args.name}}`, dotted paths reach into objects. Missing ones fill
* in empty; objects and arrays as JSON.
*/
args?: Record<string, unknown>;
/**
* `{{env.KEY}}`. A key that isn't set throws, naming it.
*/
envs?: { key: string; value: string }[];
/**
* Applied to every filled-in argument, e.g. `encodeURIComponent` in a
* URL. Envs go in as they are (one may hold a whole base URL).
*/
encode?: (value: string) => string;
};
const Pattern = /\{\{\s*(args|env)\.([A-Za-z0-9_.-]+)\s*\}\}/g;
/**
* Fills a custom tool's or MCP server's template: `{{args.x}}` from the
* model's arguments, `{{env.KEY}}` from the envs. Anything else is left as
* written.
*/
export default function fillTemplate({ template, args = {}, envs = [], encode = (v) => v }: Params) {
return template.replace(Pattern, (_match, source: string, path: string) => {
if (source == "env") {
const env = envs.find((e) => e.key == path);
if (!env) throw new Error(`The env ${path} isn't set. Add it in Settings, under Environment.`);
return env.value;
}
return encode(stringify({ value: pick({ value: args, path }) }));
});
}
function pick({ value, path }: { value: unknown; path: string }) {
return path.split(".").reduce<unknown>((current, key) => {
if (current && typeof current == "object") return (current as Record<string, unknown>)[key];
return undefined;
}, value);
}
function stringify({ value }: { value: unknown }) {
if (value === undefined || value === null) return "";
if (typeof value == "string") return value;
if (typeof value == "number" || typeof value == "boolean") return String(value);
return JSON.stringify(value);
}
+37
View File
@@ -0,0 +1,37 @@
const AceScripts = [
"https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ace.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/ace/1.22.0/ext-language_tools.min.js",
];
let loading: Promise<void> | undefined;
/**
* Loads the ACE editor's scripts once per page, in order (the language
* tools need ACE itself), so only pages with an editor download them.
* twui's `AceEditor` waits for `window.ace` on its own.
*/
export default function loadAceScripts() {
if (typeof window == "undefined") return Promise.resolve();
loading ||= AceScripts.reduce(
(previous, src) =>
previous.then(
() =>
new Promise<void>((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const script = document.createElement("script");
script.src = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Couldn't load ${src}`));
document.head.appendChild(script);
}),
),
Promise.resolve(),
).catch((error) => {
loading = undefined;
throw error;
});
return loading;
}
+18
View File
@@ -0,0 +1,18 @@
type Params = {
name: string;
};
/**
* A server's slug from its name: lowercase letters, numbers and single
* underscores, at most 24 characters, so its tools' names stay short.
*/
export default function mcpServerSlug({ name }: Params) {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 24)
.replace(/_+$/g, "");
return slug || "mcp";
}
+18
View File
@@ -0,0 +1,18 @@
type Params = {
/**
* The server's slug (`mcp_servers.slug`).
*/
slug: string;
/**
* The tool's name on the server.
*/
name: string;
};
/**
* What a bot calls an MCP server's tool: `<slug>__<tool>`, only letters,
* numbers, `_` and `-`, at most 64 characters (what providers accept).
*/
export default function mcpToolName({ slug, name }: Params) {
return `${slug}__${name.replace(/[^A-Za-z0-9_-]/g, "_")}`.slice(0, 64);
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test";
import parseTemplateLines, { templateLines } from "./parse-template-lines";
describe("parseTemplateLines", () => {
test("reads headers, keeping colons in values", () => {
expect(
parseTemplateLines({ text: "Authorization: Bearer {{env.T}}\n\nX-Url: http://a:1\nbad line", separator: ":" }),
).toEqual({ Authorization: "Bearer {{env.T}}", "X-Url": "http://a:1" });
});
test("reads envs and writes them back", () => {
const map = parseTemplateLines({ text: "A=1\nB = x=y", separator: "=" });
expect(map).toEqual({ A: "1", B: "x=y" });
expect(templateLines({ map, separator: "=" })).toBe("A=1\nB=x=y");
});
});
+35
View File
@@ -0,0 +1,35 @@
type Params = {
text: string;
/**
* What separates a name from its value: `:` for headers, `=` for envs.
*/
separator: ":" | "=";
};
/**
* Reads one `Name: value` (or `NAME=value`) per line into a map, for the
* header and env fields of an MCP server or tool. Blank lines and lines
* without the separator are skipped; the value is everything after the
* first one, trimmed.
*/
export default function parseTemplateLines({ text, separator }: Params): Record<string, string> {
const map: Record<string, string> = {};
for (const line of text.split("\n")) {
const at = line.indexOf(separator);
const name = at > 0 ? line.slice(0, at).trim() : "";
if (name) map[name] = line.slice(at + 1).trim();
}
return map;
}
/**
* The other way: a map as one `Name: value` (or `NAME=value`) per line.
*/
export function templateLines({ map, separator }: { map?: Record<string, string>; separator: ":" | "=" }) {
return Object.entries(map || {})
.map(([name, value]) => `${name}${separator == ":" ? ": " : "="}${value}`)
.join("\n");
}