- CHANGELOG: add an [Unreleased]/Added bullet for the per-row "Test" button
(idle Test -> OK · N / Failed, tooltip, isolated per-row state).
- ai-mcp-server-row: show a red "Failed" when the request itself rejects
(401/403/500/network), reading testMutation.isError — previously only a
server-reported {ok:false} was surfaced and a real reject silently reverted
to "Test". Tooltip uses error.response?.data?.message or the i18n fallback.
- ai-mcp-server-row: clarify the reset-effect comment (hasHeaders is a
presence flag, so value-only token rotation is intentionally not reset).
- ai-mcp-server-row: drop the redundant disabled={isPending} (Mantine already
disables a loading button).
- ru-RU: add the "No tools available" translation.
- tests: cover the request-reject failure, the empty tool list (OK · 0), and
the reset-on-change effect (url / transport / hasHeaders) via rerender.
Note: kept the `"error" in result` guard instead of the suggested bare
`else if (result)` — optional chaining on `result?.ok` doesn't narrow the
discriminated union in the else branch, so the bare form fails tsc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1172,5 +1172,6 @@
|
||||
"OpenAI (official)": "OpenAI (официальный)",
|
||||
"Test": "Тест",
|
||||
"Failed": "Ошибка",
|
||||
"OK · {{count}}": "OK · {{count}}"
|
||||
"OK · {{count}}": "OK · {{count}}",
|
||||
"No tools available": "Нет доступных инструментов"
|
||||
}
|
||||
|
||||
@@ -40,13 +40,8 @@ const baseServer = (over?: Partial<IAiMcpServer>): IAiMcpServer => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
function renderRow(server: IAiMcpServer, testid: string) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MantineProvider>
|
||||
function tree(server: IAiMcpServer, testid: string) {
|
||||
return (
|
||||
<div data-testid={testid}>
|
||||
<AiMcpServerRow
|
||||
server={server}
|
||||
@@ -55,9 +50,27 @@ function renderRow(server: IAiMcpServer, testid: string) {
|
||||
onToggleEnabled={vi.fn()}
|
||||
/>
|
||||
</div>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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", () => {
|
||||
@@ -95,6 +108,77 @@ describe("AiMcpServerRow — inline Test button", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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) =>
|
||||
|
||||
@@ -35,7 +35,10 @@ export default function AiMcpServerRow({
|
||||
|
||||
// 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 URL/transport/auth changes. Reset on those.
|
||||
// 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
|
||||
@@ -61,12 +64,25 @@ export default function AiMcpServerRow({
|
||||
? 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} />;
|
||||
// The error string is already sanitized server-side (no secrets).
|
||||
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 = (
|
||||
@@ -78,9 +94,8 @@ export default function AiMcpServerRow({
|
||||
// (Test -> OK · 5 -> Failed).
|
||||
miw={88}
|
||||
leftSection={icon}
|
||||
// Mantine disables the button automatically while loading.
|
||||
loading={testMutation.isPending}
|
||||
// Only blocked while in flight — testing a disabled server is useful too.
|
||||
disabled={testMutation.isPending}
|
||||
onClick={() => testMutation.mutate(server.id)}
|
||||
>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user