diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts index 34056980..b2050e4f 100644 --- a/e2e/virtual-servers.spec.ts +++ b/e2e/virtual-servers.spec.ts @@ -439,7 +439,7 @@ test.describe("Virtual Servers page", () => { await page.getByRole("checkbox", { name: "Select github-mcp" }).check(); await page.getByRole("button", { name: "Submit" }).click(); - await expect(page.getByRole("alert")).toHaveText("Unable to load tools"); + await expect(page.getByRole("alert")).toHaveText("github-mcp: Unable to load tools"); await expect(page).toHaveURL(/\/app\/gateways\/create-server$/); }); diff --git a/src/components/gateways/SourceSelection.test.tsx b/src/components/gateways/SourceSelection.test.tsx index 970fc1d3..f1daf2ce 100644 --- a/src/components/gateways/SourceSelection.test.tsx +++ b/src/components/gateways/SourceSelection.test.tsx @@ -366,4 +366,199 @@ describe("SourceSelection", () => { expect(grpcAction).not.toHaveBeenCalled(); expect(screen.getAllByRole("button", { name: "Connect" })).toHaveLength(1); }); + it("keeps every source selectable and explains its state", async () => { + const user = userEvent.setup(); + server.use( + http.get("*/v1/mcp-servers", () => + HttpResponse.json({ + gateways: [ + { + id: "s-off", + name: "offline-src", + enabled: true, + reachable: false, + lastSeen: "2026-01-01T00:00:00Z", + visibility: "public", + tool_count: 4, + }, + { + id: "s-draft", + name: "draft-src", + enabled: false, + reachable: false, + visibility: "public", + tool_count: 0, + }, + ], + }), + ), + http.get("*/oauth/status", () => HttpResponse.json({})), + ); + + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { + name: "Add tools, resources, and prompts from connected sources", + }), + ); + + expect(await screen.findByText("offline-src")).toBeInTheDocument(); + expect(screen.getByText("Offline")).toBeInTheDocument(); + expect(screen.getByText("Inactive")).toBeInTheDocument(); + + // An unavailable source stays selectable: its components remain in the catalog. + for (const name of ["Select offline-src", "Select draft-src"]) { + expect(screen.getByRole("checkbox", { name })).toBeEnabled(); + } + }); + + it("warns at submit only about selected sources with nothing to add", async () => { + const user = userEvent.setup(); + server.use( + http.get("*/v1/mcp-servers", () => + HttpResponse.json({ + gateways: [ + { + id: "s-full", + name: "full-src", + enabled: true, + reachable: true, + visibility: "public", + tool_count: 3, + }, + { + id: "s-empty", + name: "empty-src", + enabled: true, + reachable: true, + visibility: "public", + tool_count: 0, + resource_count: 0, + prompt_count: 0, + }, + ], + }), + ), + http.get("*/oauth/status", () => HttpResponse.json({})), + ); + + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { + name: "Add tools, resources, and prompts from connected sources", + }), + ); + await screen.findByText("full-src"); + + await user.click(screen.getByRole("checkbox", { name: "Select full-src" })); + expect(screen.queryByText(/no components to add yet/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole("checkbox", { name: "Select empty-src" })); + expect(screen.getByText(/empty-src has no components to add yet/)).toBeInTheDocument(); + + // Non-blocking: submitting stays available. + expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled(); + }); + + it("reports the selected source names to the caller", async () => { + const user = userEvent.setup(); + const onSelectSources = vi.fn(); + server.use( + http.get("*/v1/mcp-servers", () => + HttpResponse.json({ + gateways: [ + { + id: "s-1", + name: "alpha", + enabled: true, + reachable: true, + visibility: "public", + tool_count: 1, + }, + ], + }), + ), + http.get("*/oauth/status", () => HttpResponse.json({})), + ); + + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { + name: "Add tools, resources, and prompts from connected sources", + }), + ); + await screen.findByText("alpha"); + await user.click(screen.getByRole("checkbox", { name: "Select alpha" })); + + expect(onSelectSources).toHaveBeenCalledWith(["s-1"], { "s-1": "alpha" }); + }); + + it("keeps the name of a selected source that has dropped out of the list", async () => { + const user = userEvent.setup(); + const onSelectSources = vi.fn(); + server.use( + http.get("*/v1/mcp-servers", () => + HttpResponse.json({ + gateways: [ + { id: "s-1", name: "alpha", enabled: true, reachable: true, tool_count: 1 }, + { id: "s-2", name: "beta", enabled: true, reachable: true, tool_count: 1 }, + ], + }), + ), + http.get("*/oauth/status", () => HttpResponse.json({})), + ); + + const { rerender } = renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { + name: "Add tools, resources, and prompts from connected sources", + }), + ); + await screen.findByText("beta"); + await user.click(screen.getByRole("checkbox", { name: "Select beta" })); + + // beta leaves the available list while it is still selected. + rerender( + , + ); + expect(screen.queryByText("beta")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("checkbox", { name: "Select alpha" })); + + expect(onSelectSources).toHaveBeenLastCalledWith(["s-2", "s-1"], { + "s-1": "alpha", + "s-2": "beta", + }); + }); }); diff --git a/src/components/gateways/SourceSelection.tsx b/src/components/gateways/SourceSelection.tsx index 97d6df7e..1a57ad9c 100644 --- a/src/components/gateways/SourceSelection.tsx +++ b/src/components/gateways/SourceSelection.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useIntl } from "react-intl"; import { ArrowLeft, @@ -61,6 +61,10 @@ function getPromptCount(server: ListedMCPServer) { return server.promptCount ?? server.prompt_count ?? 0; } +function getComponentTotal(server: ListedMCPServer) { + return getToolCount(server) + getResourceCount(server) + getPromptCount(server); +} + function getVisibilityConfig(visibility: ListedMCPServer["visibility"]) { switch (visibility) { case "private": @@ -80,7 +84,7 @@ export function SourceSelection({ }: { actionCards: ActionCard[]; associatedMCPServerIds?: string[]; - onSelectSources?: (selectedIds: string[]) => void; + onSelectSources?: (selectedIds: string[], namesById: Record) => void; createServerActions?: { onBack: () => void; onSkip: () => void; @@ -98,6 +102,7 @@ export function SourceSelection({ const [isComponentsPanelOpen, setIsComponentsPanelOpen] = useState(false); const [hasRequestedMCPServers, setHasRequestedMCPServers] = useState(false); const [selectedMCPServerIds, setSelectedMCPServerIds] = useState>(new Set()); + const selectedNamesRef = useRef>({}); const { data: mcpServersData, error: mcpServersError, @@ -127,6 +132,16 @@ export function SourceSelection({ const hasSelectedMCPServers = selectedMCPServerIds.size > 0; const panelId = "connected-sources-panel"; + // Selecting an offline source still works: its components stay in the catalog. + // Only a source with nothing to contribute leaves the virtual server empty. + const emptySelectedSources = useMemo( + () => + availableMCPServers.filter( + (server) => selectedMCPServerIds.has(server.id) && getComponentTotal(server) === 0, + ), + [availableMCPServers, selectedMCPServerIds], + ); + const handleToggleComponentsPanel = () => { setIsComponentsPanelOpen((open) => !open); setHasRequestedMCPServers(true); @@ -137,7 +152,16 @@ export function SourceSelection({ if (checked) next.add(serverId); else next.delete(serverId); setSelectedMCPServerIds(next); - onSelectSources?.(Array.from(next)); + + // Kept from when each source was picked, so a refetch that drops one does not lose its name. + const names = selectedNamesRef.current; + if (checked) { + const selected = availableMCPServers.find((server) => server.id === serverId); + if (selected) names[serverId] = selected.name; + } else { + delete names[serverId]; + } + onSelectSources?.(Array.from(next), { ...names }); }; return ( @@ -419,6 +443,18 @@ export function SourceSelection({ )} + {emptySelectedSources.length > 0 && ( +

+ {intl.formatMessage( + { id: "gateways.source.emptySelectionWarning" }, + { + count: emptySelectedSources.length, + names: emptySelectedSources.map((server) => server.name).join(", "), + }, + )} +

+ )} +