Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/components/gateways/SourceSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,149 @@ 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(
<SourceSelection
actionCards={actionCards}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);

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(
<SourceSelection
actionCards={actionCards}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);

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(
<SourceSelection
actionCards={actionCards}
onSelectSources={onSelectSources}
createServerActions={{ onBack: vi.fn(), onSkip: vi.fn() }}
/>,
);

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" });
});
});
88 changes: 43 additions & 45 deletions src/components/gateways/SourceSelection.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { useMemo, useState } from "react";
import { useIntl } from "react-intl";
import {
Activity,
ArrowLeft,
Box,
Building2,
ChevronDown,
ChevronRight,
CircleSlash,
Lock,
MessageSquareCode,
Plus,
Expand All @@ -21,10 +19,11 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Loading } from "@/components/ui/loading";
import { TruncatedText } from "@/components/ui/truncated-text";
import type { ActionCard } from "@/components/gateways/types";
import { ServerStatusIndicator } from "@/components/servers/ServerStatusIndicator";
import { useQuery } from "@/hooks/useQuery";
import { useOAuthTokenStatuses } from "@/hooks/useOAuthTokenStatuses";
import { cn } from "@/lib/utils";
import { STATUS_ICON } from "@/lib/status";
import type { MCPServer, ServerStatus } from "@/types/server";
import type { MCPServer } from "@/types/server";

const MCP_SERVERS_QUERY_PATH = "/v1/mcp-servers?limit=100&include_inactive=true";

Expand Down Expand Up @@ -60,39 +59,8 @@ function getPromptCount(server: ListedMCPServer) {
return server.promptCount ?? server.prompt_count ?? 0;
}

function getServerStatus(server: ListedMCPServer): ServerStatus {
if (!server.enabled) return "draft";
if (!server.reachable) return server.lastSeen ? "warning" : "offline";
return "active";
}

function getStatusConfig(status: ServerStatus) {
switch (status) {
case "active":
return {
Icon: Activity,
labelId: "gateways.source.status.active",
className: "text-success",
};
case "warning":
return {
Icon: STATUS_ICON.warning,
labelId: "gateways.source.status.warning",
className: "text-warning",
};
case "offline":
return {
Icon: CircleSlash,
labelId: "gateways.source.status.offline",
className: "text-muted-foreground",
};
default:
return {
Icon: CircleSlash,
labelId: "gateways.source.status.inactive",
className: "text-muted-foreground",
};
}
function getComponentTotal(server: ListedMCPServer) {
return getToolCount(server) + getResourceCount(server) + getPromptCount(server);
}

function getVisibilityConfig(visibility: ListedMCPServer["visibility"]) {
Expand All @@ -114,7 +82,7 @@ export function SourceSelection({
}: {
actionCards: ActionCard[];
associatedMCPServerIds?: string[];
onSelectSources?: (selectedIds: string[]) => void;
onSelectSources?: (selectedIds: string[], namesById: Record<string, string>) => void;
createServerActions?: {
onBack: () => void;
onSkip: () => void;
Expand Down Expand Up @@ -151,6 +119,17 @@ export function SourceSelection({
);
const hasSelectedMCPServers = selectedMCPServerIds.size > 0;
const panelId = "connected-sources-panel";
const { oauthTokenStatuses } = useOAuthTokenStatuses(mcpServers);

// 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);
Expand All @@ -162,7 +141,14 @@ export function SourceSelection({
if (checked) next.add(serverId);
else next.delete(serverId);
setSelectedMCPServerIds(next);
onSelectSources?.(Array.from(next));
onSelectSources?.(
Array.from(next),
Object.fromEntries(
availableMCPServers
.filter((server) => next.has(server.id))
.map((server) => [server.id, server.name]),
),
);
};

return (
Expand Down Expand Up @@ -382,8 +368,6 @@ export function SourceSelection({
const promptCount = getPromptCount(server);
const visibility = getVisibilityConfig(server.visibility);
const VisibilityIcon = visibility.Icon;
const status = getStatusConfig(getServerStatus(server));
const StatusIcon = status.Icon;
const isSelected = selectedMCPServerIds.has(server.id);

return (
Expand Down Expand Up @@ -429,10 +413,12 @@ export function SourceSelection({
<VisibilityIcon className="size-3.5" />
{intl.formatMessage({ id: visibility.labelId })}
</span>
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<StatusIcon className={`size-3.5 ${status.className}`} />
{intl.formatMessage({ id: status.labelId })}
</span>
<ServerStatusIndicator
server={server}
oauthTokenStatus={oauthTokenStatuses[server.id]}
compact
className="justify-self-start"
/>
</div>
);
})}
Expand All @@ -442,6 +428,18 @@ export function SourceSelection({
</section>
)}

{emptySelectedSources.length > 0 && (
<p role="status" className="text-sm text-muted-foreground">
{intl.formatMessage(
{ id: "gateways.source.emptySelectionWarning" },
{
count: emptySelectedSources.length,
names: emptySelectedSources.map((server) => server.name).join(", "),
},
)}
</p>
)}

<div className="flex justify-end">
<Button
type="button"
Expand Down
Loading