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/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index e46a0579..41c7f6dc 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1172,5 +1172,6 @@ "OpenAI (official)": "OpenAI (официальный)", "Test": "Тест", "Failed": "Ошибка", - "OK · {{count}}": "OK · {{count}}" + "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 index ecd5d2a6..5c6edd24 100644 --- 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 @@ -40,24 +40,37 @@ const baseServer = (over?: Partial): IAiMcpServer => ({ ...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 } }, }); - return render( + 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", () => { @@ -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) => 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 index 04c305da..cb96fd42 100644 --- 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 @@ -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 = ; - // 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 = ; + 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}