diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 2a56a9b0..bc62577b 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -689,6 +689,25 @@
"View the API documentation for usage details.": "View the API documentation for usage details.",
"View the MCP documentation.": "View the MCP documentation.",
"AI / Models": "AI / Models",
+ "AI / External tools (MCP)": "AI / External tools (MCP)",
+ "Add server": "Add server",
+ "Edit server": "Edit server",
+ "Delete server": "Delete server",
+ "Are you sure you want to delete this MCP server?": "Are you sure you want to delete this MCP server?",
+ "No external servers configured": "No external servers configured",
+ "Server name": "Server name",
+ "Transport": "Transport",
+ "URL": "URL",
+ "Authorization header": "Authorization header",
+ "Tool allowlist": "Tool allowlist",
+ "Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.",
+ "Use Tavily preset": "Use Tavily preset",
+ "Test": "Test",
+ "Available tools": "Available tools",
+ "No tools available": "No tools available",
+ "Created successfully": "Created successfully",
+ "Deleted successfully": "Deleted successfully",
+ "Clear": "Clear",
"Provider": "Provider",
"•••• set": "•••• set",
"Clear key": "Clear key",
diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx
new file mode 100644
index 00000000..3e6a8958
--- /dev/null
+++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx
@@ -0,0 +1,291 @@
+import { useEffect, useState } from "react";
+import { z } from "zod/v4";
+import {
+ Alert,
+ Button,
+ Group,
+ List,
+ PasswordInput,
+ Select,
+ Stack,
+ Switch,
+ TagsInput,
+ Text,
+ TextInput,
+} from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { zod4Resolver } from "mantine-form-zod-resolver";
+import { IconCheck, IconX } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import {
+ useCreateAiMcpServerMutation,
+ useUpdateAiMcpServerMutation,
+ useTestAiMcpServerMutation,
+} from "@/features/workspace/queries/ai-mcp-server-query.ts";
+import {
+ IAiMcpServer,
+ IAiMcpServerCreate,
+ IAiMcpServerUpdate,
+ McpTransport,
+} from "@/features/workspace/services/ai-mcp-server-service.ts";
+
+const formSchema = z.object({
+ name: z.string().min(1),
+ transport: z.enum(["http", "sse"]),
+ url: z.string().min(1),
+ // Write-only secret buffer. Empty string means "do not change" (unless cleared).
+ authHeader: z.string(),
+ toolAllowlist: z.array(z.string()),
+ enabled: z.boolean(),
+});
+
+type FormValues = z.infer;
+
+interface AiMcpServerFormProps {
+ // When provided, the form edits an existing server; otherwise it creates one.
+ server?: IAiMcpServer;
+ onClose: () => void;
+}
+
+// Tavily preset (§8.10): the API key goes in the Authorization HEADER, not the URL.
+const TAVILY_PRESET = {
+ name: "Tavily",
+ transport: "http" as McpTransport,
+ url: "https://mcp.tavily.com/mcp/",
+};
+
+export default function AiMcpServerForm({
+ server,
+ onClose,
+}: AiMcpServerFormProps) {
+ const { t } = useTranslation();
+ const isEdit = Boolean(server);
+
+ const createMutation = useCreateAiMcpServerMutation();
+ const updateMutation = useUpdateAiMcpServerMutation();
+ const testMutation = useTestAiMcpServerMutation();
+
+ // Whether auth headers are currently stored server-side (drives the placeholder).
+ const [hasHeaders, setHasHeaders] = useState(server?.hasHeaders ?? false);
+ // Tracks whether the user explicitly cleared the stored auth headers.
+ const [headersCleared, setHeadersCleared] = useState(false);
+
+ const form = useForm({
+ validate: zod4Resolver(formSchema),
+ initialValues: {
+ name: server?.name ?? "",
+ transport: server?.transport ?? "http",
+ url: server?.url ?? "",
+ authHeader: "",
+ toolAllowlist: server?.toolAllowlist ?? [],
+ enabled: server?.enabled ?? true,
+ },
+ });
+
+ // Re-hydrate when the target server changes (e.g. reusing the modal).
+ useEffect(() => {
+ form.setValues({
+ name: server?.name ?? "",
+ transport: server?.transport ?? "http",
+ url: server?.url ?? "",
+ authHeader: "",
+ toolAllowlist: server?.toolAllowlist ?? [],
+ enabled: server?.enabled ?? true,
+ });
+ form.resetDirty();
+ setHasHeaders(server?.hasHeaders ?? false);
+ setHeadersCleared(false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [server?.id]);
+
+ const transportOptions = [
+ { value: "http", label: "HTTP" },
+ { value: "sse", label: "SSE" },
+ ];
+
+ // Map the single Authorization value to a headers map, honouring write-only
+ // semantics: typed value -> set; explicitly cleared -> {} (clear); else omit.
+ function resolveHeaders(): Record | undefined {
+ if (form.values.authHeader.length > 0) {
+ return { Authorization: form.values.authHeader };
+ }
+ if (headersCleared) {
+ return {};
+ }
+ return undefined;
+ }
+
+ async function handleSubmit(values: FormValues) {
+ const headers = resolveHeaders();
+
+ if (isEdit && server) {
+ const payload: IAiMcpServerUpdate = {
+ id: server.id,
+ name: values.name,
+ transport: values.transport,
+ url: values.url,
+ toolAllowlist: values.toolAllowlist,
+ enabled: values.enabled,
+ };
+ // Only attach headers when set or explicitly cleared (omit => unchanged).
+ if (headers !== undefined) payload.headers = headers;
+ await updateMutation.mutateAsync(payload);
+ } else {
+ const payload: IAiMcpServerCreate = {
+ name: values.name,
+ transport: values.transport,
+ url: values.url,
+ toolAllowlist: values.toolAllowlist,
+ enabled: values.enabled,
+ };
+ // On create, only a typed value matters (no prior stored headers).
+ if (headers !== undefined && Object.keys(headers).length > 0) {
+ payload.headers = headers;
+ }
+ await createMutation.mutateAsync(payload);
+ }
+
+ onClose();
+ }
+
+ function handleClearHeaders() {
+ setHeadersCleared(true);
+ setHasHeaders(false);
+ form.setFieldValue("authHeader", "");
+ }
+
+ function applyTavilyPreset() {
+ form.setFieldValue("name", TAVILY_PRESET.name);
+ form.setFieldValue("transport", TAVILY_PRESET.transport);
+ form.setFieldValue("url", TAVILY_PRESET.url);
+ // Prefill the Bearer prefix; the admin pastes their Tavily key after it.
+ form.setFieldValue("authHeader", "Bearer ");
+ setHeadersCleared(false);
+ }
+
+ const testResult = testMutation.data;
+ const isSaving = createMutation.isPending || updateMutation.isPending;
+
+ return (
+
+ {!isEdit && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {hasHeaders && (
+
+
+
+ )}
+
+
+
+
+ form.setFieldValue("enabled", event.currentTarget.checked)
+ }
+ />
+
+ {testResult && (
+ : }
+ >
+ {testResult.ok ? (
+
+
+ {t("Available tools")}
+
+ {testResult.tools.length > 0 ? (
+
+ {testResult.tools.map((tool) => (
+ {tool}
+ ))}
+
+ ) : (
+ {t("No tools available")}
+ )}
+
+ ) : (
+ // `in` narrows the discriminated union to the error member.
+ ("error" in testResult && testResult.error) ||
+ t("Connection failed")
+ )}
+
+ )}
+
+
+ {/* Test runs against the SAVED server, so it's only available in edit mode. */}
+ {isEdit && server ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx
new file mode 100644
index 00000000..86db3e4a
--- /dev/null
+++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx
@@ -0,0 +1,161 @@
+import { useState } from "react";
+import {
+ ActionIcon,
+ Badge,
+ Button,
+ Group,
+ Modal,
+ Paper,
+ Stack,
+ Switch,
+ Text,
+} from "@mantine/core";
+import { useDisclosure } from "@mantine/hooks";
+import { modals } from "@mantine/modals";
+import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import useUserRole from "@/hooks/use-user-role.tsx";
+import {
+ useAiMcpServersQuery,
+ useDeleteAiMcpServerMutation,
+ useUpdateAiMcpServerMutation,
+} from "@/features/workspace/queries/ai-mcp-server-query.ts";
+import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
+import AiMcpServerForm from "./ai-mcp-server-form.tsx";
+
+/**
+ * Admin section: list / add / edit / delete external MCP servers the agent may
+ * use (web search, etc.). The add/edit form (incl. the per-server Test) lives in
+ * `AiMcpServerForm`, opened in a modal. Auth headers are write-only and never
+ * shown (only `hasHeaders` is known client-side).
+ */
+export default function AiMcpServers() {
+ const { t } = useTranslation();
+ const { isAdmin } = useUserRole();
+
+ // Only admins may read/manage external servers; the server enforces this too.
+ const { data: servers, isLoading } = useAiMcpServersQuery(isAdmin);
+ const updateMutation = useUpdateAiMcpServerMutation();
+ const deleteMutation = useDeleteAiMcpServerMutation();
+
+ const [opened, { open, close }] = useDisclosure(false);
+ // The server being edited; undefined means the modal is in "create" mode.
+ const [editing, setEditing] = useState(undefined);
+
+ if (!isAdmin) {
+ return (
+
+ {t("Only workspace admins can manage AI provider settings.")}
+
+ );
+ }
+
+ function openCreate() {
+ setEditing(undefined);
+ open();
+ }
+
+ function openEdit(server: IAiMcpServer) {
+ setEditing(server);
+ open();
+ }
+
+ function confirmDelete(server: IAiMcpServer) {
+ modals.openConfirmModal({
+ title: t("Delete server"),
+ children: (
+
+ {t("Are you sure you want to delete this MCP server?")}
+
+ ),
+ labels: { confirm: t("Delete"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: () => deleteMutation.mutate(server.id),
+ });
+ }
+
+ return (
+
+
+ }
+ variant="default"
+ onClick={openCreate}
+ >
+ {t("Add server")}
+
+
+
+ {!isLoading && (!servers || servers.length === 0) && (
+
+ {t("No external servers configured")}
+
+ )}
+
+
+ {servers?.map((server) => (
+
+
+
+
+
+ {server.name}
+
+
+ {server.transport.toUpperCase()}
+
+
+
+ {server.url}
+
+
+
+
+
+ updateMutation.mutate({
+ id: server.id,
+ enabled: event.currentTarget.checked,
+ })
+ }
+ />
+ openEdit(server)}
+ >
+
+
+ confirmDelete(server)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ {/* Remount the form per target so its internal state re-hydrates. */}
+
+
+
+ );
+}
diff --git a/apps/client/src/features/workspace/queries/ai-mcp-server-query.ts b/apps/client/src/features/workspace/queries/ai-mcp-server-query.ts
new file mode 100644
index 00000000..c605695c
--- /dev/null
+++ b/apps/client/src/features/workspace/queries/ai-mcp-server-query.ts
@@ -0,0 +1,99 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ getAiMcpServers,
+ createAiMcpServer,
+ updateAiMcpServer,
+ deleteAiMcpServer,
+ testAiMcpServer,
+ IAiMcpServer,
+ IAiMcpServerCreate,
+ IAiMcpServerUpdate,
+ IAiMcpServerTestResult,
+} from "@/features/workspace/services/ai-mcp-server-service.ts";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+const aiMcpServersKey = ["ai-mcp-servers"];
+
+export function useAiMcpServersQuery(
+ enabled: boolean = true,
+): UseQueryResult {
+ return useQuery({
+ queryKey: aiMcpServersKey,
+ queryFn: () => getAiMcpServers(),
+ enabled,
+ });
+}
+
+export function useCreateAiMcpServerMutation() {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data) => createAiMcpServer(data),
+ onSuccess: () => {
+ notifications.show({ message: t("Created successfully") });
+ queryClient.invalidateQueries({ queryKey: aiMcpServersKey });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage ?? t("Failed to update data"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useUpdateAiMcpServerMutation() {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data) => updateAiMcpServer(data),
+ onSuccess: () => {
+ notifications.show({ message: t("Updated successfully") });
+ queryClient.invalidateQueries({ queryKey: aiMcpServersKey });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage ?? t("Failed to update data"),
+ color: "red",
+ });
+ },
+ });
+}
+
+export function useDeleteAiMcpServerMutation() {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+
+ return useMutation<{ success: true }, Error, string>({
+ mutationFn: (id) => deleteAiMcpServer(id),
+ onSuccess: () => {
+ notifications.show({ message: t("Deleted successfully") });
+ queryClient.invalidateQueries({ queryKey: aiMcpServersKey });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage ?? t("Failed to update data"),
+ color: "red",
+ });
+ },
+ });
+}
+
+// Tests a saved server by id. The result ({ ok, tools } | { ok, error }) is
+// rendered inline by the caller, so this mutation has no notifications.
+export function useTestAiMcpServerMutation() {
+ return useMutation({
+ mutationFn: (id) => testAiMcpServer(id),
+ });
+}
diff --git a/apps/client/src/features/workspace/services/ai-mcp-server-service.ts b/apps/client/src/features/workspace/services/ai-mcp-server-service.ts
new file mode 100644
index 00000000..ea3c2130
--- /dev/null
+++ b/apps/client/src/features/workspace/services/ai-mcp-server-service.ts
@@ -0,0 +1,95 @@
+import api from "@/lib/api-client";
+
+// External MCP server transports (mirrors the server's MCP_TRANSPORTS).
+export type McpTransport = "http" | "sse";
+
+// Admin-facing view of a configured external MCP server.
+// SECURITY (§8.10): the auth headers are NEVER returned — only `hasHeaders`
+// signals whether any are stored. `toolAllowlist` is null when unrestricted.
+export interface IAiMcpServer {
+ id: string;
+ name: string;
+ transport: McpTransport;
+ url: string;
+ enabled: boolean;
+ toolAllowlist: string[] | null;
+ hasHeaders: boolean;
+}
+
+// Create payload. `headers` is write-only: omit => no auth headers.
+export interface IAiMcpServerCreate {
+ name: string;
+ transport: McpTransport;
+ url: string;
+ // Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
+ // never returned.
+ headers?: Record;
+ toolAllowlist?: string[];
+ enabled?: boolean;
+}
+
+// Update payload. Every field is optional (partial update). `headers` semantics:
+// - omit -> auth headers unchanged
+// - {} (empty) -> auth headers cleared
+// - non-empty value -> auth headers replaced
+export interface IAiMcpServerUpdate {
+ id: string;
+ name?: string;
+ transport?: McpTransport;
+ url?: string;
+ headers?: Record;
+ toolAllowlist?: string[];
+ enabled?: boolean;
+}
+
+// Result of a "Test connection" against a SAVED server (by id).
+// The error string is already sanitized server-side; never carries secrets.
+export type IAiMcpServerTestResult =
+ | { ok: true; tools: string[] }
+ | { ok: false; error: string };
+
+export async function getAiMcpServers(): Promise {
+ const req = await api.post("/workspace/ai-mcp-servers");
+ return req.data;
+}
+
+export async function createAiMcpServer(
+ data: IAiMcpServerCreate,
+): Promise {
+ const req = await api.post(
+ "/workspace/ai-mcp-servers/create",
+ data,
+ );
+ return req.data;
+}
+
+export async function updateAiMcpServer(
+ data: IAiMcpServerUpdate,
+): Promise {
+ const req = await api.post(
+ "/workspace/ai-mcp-servers/update",
+ data,
+ );
+ return req.data;
+}
+
+export async function deleteAiMcpServer(
+ id: string,
+): Promise<{ success: true }> {
+ const req = await api.post<{ success: true }>(
+ "/workspace/ai-mcp-servers/delete",
+ { id },
+ );
+ return req.data;
+}
+
+// Tests a SAVED server by id (the server connects with the stored headers).
+export async function testAiMcpServer(
+ id: string,
+): Promise {
+ const req = await api.post(
+ "/workspace/ai-mcp-servers/test",
+ { id },
+ );
+ return req.data;
+}
diff --git a/apps/client/src/pages/settings/workspace/workspace-settings.tsx b/apps/client/src/pages/settings/workspace/workspace-settings.tsx
index bd8a2276..bed8f6f8 100644
--- a/apps/client/src/pages/settings/workspace/workspace-settings.tsx
+++ b/apps/client/src/pages/settings/workspace/workspace-settings.tsx
@@ -3,6 +3,7 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.tsx";
import AiProviderSettings from "@/features/workspace/components/settings/components/ai-provider-settings.tsx";
+import AiMcpServers from "@/features/workspace/components/settings/components/ai-mcp-servers.tsx";
import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@@ -32,6 +33,11 @@ export default function WorkspaceSettings() {
+
+
+
+
+
>
)}
>