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
+52
View File
@@ -0,0 +1,52 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import deleteMcpServer from "@/src/functions/backend/db/extensions/delete-mcp-server";
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
import { McpServerIdRequestSchema } from "@/src/schemas/extension-schema";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { McpServersOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: deletes an MCP server. Bots lose its tools at once.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<McpServersOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const { mcp_server_id } = parseWithSchema({ schema: McpServerIdRequestSchema, data: body });
const server = await deleteMcpServer({ mcp_server_id });
await logEvent({
title: "MCP server deleted",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} deleted the MCP server ${server.name} (#${server.id}).`,
metadata: { mcp_server_id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Deleted ${server.name}.`,
singleRes: await grabMcpsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+59
View File
@@ -0,0 +1,59 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
import saveMcpServer from "@/src/functions/backend/db/extensions/save-mcp-server";
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
import { SaveMcpServerRequestSchema } from "@/src/schemas/extension-schema";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { McpServersOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: adds or edits an MCP server, then lists its tools
* (starting a stdio one after its setup) so bots can use them.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<McpServersOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const data = parseWithSchema({ schema: SaveMcpServerRequestSchema, data: body });
const mcp_server_id = await saveMcpServer({ user_id: user.id, data });
const sync = data.enabled ? await syncMcpServer({ mcp_server_id }) : null;
await logEvent({
title: data.mcp_server_id ? "MCP server changed" : "MCP server added",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} ${data.mcp_server_id ? "changed" : "added"} the MCP server ${data.name} (#${mcp_server_id}, ${data.transport}).`,
metadata: { mcp_server_id, transport: data.transport },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: !sync
? `Saved ${data.name}. It's off.`
: sync.ok
? `Saved ${data.name}: ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
: `Saved ${data.name}, but it didn't connect.`,
singleRes: await grabMcpsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,59 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
import type { McpServersOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admins and admins: attaches an MCP server to exactly these bots and
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<McpServersOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
await setExtensionAccess({ user_id: user.id, kind: "mcp", ...access });
await logEvent({
title: "MCP server access changed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} changed who gets the MCP server #${access.id}.`,
metadata: access,
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved who gets it.`,
singleRes: await grabMcpsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+57
View File
@@ -0,0 +1,57 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabMcpsOverview from "@/src/functions/backend/db/extensions/grab-mcps-overview";
import syncMcpServer from "@/src/functions/backend/db/extensions/sync-mcp-server";
import { SyncMcpServerRequestSchema } from "@/src/schemas/extension-schema";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { McpServersOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: lists an MCP server's tools again, optionally running
* a stdio server's setup again first (Reinstall).
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<McpServersOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const { mcp_server_id, reinstall } = parseWithSchema({ schema: SyncMcpServerRequestSchema, data: body });
const sync = await syncMcpServer({ mcp_server_id, reinstall });
if (reinstall) {
await logEvent({
title: "MCP server reinstalled",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} reinstalled the MCP server #${mcp_server_id}.`,
metadata: { mcp_server_id },
ip_address: grabRequestIP(req),
});
}
return {
success: true,
msg: sync.ok
? `Connected: ${sync.tools.length} ${sync.tools.length == 1 ? "tool" : "tools"}.`
: `It didn't connect.`,
singleRes: await grabMcpsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+52
View File
@@ -0,0 +1,52 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import deleteCustomTool from "@/src/functions/backend/db/extensions/delete-custom-tool";
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
import { CustomToolIdRequestSchema } from "@/src/schemas/extension-schema";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { CustomToolsOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: deletes a custom tool. Bots lose it at once.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<CustomToolsOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const { custom_tool_id } = parseWithSchema({ schema: CustomToolIdRequestSchema, data: body });
const tool = await deleteCustomTool({ custom_tool_id });
await logEvent({
title: "Tool deleted",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} deleted the tool ${tool.name} (#${tool.id}).`,
metadata: { custom_tool_id },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Deleted ${tool.name}.`,
singleRes: await grabToolsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+52
View File
@@ -0,0 +1,52 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
import saveCustomTool from "@/src/functions/backend/db/extensions/save-custom-tool";
import { SaveCustomToolRequestSchema } from "@/src/schemas/extension-schema";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { CustomToolsOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: adds or edits a custom tool.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<CustomToolsOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const data = parseWithSchema({ schema: SaveCustomToolRequestSchema, data: body });
const custom_tool_id = await saveCustomTool({ user_id: user.id, data });
await logEvent({
title: data.custom_tool_id ? "Tool changed" : "Tool added",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} ${data.custom_tool_id ? "changed" : "added"} the tool ${data.name} (#${custom_tool_id}, ${data.kind}).`,
metadata: { custom_tool_id, kind: data.kind },
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved ${data.name}.`,
singleRes: await grabToolsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,59 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabToolsOverview from "@/src/functions/backend/db/extensions/grab-tools-overview";
import setExtensionAccess from "@/src/functions/backend/db/extensions/set-extension-access";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SetExtensionAccessRequestSchema } from "@/src/schemas/extension-schema";
import type { CustomToolsOverviewPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admins and admins: attaches a custom tool to exactly these bots and
* workspaces. Only a super admin sets "all bots" (`setExtensionAccess`).
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<CustomToolsOverviewPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const access = parseWithSchema({ schema: SetExtensionAccessRequestSchema, data: body });
await setExtensionAccess({ user_id: user.id, kind: "tool", ...access });
await logEvent({
title: "Tool access changed",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} changed who gets the tool #${access.id}.`,
metadata: access,
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved who gets it.`,
singleRes: await grabToolsOverview({ user_id: user.id }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+48
View File
@@ -0,0 +1,48 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import testCustomTool from "@/src/functions/backend/db/extensions/test-custom-tool";
import { TestCustomToolRequestSchema } from "@/src/schemas/extension-schema";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import type { CustomToolTestPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Super admin only: runs a custom tool once with sample arguments and
* returns what a bot would read.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<CustomToolTestPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const user = await grabSuperAdminUser({ req });
if (!user?.id) {
throw new AppError(`Only a super admin can do that.`);
}
const { custom_tool_id, args, computer_id } = parseWithSchema({ schema: TestCustomToolRequestSchema, data: body });
const output = await testCustomTool({ custom_tool_id, args, computer_id });
await logEvent({
title: "Tool tried",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} tried the tool #${custom_tool_id}.`,
metadata: { custom_tool_id },
ip_address: grabRequestIP(req),
});
return { success: true, msg: "Ran it.", singleRes: { output } };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};