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
+134
View File
@@ -0,0 +1,134 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import {
CustomToolHttpMethods,
CustomToolScriptLanguages,
type CustomToolScriptLanguage,
} from "@/src/dict/custom-tool-dict";
import { McpTransports, type McpTransport } from "@/src/dict/mcp-dict";
import checkCustomToolName from "@/src/utils/check-custom-tool-name";
const Id = z.coerce.number().int().positive();
const Ids = z.array(Id).max(1000).default([]);
/**
* Header or env names to their (template) values.
*/
const TemplateMap = z
.record(z.string().trim().min(1).max(200), z.string().max(4000))
.default({});
const TimeoutMs = z.coerce.number().int().min(1000).max(AppData.CustomToolMaxTimeoutMs).optional();
export const SaveMcpServerRequestSchema = z
.object({
mcp_server_id: Id.optional(),
name: z.string().trim().min(1, "Enter a name").max(80),
description: z.string().trim().max(1000).default(""),
transport: z.enum(McpTransports.map((t) => t.value) as [McpTransport, ...McpTransport[]]),
url: z.string().trim().max(2000).default(""),
headers: TemplateMap,
command: z.string().trim().max(4000).default(""),
setup_command: z.string().trim().max(8000).default(""),
env: TemplateMap,
excluded_tools: z.array(z.string().max(200)).max(500).default([]),
enabled: z.boolean().default(true),
all_bots: z.boolean().default(false),
})
.superRefine((value, ctx) => {
if (value.transport == "remote" && !/^(https?:\/\/|\{\{\s*env\.)/.test(value.url)) {
ctx.addIssue({ code: "custom", path: ["url"], message: "Enter the server's http(s) URL." });
}
if (value.transport == "stdio" && !value.command) {
ctx.addIssue({ code: "custom", path: ["command"], message: "Enter the command that starts the server." });
}
});
export const McpServerIdRequestSchema = z.object({
mcp_server_id: Id,
});
export const SyncMcpServerRequestSchema = McpServerIdRequestSchema.extend({
/**
* Runs a stdio server's setup again first.
*/
reinstall: z.boolean().default(false),
});
export const SetExtensionAccessRequestSchema = z.object({
id: Id,
bot_ids: Ids,
workspace_ids: Ids,
all_bots: z.boolean().optional(),
});
/**
* A tool's arguments: a JSON schema of an object.
*/
const ToolParameters = z
.record(z.string(), z.unknown())
.refine((value) => value.type == "object", { message: `The arguments schema must have "type": "object".` })
.refine((value) => JSON.stringify(value).length <= 20000, { message: "Keep the arguments schema under 20,000 characters." });
const ToolConfig = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("http"),
config: z.object({
method: z.enum(CustomToolHttpMethods),
url: z.string().trim().min(1, "Enter the URL").max(4000),
headers: TemplateMap,
body: z.string().max(20000).default(""),
timeout_ms: TimeoutMs,
}),
}),
z.object({
kind: z.literal("script"),
config: z.object({
language: z.enum(
CustomToolScriptLanguages.map((l) => l.value) as [CustomToolScriptLanguage, ...CustomToolScriptLanguage[]],
),
code: z.string().min(1, "Enter the code").max(60000, "Keep the code under 60,000 characters"),
timeout_ms: TimeoutMs,
}),
}),
z.object({
kind: z.literal("prompt"),
config: z.object({
prompt: z.string().trim().min(1, "Enter the prompt").max(20000),
}),
}),
]);
export const SaveCustomToolRequestSchema = z
.object({
custom_tool_id: Id.optional(),
name: z
.string()
.trim()
.superRefine((name, ctx) => {
const problem = checkCustomToolName({ name });
if (problem) ctx.addIssue({ code: "custom", message: problem });
}),
description: z.string().trim().min(1, "Describe what it does, for the bots").max(2000),
parameters: ToolParameters,
enabled: z.boolean().default(true),
all_bots: z.boolean().default(false),
})
.and(ToolConfig);
export const CustomToolIdRequestSchema = z.object({
custom_tool_id: Id,
});
export const TestCustomToolRequestSchema = z.object({
custom_tool_id: Id,
args: z.record(z.string(), z.unknown()).default({}),
/**
* Where a script tool runs. The main computer when not given.
*/
computer_id: Id.optional(),
});
export type SaveMcpServerRequest = z.infer<typeof SaveMcpServerRequestSchema>;
export type SaveCustomToolRequest = z.infer<typeof SaveCustomToolRequestSchema>;