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>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",
|
||||
|
||||
@@ -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 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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user