From eefbf672882d9869495ae2ecdde930bc103a79c3 Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Wed, 17 Jun 2026 05:57:37 +0300 Subject: [PATCH] feat(ai-chat): external MCP servers admin UI (E3)" -m "Admin 'AI / External tools (MCP)' settings section: list/add/edit/delete external MCP servers, per-server enable toggle and Test (lists the server's tools), write-only auth headers (never shown), tool allowlist, and a Tavily preset (key in the Authorization header, not the URL). Consumes the existing admin /workspace/ai-mcp-servers endpoints. Fixes a discriminated-union narrowing type error in the (previously untracked) server form. --- .../public/locales/en-US/translation.json | 19 ++ .../components/ai-mcp-server-form.tsx | 291 ++++++++++++++++++ .../settings/components/ai-mcp-servers.tsx | 161 ++++++++++ .../workspace/queries/ai-mcp-server-query.ts | 99 ++++++ .../services/ai-mcp-server-service.ts | 95 ++++++ .../settings/workspace/workspace-settings.tsx | 6 + 6 files changed, 671 insertions(+) create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx create mode 100644 apps/client/src/features/workspace/components/settings/components/ai-mcp-servers.tsx create mode 100644 apps/client/src/features/workspace/queries/ai-mcp-server-query.ts create mode 100644 apps/client/src/features/workspace/services/ai-mcp-server-service.ts 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 && ( + + + + )} + + + +