feat(ai-chat): inline Test button per external MCP server row (#170) #208

Closed
Ghost wants to merge 2 commits from feat/170-mcp-test-button into develop
6 changed files with 391 additions and 53 deletions

View File

@@ -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

View File

@@ -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 \"<server name>_*\".": "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 \"<server name>_*\".",
"Test": "Test",
"Failed": "Failed",
"OK · {{count}}": "OK · {{count}}",
"Available tools": "Available tools",
"No tools available": "No tools available",
"Created successfully": "Created successfully",

View File

@@ -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": "Нет доступных инструментов"
}

View File

@@ -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>): 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 (
<div data-testid={testid}>
<AiMcpServerRow
server={server}
onEdit={vi.fn()}
onDelete={vi.fn()}
onToggleEnabled={vi.fn()}
/>
</div>
);
}
function renderRow(server: IAiMcpServer, testid: string) {
const client = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
});
const utils = render(
<QueryClientProvider client={client}>
<MantineProvider>{tree(server, testid)}</MantineProvider>
</QueryClientProvider>,
);
// 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(
<QueryClientProvider client={client}>
<MantineProvider>{tree(next, testid)}</MantineProvider>
</QueryClientProvider>,
);
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();
});
});

View File

@@ -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 = <IconPlugConnected size={16} />;
let tooltip: string | undefined;
if (result?.ok) {
label = t("OK · {{count}}", { count: result.tools.length });
color = "green";
variant = "light";
icon = <IconCheck size={16} />;
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 = <IconX size={16} />;
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 = <IconX size={16} />;
tooltip =
testMutation.error?.["response"]?.data?.message ??
t("Failed to update data");
}
const testButton = (
<Button
size="xs"
variant={variant}
color={color}
// Fixed min-width so the row does not jump as the label changes
// (Test -> OK · 5 -> Failed).
miw={88}
leftSection={icon}
// Mantine disables the button automatically while loading.
loading={testMutation.isPending}
onClick={() => testMutation.mutate(server.id)}
>
{label}
</Button>
);
return (
<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
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
{/* Show the tooltip (tools list / error) only once there is a result. */}
{tooltip ? (
<Tooltip label={tooltip} multiline maw={320} withArrow>
{testButton}
</Tooltip>
) : (
testButton
)}
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
onToggleEnabled(server, event.currentTarget.checked)
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => onEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => onDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -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() {
<Stack gap="xs" mt="sm">
{servers?.map((server) => (
<Group key={server.id} 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
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{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>
// Keyed by id (never remounts) so each row keeps its own test state.
<AiMcpServerRow
key={server.id}
server={server}
onEdit={openEdit}
onDelete={confirmDelete}
onToggleEnabled={(s, enabled) =>
updateMutation.mutate({ id: s.id, enabled })
}
/>
))}
</Stack>