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.
This commit is contained in:
vvzvlad
2026-06-17 05:57:37 +03:00
parent 6ec91c8a2c
commit eefbf67288
6 changed files with 671 additions and 0 deletions

View File

@@ -689,6 +689,25 @@
"View the <anchor>API documentation</anchor> for usage details.": "View the <anchor>API documentation</anchor> for usage details.",
"View the <anchor>MCP documentation</anchor>.": "View the <anchor>MCP documentation</anchor>.",
"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",

View File

@@ -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<typeof formSchema>;
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<FormValues>({
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<string, string> | 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 (
<Stack>
{!isEdit && (
<Group justify="flex-start">
<Button variant="default" size="compact-sm" onClick={applyTavilyPreset}>
{t("Use Tavily preset")}
</Button>
</Group>
)}
<TextInput
label={t("Server name")}
{...form.getInputProps("name")}
/>
<Select
label={t("Transport")}
data={transportOptions}
allowDeselect={false}
{...form.getInputProps("transport")}
/>
<TextInput label={t("URL")} {...form.getInputProps("url")} />
<PasswordInput
label={t("Authorization header")}
// Placeholder hints whether headers are stored; the value is never shown.
placeholder={hasHeaders ? t("•••• set") : ""}
autoComplete="off"
{...form.getInputProps("authHeader")}
/>
{hasHeaders && (
<Group justify="flex-start" mt={-8}>
<Button
variant="subtle"
size="compact-sm"
color="red"
onClick={handleClearHeaders}
>
{t("Clear")}
</Button>
</Group>
)}
<TagsInput
label={t("Tool allowlist")}
description={t(
"Optional. Leave empty to allow all tools the server exposes.",
)}
splitChars={[",", " "]}
clearable
{...form.getInputProps("toolAllowlist")}
/>
<Switch
label={t("Enabled")}
checked={form.values.enabled}
onChange={(event) =>
form.setFieldValue("enabled", event.currentTarget.checked)
}
/>
{testResult && (
<Alert
color={testResult.ok ? "green" : "red"}
icon={testResult.ok ? <IconCheck size={16} /> : <IconX size={16} />}
>
{testResult.ok ? (
<Stack gap={4}>
<Text size="sm" fw={500}>
{t("Available tools")}
</Text>
{testResult.tools.length > 0 ? (
<List size="sm">
{testResult.tools.map((tool) => (
<List.Item key={tool}>{tool}</List.Item>
))}
</List>
) : (
<Text size="sm">{t("No tools available")}</Text>
)}
</Stack>
) : (
// `in` narrows the discriminated union to the error member.
("error" in testResult && testResult.error) ||
t("Connection failed")
)}
</Alert>
)}
<Group justify="space-between" mt="sm">
{/* Test runs against the SAVED server, so it's only available in edit mode. */}
{isEdit && server ? (
<Button
type="button"
variant="default"
onClick={() => testMutation.mutate(server.id)}
loading={testMutation.isPending}
>
{t("Test")}
</Button>
) : (
<span />
)}
<Group>
<Button type="button" variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
type="button"
onClick={() => handleSubmit(form.values)}
disabled={isSaving || !form.isValid()}
loading={isSaving}
>
{t("Save")}
</Button>
</Group>
</Group>
</Stack>
);
}

View File

@@ -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<IAiMcpServer | undefined>(undefined);
if (!isAdmin) {
return (
<Text size="sm" c="dimmed">
{t("Only workspace admins can manage AI provider settings.")}
</Text>
);
}
function openCreate() {
setEditing(undefined);
open();
}
function openEdit(server: IAiMcpServer) {
setEditing(server);
open();
}
function confirmDelete(server: IAiMcpServer) {
modals.openConfirmModal({
title: t("Delete server"),
children: (
<Text size="sm">
{t("Are you sure you want to delete this MCP server?")}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteMutation.mutate(server.id),
});
}
return (
<Stack mt="sm">
<Group justify="flex-start">
<Button
leftSection={<IconPlus size={16} />}
variant="default"
onClick={openCreate}
>
{t("Add server")}
</Button>
</Group>
{!isLoading && (!servers || servers.length === 0) && (
<Text size="sm" c="dimmed">
{t("No external servers configured")}
</Text>
)}
<Stack gap="xs">
{servers?.map((server) => (
<Paper key={server.id} withBorder p="sm" radius="sm">
<Group justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text size="xs" c="dimmed" truncate>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
updateMutation.mutate({
id: server.id,
enabled: event.currentTarget.checked,
})
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => openEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => confirmDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
</Paper>
))}
</Stack>
<Modal
opened={opened}
onClose={close}
title={editing ? t("Edit server") : t("Add server")}
size="lg"
>
{/* Remount the form per target so its internal state re-hydrates. */}
<AiMcpServerForm
key={editing?.id ?? "new"}
server={editing}
onClose={close}
/>
</Modal>
</Stack>
);
}

View File

@@ -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<IAiMcpServer[], Error> {
return useQuery({
queryKey: aiMcpServersKey,
queryFn: () => getAiMcpServers(),
enabled,
});
}
export function useCreateAiMcpServerMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IAiMcpServer, Error, IAiMcpServerCreate>({
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<IAiMcpServer, Error, IAiMcpServerUpdate>({
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<IAiMcpServerTestResult, Error, string>({
mutationFn: (id) => testAiMcpServer(id),
});
}

View File

@@ -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<string, string>;
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<string, string>;
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<IAiMcpServer[]> {
const req = await api.post<IAiMcpServer[]>("/workspace/ai-mcp-servers");
return req.data;
}
export async function createAiMcpServer(
data: IAiMcpServerCreate,
): Promise<IAiMcpServer> {
const req = await api.post<IAiMcpServer>(
"/workspace/ai-mcp-servers/create",
data,
);
return req.data;
}
export async function updateAiMcpServer(
data: IAiMcpServerUpdate,
): Promise<IAiMcpServer> {
const req = await api.post<IAiMcpServer>(
"/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<IAiMcpServerTestResult> {
const req = await api.post<IAiMcpServerTestResult>(
"/workspace/ai-mcp-servers/test",
{ id },
);
return req.data;
}

View File

@@ -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() {
<SettingsTitle title={t("AI / Models")} />
<AiProviderSettings />
<Divider my="lg" />
<SettingsTitle title={t("AI / External tools (MCP)")} />
<AiMcpServers />
</>
)}
</>