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:
@@ -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>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>.",
|
"View the <anchor>MCP documentation</anchor>.": "View the <anchor>MCP documentation</anchor>.",
|
||||||
"AI / Models": "AI / Models",
|
"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",
|
"Provider": "Provider",
|
||||||
"•••• set": "•••• set",
|
"•••• set": "•••• set",
|
||||||
"Clear key": "Clear key",
|
"Clear key": "Clear key",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
|
|||||||
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
|
||||||
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.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 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 { useTranslation } from "react-i18next";
|
||||||
import { getAppName } from "@/lib/config.ts";
|
import { getAppName } from "@/lib/config.ts";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
@@ -32,6 +33,11 @@ export default function WorkspaceSettings() {
|
|||||||
|
|
||||||
<SettingsTitle title={t("AI / Models")} />
|
<SettingsTitle title={t("AI / Models")} />
|
||||||
<AiProviderSettings />
|
<AiProviderSettings />
|
||||||
|
|
||||||
|
<Divider my="lg" />
|
||||||
|
|
||||||
|
<SettingsTitle title={t("AI / External tools (MCP)")} />
|
||||||
|
<AiMcpServers />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user