diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index bd8c4ed3..971cbc11 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -715,6 +715,8 @@
"Test": "Test",
"Available tools": "Available tools",
"No tools available": "No tools available",
+ "Failed": "Failed",
+ "OK · {{n}}": "OK · {{n}}",
"Created successfully": "Created successfully",
"Deleted successfully": "Deleted successfully",
"Clear": "Clear",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index f8c59436..349dc227 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -711,6 +711,11 @@
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
+ "Failed": "Ошибка",
+ "OK · {{n}}": "OK · {{n}}",
+ "Test": "Тест",
+ "No tools available": "Инструменты недоступны",
+ "Available tools": "Доступные инструменты",
"Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.",
"Send": "Отправить",
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
index 15db8c22..5dabd174 100644
--- 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
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import {
ActionIcon,
Badge,
@@ -10,15 +10,24 @@ import {
Stack,
Switch,
Text,
+ Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
-import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
+import {
+ IconCheck,
+ IconPencil,
+ IconPlugConnected,
+ IconPlus,
+ IconTrash,
+ IconX,
+} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
useAiMcpServersQuery,
useDeleteAiMcpServerMutation,
+ useTestAiMcpServerMutation,
useUpdateAiMcpServerMutation,
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
@@ -112,55 +121,15 @@ export default function AiMcpServers() {
{servers?.map((server) => (
-
-
-
-
- {server.name}
-
-
- {server.transport.toUpperCase()}
-
-
-
- {server.url}
-
-
-
-
-
- updateMutation.mutate({
- id: server.id,
- enabled: event.currentTarget.checked,
- })
- }
- />
- openEdit(server)}
- >
-
-
- confirmDelete(server)}
- >
-
-
-
-
+
+ updateMutation.mutate({ id: server.id, enabled })
+ }
+ />
))}
@@ -180,3 +149,132 @@ export default function AiMcpServers() {
);
}
+
+interface AiMcpServerRowProps {
+ server: IAiMcpServer;
+ onEdit: (server: IAiMcpServer) => void;
+ onDelete: (server: IAiMcpServer) => void;
+ onToggleEnabled: (enabled: boolean) => void;
+}
+
+/**
+ * A single external MCP server row: name/badge/url on the left and the
+ * Test / Switch / Edit / Delete controls on the right. Each row owns its own
+ * `useTestAiMcpServerMutation()` so the inline Test result and loading state are
+ * independent per row (a shared mutation would make `isPending` global and make
+ * every row flicker).
+ */
+function AiMcpServerRow({
+ server,
+ onEdit,
+ onDelete,
+ onToggleEnabled,
+}: AiMcpServerRowProps) {
+ const { t } = useTranslation();
+ const testMutation = useTestAiMcpServerMutation();
+ const result = testMutation.data;
+
+ // The row is keyed by `server.id`, so editing the connection-relevant fields
+ // (url/transport/headers) does NOT remount it — an old success/failure result
+ // would otherwise stick. Clear the result when those fields change.
+ useEffect(() => {
+ testMutation.reset();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [server.url, server.transport, server.hasHeaders]);
+
+ // Tooltip text describes the cause/details; disabled while there is no result.
+ let tooltipLabel = "";
+ if (result?.ok) {
+ tooltipLabel =
+ result.tools.length > 0
+ ? result.tools.join(", ")
+ : t("No tools available");
+ } else if (result && result.ok === false) {
+ tooltipLabel = result.error;
+ }
+
+ // Pick the button presentation from the current test state. Color is never the
+ // only signal — the label changes too (a11y / colorblind-friendly).
+ let buttonColor: string | undefined;
+ let buttonVariant = "default";
+ let buttonIcon = ;
+ let buttonLabel = t("Test");
+ if (result?.ok) {
+ buttonColor = "green";
+ buttonVariant = "light";
+ buttonIcon = ;
+ buttonLabel = t("OK · {{n}}", { n: result.tools.length });
+ } else if (result && result.ok === false) {
+ buttonColor = "red";
+ buttonVariant = "light";
+ buttonIcon = ;
+ buttonLabel = t("Failed");
+ }
+
+ return (
+
+
+
+
+ {server.name}
+
+
+ {server.transport.toUpperCase()}
+
+
+
+ {server.url}
+
+
+
+
+ {/* Always clickable: testing a disabled server before enabling it is useful. */}
+
+
+
+ onToggleEnabled(event.currentTarget.checked)}
+ />
+ onEdit(server)}
+ >
+
+
+ onDelete(server)}
+ >
+
+
+
+
+ );
+}