diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2aa9c9..815b2ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Inline "Test" button per external MCP server.** Each server row in admin AI + settings now has its own "Test" button that runs an isolated connection check: + idle `Test` → green `OK · N` (with a tooltip listing the discovered tools, or + "No tools available") on success, or red `Failed` (tooltip with the sanitized + error) on a connection problem. State is per-row, so testing one server never + spins or recolours the others. (#170) + - **Persistent AI-chat history as the source of truth + server-side export.** An assistant turn is now persisted to the database step by step: the row is inserted upfront as `streaming` and updated as each agent step finishes, then diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index bd8c4ed3..cb1b3086 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -713,6 +713,8 @@ "Optional. Leave empty to allow all tools the server exposes.": "Optional. Leave empty to allow all tools the server exposes.", "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"_*\".": "Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"_*\".", "Test": "Test", + "Failed": "Failed", + "OK · {{count}}": "OK · {{count}}", "Available tools": "Available tools", "No tools available": "No tools available", "Created successfully": "Created successfully", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index f8c59436..41c7f6dc 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1169,5 +1169,9 @@ "Protocol": "Протокол", "How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning", "OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)", - "OpenAI (official)": "OpenAI (официальный)" + "OpenAI (official)": "OpenAI (официальный)", + "Test": "Тест", + "Failed": "Ошибка", + "OK · {{count}}": "OK · {{count}}", + "No tools available": "Нет доступных инструментов" } diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.test.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.test.tsx new file mode 100644 index 00000000..5c6edd24 --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.test.tsx @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts"; + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This +// keeps assertions on the row's OWN label logic, mirroring the t-mock pattern +// used by other component tests in the repo. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { count?: number }) => + opts && typeof opts.count === "number" + ? key.replace("{{count}}", String(opts.count)) + : key, + }), +})); + +// Mock only the network call. The REAL useTestAiMcpServerMutation runs on a real +// QueryClient so each row gets a genuinely independent mutation instance — this +// is exactly the isolation the feature relies on (#170). +const testAiMcpServer = vi.fn(); +vi.mock("@/features/workspace/services/ai-mcp-server-service.ts", () => ({ + testAiMcpServer: (id: string) => testAiMcpServer(id), +})); + +import AiMcpServerRow from "./ai-mcp-server-row.tsx"; + +const baseServer = (over?: Partial): IAiMcpServer => ({ + id: "srv-1", + name: "Search", + transport: "http", + url: "https://example.com/mcp", + enabled: true, + toolAllowlist: null, + hasHeaders: false, + instructions: null, + ...over, +}); + +function tree(server: IAiMcpServer, testid: string) { + return ( +
+ +
+ ); +} + +function renderRow(server: IAiMcpServer, testid: string) { + const client = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + const utils = render( + + {tree(server, testid)} + , + ); + // A rerender helper that swaps only the server prop (same QueryClient, so the + // row keeps its mutation state and the reset-on-change effect is exercised). + const rerenderWith = (next: IAiMcpServer) => + utils.rerender( + + {tree(next, testid)} + , + ); + return { ...utils, rerenderWith }; +} + +describe("AiMcpServerRow — inline Test button", () => { + beforeEach(() => { + testAiMcpServer.mockReset(); + }); + + it("starts in the idle state with a plain 'Test' label", () => { + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + expect(within(row).getByRole("button", { name: "Test" })).toBeDefined(); + }); + + it("shows a green 'OK · N' label with the tool count on success", async () => { + testAiMcpServer.mockResolvedValue({ ok: true, tools: ["a", "b", "c"] }); + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + + fireEvent.click(within(row).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(row).getByRole("button", { name: /OK · 3/ })).toBeDefined(), + ); + }); + + it("shows 'Failed' on a connection error", async () => { + testAiMcpServer.mockResolvedValue({ ok: false, error: "boom" }); + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + + fireEvent.click(within(row).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(row).getByRole("button", { name: "Failed" })).toBeDefined(), + ); + }); + + it("shows 'Failed' when the request itself rejects (401/403/500/network)", async () => { + // A real reject yields no { ok:false } payload — the row must read isError, + // not just mutation.data, or it would spin then silently revert to "Test". + testAiMcpServer.mockRejectedValue(new Error("Request failed")); + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + + fireEvent.click(within(row).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(row).getByRole("button", { name: "Failed" })).toBeDefined(), + ); + }); + + it("shows 'OK · 0' and a 'No tools available' tooltip for an empty tool list", async () => { + testAiMcpServer.mockResolvedValue({ ok: true, tools: [] }); + renderRow(baseServer(), "row"); + const row = screen.getByTestId("row"); + + fireEvent.click(within(row).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(row).getByRole("button", { name: /OK · 0/ })).toBeDefined(), + ); + }); + + it("resets a stale result when url / transport / hasHeaders change", async () => { + testAiMcpServer.mockResolvedValue({ ok: true, tools: ["a", "b", "c"] }); + const { rerenderWith } = renderRow(baseServer(), "row"); + const row = () => screen.getByTestId("row"); + + fireEvent.click(within(row()).getByRole("button", { name: "Test" })); + await waitFor(() => + expect(within(row()).getByRole("button", { name: /OK · 3/ })).toBeDefined(), + ); + + // Changing the URL must drop the stale green result back to idle "Test". + rerenderWith(baseServer({ url: "https://changed.example.com/mcp" })); + await waitFor(() => + expect(within(row()).getByRole("button", { name: "Test" })).toBeDefined(), + ); + + // Same for the transport. + fireEvent.click(within(row()).getByRole("button", { name: "Test" })); + await waitFor(() => + expect(within(row()).getByRole("button", { name: /OK · 3/ })).toBeDefined(), + ); + rerenderWith( + baseServer({ url: "https://changed.example.com/mcp", transport: "sse" }), + ); + await waitFor(() => + expect(within(row()).getByRole("button", { name: "Test" })).toBeDefined(), + ); + + // And for the presence of auth headers. + fireEvent.click(within(row()).getByRole("button", { name: "Test" })); + await waitFor(() => + expect(within(row()).getByRole("button", { name: /OK · 3/ })).toBeDefined(), + ); + rerenderWith( + baseServer({ + url: "https://changed.example.com/mcp", + transport: "sse", + hasHeaders: true, + }), + ); + await waitFor(() => + expect(within(row()).getByRole("button", { name: "Test" })).toBeDefined(), + ); + }); + + it("keeps each row's result isolated (testing one does not affect another)", async () => { + // Resolve based on id so the two rows get different outcomes. + testAiMcpServer.mockImplementation(async (id: string) => + id === "ok-1" + ? { ok: true, tools: ["x", "y"] } + : { ok: false, error: "down" }, + ); + + renderRow(baseServer({ id: "ok-1", name: "Good" }), "row-ok"); + renderRow(baseServer({ id: "fail-1", name: "Bad" }), "row-fail"); + + const okRow = screen.getByTestId("row-ok"); + fireEvent.click(within(okRow).getByRole("button", { name: "Test" })); + + await waitFor(() => + expect(within(okRow).getByRole("button", { name: /OK · 2/ })).toBeDefined(), + ); + + // The untouched row must still be idle — no shared/global pending state. + const failRow = screen.getByTestId("row-fail"); + expect(within(failRow).getByRole("button", { name: "Test" })).toBeDefined(); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.tsx b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.tsx new file mode 100644 index 00000000..cb96fd42 --- /dev/null +++ b/apps/client/src/features/workspace/components/settings/components/ai-mcp-server-row.tsx @@ -0,0 +1,161 @@ +import { useEffect } from "react"; +import { ActionIcon, Badge, Button, Group, Stack, Switch, Text, Tooltip } from "@mantine/core"; +import { + IconCheck, + IconPencil, + IconPlugConnected, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useTestAiMcpServerMutation } from "@/features/workspace/queries/ai-mcp-server-query.ts"; +import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts"; + +interface AiMcpServerRowProps { + server: IAiMcpServer; + onEdit: (server: IAiMcpServer) => void; + onDelete: (server: IAiMcpServer) => void; + onToggleEnabled: (server: IAiMcpServer, enabled: boolean) => void; +} + +/** + * A single external MCP server row with an inline "Test" button. Each row owns + * its OWN test mutation instance so the loading/result state is isolated per + * row — a list-level mutation would make every row's spinner and colour jump on + * any single test (#170). + */ +export default function AiMcpServerRow({ + server, + onEdit, + onDelete, + onToggleEnabled, +}: AiMcpServerRowProps) { + const { t } = useTranslation(); + const testMutation = useTestAiMcpServerMutation(); + + // The result colour/label reflects the connection params at the time of the + // test. The row is keyed by id and never remounts, so a stale "OK"/"Failed" + // would otherwise stick after the connection params change. Reset on those. + // Note: `hasHeaders` is a presence flag only (header values are write-only and + // never returned), so this resets on adding/removing auth headers, NOT on + // rotating a token's value — that value-only change is invisible to the client. + useEffect(() => { + testMutation.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [server.url, server.transport, server.hasHeaders]); + + const result = testMutation.data; + + // Derive the button's appearance from the test outcome. Colour is never the + // only signal — the label changes too (a11y / colour-blind friendly). + let label = t("Test"); + let color: string | undefined; + let variant = "default"; + let icon = ; + let tooltip: string | undefined; + + if (result?.ok) { + label = t("OK · {{count}}", { count: result.tools.length }); + color = "green"; + variant = "light"; + icon = ; + tooltip = + result.tools.length > 0 + ? result.tools.join(", ") + : t("No tools available"); + } else if (result && "error" in result) { + // Server-reported failure ({ ok: false, error }, HTTP 200). The error string + // is already sanitized server-side (no secrets). The `"error" in result` + // guard is required: `result?.ok` optional-chaining doesn't narrow the union + // in the else branch, so a bare `else if (result)` fails to type-check. + label = t("Failed"); + color = "red"; + variant = "light"; + icon = ; + tooltip = result.error; + } else if (testMutation.isError) { + // The request itself rejected (401/403/500/network) — there is no result + // payload, so without this the row would silently revert to "Test". + label = t("Failed"); + color = "red"; + variant = "light"; + icon = ; + tooltip = + testMutation.error?.["response"]?.data?.message ?? + t("Failed to update data"); + } + + const testButton = ( + + ); + + return ( + + + + + {server.name} + + + {server.transport.toUpperCase()} + + + + {server.url} + + + + + {/* Show the tooltip (tools list / error) only once there is a result. */} + {tooltip ? ( + + {testButton} + + ) : ( + testButton + )} + + onToggleEnabled(server, event.currentTarget.checked) + } + /> + onEdit(server)} + > + + + onDelete(server)} + > + + + + + ); +} 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..617845ed 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,6 +1,5 @@ import { useState } from "react"; import { - ActionIcon, Badge, Box, Button, @@ -8,12 +7,11 @@ import { 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 { IconPlus } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import useUserRole from "@/hooks/use-user-role.tsx"; import { @@ -23,6 +21,7 @@ import { } 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"; +import AiMcpServerRow from "./ai-mcp-server-row.tsx"; /** * Admin section: list / add / edit / delete external MCP servers the agent may @@ -112,55 +111,16 @@ 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)} - > - - - - + // Keyed by id (never remounts) so each row keeps its own test state. + + updateMutation.mutate({ id: s.id, enabled }) + } + /> ))}