diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx
index 0993320..e3d44f3 100644
--- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx
+++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { http, HttpResponse } from "msw";
+import { http, HttpResponse, delay } from "msw";
import { server as mswServer } from "@/test/mocks/server";
import { renderWithProviders as render } from "@/test/test-utils";
import { VirtualServerDetailsPanel } from "./VirtualServerDetailsPanel";
@@ -567,6 +567,129 @@ describe("VirtualServerDetailsPanel test connection tab", () => {
expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
});
+ it("suppresses the mismatch banner when one component query fails", async () => {
+ // If the resources query errors while tools succeeds, the aggregate must
+ // not silently treat the failed query as a count of 0 — that would flag
+ // a mismatch against a handshake that actually agrees.
+ const user = userEvent.setup();
+ mswServer.use(
+ http.get("*/servers/:id/tools", () =>
+ HttpResponse.json({ tools: [{ id: "t1", name: "tool-1", originalName: "tool-1" }] }),
+ ),
+ http.get("*/servers/:id/resources", () =>
+ HttpResponse.json({ error: "boom" }, { status: 500 }),
+ ),
+ http.post(HANDSHAKE_ENDPOINT, () =>
+ HttpResponse.json({
+ success: true,
+ latencyMs: 10,
+ componentCounts: { tools: 1, resources: 2 },
+ }),
+ ),
+ );
+
+ render(
+ ,
+ );
+
+ await user.click(await screen.findByRole("tab", { name: "Try it" }));
+ await user.click(screen.getByRole("button", { name: /^test connection$/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/^connection test$/i)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
+ });
+
+ it("suppresses the mismatch banner when all component queries fail, even with a disabled component", async () => {
+ // When every component query fails, the panel would otherwise fall back
+ // to buildComponentItems(server), which carries no `enabled` field and
+ // can't exclude disabled components from the aggregate.
+ const user = userEvent.setup();
+ mswServer.use(
+ http.get("*/servers/:id/tools", () => HttpResponse.json({ error: "boom" }, { status: 500 })),
+ http.get("*/servers/:id/resources", () =>
+ HttpResponse.json({ error: "boom" }, { status: 500 }),
+ ),
+ http.get("*/servers/:id/prompts", () =>
+ HttpResponse.json({ error: "boom" }, { status: 500 }),
+ ),
+ http.post(HANDSHAKE_ENDPOINT, () =>
+ HttpResponse.json({
+ success: true,
+ latencyMs: 10,
+ componentCounts: { tools: 1 },
+ }),
+ ),
+ );
+
+ render(
+ ,
+ );
+
+ await user.click(await screen.findByRole("tab", { name: "Try it" }));
+ await user.click(screen.getByRole("button", { name: /^test connection$/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/^connection test$/i)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
+ });
+
+ it("suppresses the mismatch banner while a component query is still loading", async () => {
+ // Before all three queries have resolved, the aggregate must not stand
+ // in as {0,0,0} and get compared against the handshake — that flags a
+ // spurious mismatch during the loading window, which is longest when a
+ // query hangs rather than erroring outright.
+ const user = userEvent.setup();
+ mswServer.use(
+ http.get("*/servers/:id/tools", async () => {
+ await delay("infinite");
+ return HttpResponse.json({ tools: [] });
+ }),
+ http.post(HANDSHAKE_ENDPOINT, () =>
+ HttpResponse.json({
+ success: true,
+ latencyMs: 10,
+ componentCounts: { tools: 1 },
+ }),
+ ),
+ );
+
+ render(
+ ,
+ );
+
+ await user.click(await screen.findByRole("tab", { name: "Try it" }));
+ await user.click(screen.getByRole("button", { name: /^test connection$/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/^connection test$/i)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument();
+ });
+
it("resets to the try-it tab when a new server is selected", async () => {
const user = userEvent.setup();
const { rerender } = render(
diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx
index b1ea443..3e1ffba 100644
--- a/src/components/gateways/VirtualServerDetailsPanel.tsx
+++ b/src/components/gateways/VirtualServerDetailsPanel.tsx
@@ -231,23 +231,29 @@ export function VirtualServerDetailsPanel({
// Fetch components data - only when panel is open and server exists
const fetchEnabled = open && Boolean(server?.id);
- const { data: toolsData, isLoading: toolsLoading } = useQuery<{ tools: Tool[] }>(toolsPath, {
+ const {
+ data: toolsData,
+ isLoading: toolsLoading,
+ error: toolsError,
+ } = useQuery<{ tools: Tool[] }>(toolsPath, {
enabled: fetchEnabled,
});
- const { data: resourcesData, isLoading: resourcesLoading } = useQuery<{ resources: Resource[] }>(
- resourcesPath,
- {
- enabled: fetchEnabled,
- },
- );
+ const {
+ data: resourcesData,
+ isLoading: resourcesLoading,
+ error: resourcesError,
+ } = useQuery<{ resources: Resource[] }>(resourcesPath, {
+ enabled: fetchEnabled,
+ });
- const { data: promptsData, isLoading: promptsLoading } = useQuery<{ prompts: Prompt[] }>(
- promptsPath,
- {
- enabled: fetchEnabled,
- },
- );
+ const {
+ data: promptsData,
+ isLoading: promptsLoading,
+ error: promptsError,
+ } = useQuery<{ prompts: Prompt[] }>(promptsPath, {
+ enabled: fetchEnabled,
+ });
const fetchedComponents = useMemo((): ComponentWithType[] => {
const tools = Array.isArray(toolsData) ? toolsData : toolsData?.tools || [];
@@ -292,14 +298,33 @@ export function VirtualServerDetailsPanel({
// MCP endpoint, which only ever see enabled components — so a disabled
// component here must be excluded too, or a server with one disabled tool
// would show a permanent, spurious mismatch.
+ //
+ // Only computed from `fetchedComponents` (never the `buildComponentItems`
+ // fallback, which carries no `enabled` field and can't be filtered) and
+ // only when all three component queries succeeded — a failed query would
+ // otherwise silently contribute 0 and trigger a false mismatch. Also
+ // withheld until all three have resolved at least once, since `useQuery`
+ // never clears `data` on a later error and the pre-resolve state would
+ // otherwise compare a `{ tools: 0, resources: 0, prompts: 0 }` aggregate
+ // against a handshake that already has real counts.
const aggregatedComponentCounts = useMemo(() => {
+ if (toolsError || resourcesError || promptsError) return undefined;
+ if (!toolsData || !resourcesData || !promptsData) return undefined;
const counts: Record = { tools: 0, resources: 0, prompts: 0 };
- for (const component of allComponents) {
+ for (const component of fetchedComponents) {
if (component.enabled === false) continue;
counts[component.type] = (counts[component.type] ?? 0) + 1;
}
return counts;
- }, [allComponents]);
+ }, [
+ fetchedComponents,
+ toolsError,
+ resourcesError,
+ promptsError,
+ toolsData,
+ resourcesData,
+ promptsData,
+ ]);
const sourceIds = useMemo(
() =>