diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts
new file mode 100644
index 0000000..7a0f5a7
--- /dev/null
+++ b/e2e/server-catalog.spec.ts
@@ -0,0 +1,74 @@
+import { test, expect, MOCK_CSRF_TOKEN } from "./fixtures/api-mock";
+import { APP } from "./utils/paths";
+
+const CATALOG_ROUTE = (url: URL) => /^(?:\/api)?\/v1\/catalog$/.test(url.pathname);
+const REGISTER_ROUTE = (url: URL) =>
+ /^(?:\/api)?\/v1\/catalog\/open-notes\/register$/.test(url.pathname);
+
+const OPEN_SERVER = {
+ id: "open-notes",
+ name: "Public Notes",
+ category: "Productivity",
+ url: "https://notes.example/mcp",
+ auth_type: "Open",
+ provider: "Example",
+ description: "Search public notes and documents",
+ tags: ["search", "documents"],
+ transport: "STREAMABLEHTTP",
+ is_available: true,
+ is_registered: false,
+};
+
+test.describe("Server catalog", () => {
+ test.beforeEach(async ({ apiMock }) => {
+ await apiMock.mockSession();
+ });
+
+ test("adds an open server and refreshes its card to Connected", async ({ page }) => {
+ let registered = false;
+ let registerCalls = 0;
+
+ await page.route(CATALOG_ROUTE, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ servers: [{ ...OPEN_SERVER, is_registered: registered }],
+ total: 1,
+ categories: ["Productivity"],
+ auth_types: ["Open"],
+ providers: ["Example"],
+ all_tags: ["search", "documents"],
+ }),
+ });
+ });
+
+ await page.route(REGISTER_ROUTE, async (route) => {
+ expect(route.request().method()).toBe("POST");
+ expect(route.request().headers()["x-csrf-token"]).toBe(MOCK_CSRF_TOKEN);
+ registerCalls += 1;
+ registered = true;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ success: true,
+ server_id: "gateway-public-notes",
+ message: "Server registered successfully",
+ }),
+ });
+ });
+
+ await page.goto(APP.SERVER_CATALOG);
+ await expect(page.getByRole("heading", { name: "Public Notes" })).toBeVisible();
+
+ await page.getByRole("button", { name: "Add" }).click();
+
+ await expect.poll(() => registerCalls).toBe(1);
+ const catalog = page.getByRole("list", { name: "Catalog servers" });
+ await expect(catalog.getByText("Connected", { exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Add" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "View Public Notes" })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Actions for Public Notes" })).toBeVisible();
+ });
+});
diff --git a/src/api/catalog.test.ts b/src/api/catalog.test.ts
new file mode 100644
index 0000000..51c3d7b
--- /dev/null
+++ b/src/api/catalog.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { http, HttpResponse } from "msw";
+
+import { server } from "@/test/mocks/server";
+import { registerCatalogServer } from "./catalog";
+
+describe("registerCatalogServer", () => {
+ it("POSTs the URL-encoded catalog id through the API proxy", async () => {
+ let requestPath = "";
+ server.use(
+ http.post("*/api/v1/catalog/:catalogId/register", ({ request }) => {
+ requestPath = new URL(request.url).pathname;
+ return HttpResponse.json({
+ success: true,
+ server_id: "gateway-1",
+ message: "Registered",
+ });
+ }),
+ );
+
+ const result = await registerCatalogServer("server/id with space");
+
+ expect(requestPath).toBe("/api/v1/catalog/server%2Fid%20with%20space/register");
+ expect(result).toEqual({
+ success: true,
+ server_id: "gateway-1",
+ message: "Registered",
+ });
+ });
+});
diff --git a/src/api/catalog.ts b/src/api/catalog.ts
new file mode 100644
index 0000000..cdf08a0
--- /dev/null
+++ b/src/api/catalog.ts
@@ -0,0 +1,11 @@
+import { api } from "./client";
+import type { CatalogServerRegisterResponse } from "@/generated/types";
+
+/** Register an open catalog entry through the authenticated BFF proxy. */
+export async function registerCatalogServer(
+ catalogId: string,
+): Promise {
+ return api.post(
+ `/v1/catalog/${encodeURIComponent(catalogId)}/register`,
+ );
+}
diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx
index 1bb9b95..fc4a9ee 100644
--- a/src/components/server-catalog/CatalogResults.tsx
+++ b/src/components/server-catalog/CatalogResults.tsx
@@ -1,9 +1,9 @@
-import { useId, useState } from "react";
+import { useId, useRef, useState } from "react";
import type { ReactNode } from "react";
+import { CircleCheck, EllipsisVertical, FileText, Plus } from "lucide-react";
import { useIntl } from "react-intl";
import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder";
-import { StatusDot } from "@/components/dashboard/StatusDot";
import { ServerIcon } from "@/components/servers/ServerIcon";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -15,14 +15,20 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import type { CatalogServer } from "@/generated/types";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
-function getSafeCatalogLogoUrl(logoUrl: string | null | undefined): string | null {
- if (!logoUrl) return null;
+function getSafeExternalUrl(value: string | null | undefined): string | null {
+ if (!value) return null;
try {
- const parsed = new URL(logoUrl);
+ const parsed = new URL(value);
return parsed.protocol === "https:" && !parsed.username && !parsed.password
? parsed.href
: null;
@@ -33,7 +39,7 @@ function getSafeCatalogLogoUrl(logoUrl: string | null | undefined): string | nul
function CatalogLogo({ server }: { server: CatalogServer }) {
const [failedLogoUrl, setFailedLogoUrl] = useState(null);
- const logoUrl = getSafeCatalogLogoUrl(server.logo_url);
+ const logoUrl = getSafeExternalUrl(server.logo_url);
if (!logoUrl || failedLogoUrl === logoUrl) {
return (
@@ -64,12 +70,18 @@ function CatalogLogo({ server }: { server: CatalogServer }) {
function CatalogCard({
server,
onView,
+ onAdd,
+ isAdding,
}: {
server: CatalogServer;
- onView: (trigger: HTMLButtonElement) => void;
+ onView: (trigger: HTMLElement) => void;
+ onAdd: () => void;
+ isAdding: boolean;
}) {
const intl = useIntl();
const headingId = useId();
+ const actionsTriggerRef = useRef(null);
+ const isOpeningDetailsRef = useRef(false);
return (
@@ -78,11 +90,6 @@ function CatalogCard({
- {server.is_registered && (
-
- {intl.formatMessage({ id: "mcpServer.catalog.connected" })}
-
- )}
@@ -92,19 +99,77 @@ function CatalogCard({
{server.description}
-
-
+
+ {server.is_registered ? (
+ <>
+
+
+ {intl.formatMessage({ id: "mcpServer.catalog.connected" })}
+
+
+
+
+
+ {
+ if (!isOpeningDetailsRef.current) return;
+ event.preventDefault();
+ isOpeningDetailsRef.current = false;
+ }}
+ >
+ {
+ if (!actionsTriggerRef.current) return;
+ isOpeningDetailsRef.current = true;
+ onView(actionsTriggerRef.current);
+ }}
+ >
+ {intl.formatMessage({ id: "mcpServer.catalog.viewDetails" })}
+
+
+
+ >
+ ) : (
+
+ )}
+
+ {!server.is_registered && (
+
+ )}
@@ -190,10 +255,14 @@ export function CatalogResults({
servers,
emptyStateMessageId,
onView,
+ onAdd,
+ addingServerIds,
}: {
servers: CatalogServer[];
emptyStateMessageId: string;
- onView: (server: CatalogServer, trigger: HTMLButtonElement) => void;
+ onView: (server: CatalogServer, trigger: HTMLElement) => void;
+ onAdd: (server: CatalogServer) => void;
+ addingServerIds: ReadonlySet
;
}) {
const intl = useIntl();
const announcedCount = useDebouncedValue(servers.length, 300);
@@ -213,6 +282,8 @@ export function CatalogResults({
key={server.id}
server={server}
onView={(trigger) => onView(server, trigger)}
+ onAdd={() => onAdd(server)}
+ isAdding={addingServerIds.has(server.id)}
/>
))}
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index 7215ead..e424e68 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -24,8 +24,12 @@
"mcpServer.catalog.selectTags": "Select...",
"mcpServer.catalog.connected": "Connected",
"mcpServer.catalog.notConnected": "Not connected",
- "mcpServer.catalog.view": "View",
"mcpServer.catalog.viewServer": "View {name}",
+ "mcpServer.catalog.add": "Add",
+ "mcpServer.catalog.adding": "Adding…",
+ "mcpServer.catalog.addError": "Unable to add this server. Try again.",
+ "mcpServer.catalog.actionsFor": "Actions for {name}",
+ "mcpServer.catalog.viewDetails": "View details",
"mcpServer.catalog.viewOptions": "Catalog view",
"mcpServer.catalog.transport": "Transport",
"mcpServer.catalog.status": "Status",
diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json
index 5d7d329..32f3dd7 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -24,8 +24,12 @@
"mcpServer.catalog.selectTags": "Seleccionar...",
"mcpServer.catalog.connected": "Conectado",
"mcpServer.catalog.notConnected": "No conectado",
- "mcpServer.catalog.view": "Ver",
"mcpServer.catalog.viewServer": "Ver {name}",
+ "mcpServer.catalog.add": "Añadir",
+ "mcpServer.catalog.adding": "Añadiendo…",
+ "mcpServer.catalog.addError": "No se pudo añadir este servidor. Inténtalo de nuevo.",
+ "mcpServer.catalog.actionsFor": "Acciones para {name}",
+ "mcpServer.catalog.viewDetails": "Ver detalles",
"mcpServer.catalog.viewOptions": "Vista del catálogo",
"mcpServer.catalog.transport": "Transporte",
"mcpServer.catalog.status": "Estado",
diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json
index 5eaa49f..421f6e3 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -24,8 +24,12 @@
"mcpServer.catalog.selectTags": "Selecionar...",
"mcpServer.catalog.connected": "Conectado",
"mcpServer.catalog.notConnected": "Não conectado",
- "mcpServer.catalog.view": "Ver",
"mcpServer.catalog.viewServer": "Ver {name}",
+ "mcpServer.catalog.add": "Adicionar",
+ "mcpServer.catalog.adding": "Adicionando…",
+ "mcpServer.catalog.addError": "Não foi possível adicionar este servidor. Tente novamente.",
+ "mcpServer.catalog.actionsFor": "Ações para {name}",
+ "mcpServer.catalog.viewDetails": "Ver detalhes",
"mcpServer.catalog.viewOptions": "Visualização do catálogo",
"mcpServer.catalog.transport": "Transporte",
"mcpServer.catalog.status": "Status",
diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx
index 92e8eec..936204e 100644
--- a/src/pages/ServerCatalog.test.tsx
+++ b/src/pages/ServerCatalog.test.tsx
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { registerCatalogServer } from "@/api/catalog";
import type { CatalogListResponse, CatalogServer } from "@/generated/types";
import { useQuery } from "@/hooks/useQuery";
import { I18nProvider } from "@/i18n";
@@ -12,8 +13,12 @@ import { ServerCatalog } from "./ServerCatalog";
vi.mock("@/hooks/useQuery", () => ({
useQuery: vi.fn(),
}));
+vi.mock("@/api/catalog", () => ({
+ registerCatalogServer: vi.fn(),
+}));
const mockUseQuery = vi.mocked(useQuery);
+const mockRegisterCatalogServer = vi.mocked(registerCatalogServer);
const openConnected: CatalogServer = {
id: "open-connected",
@@ -111,6 +116,11 @@ describe("ServerCatalog", () => {
beforeEach(() => {
window.history.replaceState({}, "", "/app/");
mockUseQuery.mockReturnValue(queryResult());
+ mockRegisterCatalogServer.mockResolvedValue({
+ success: true,
+ server_id: "registered-server",
+ message: "Registered",
+ });
});
it("uses the catalog GET endpoint and shared loader", () => {
@@ -122,6 +132,23 @@ describe("ServerCatalog", () => {
expect(screen.getByRole("status", { name: "Loading..." })).toBeInTheDocument();
});
+ it("keeps cached catalog data visible during refreshes and refresh failures", () => {
+ mockUseQuery.mockReturnValue(queryResult({ isLoading: true }));
+ const { unmount } = renderWithRouter();
+
+ expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument();
+ expect(screen.queryByRole("status", { name: "Loading..." })).not.toBeInTheDocument();
+
+ unmount();
+ mockUseQuery.mockReturnValue(
+ queryResult({ error: { message: "refresh failed", status: 500 } }),
+ );
+ renderWithRouter();
+
+ expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument();
+ expect(screen.queryByText("Unable to load server catalog. Try again.")).not.toBeInTheDocument();
+ });
+
it("renders only exact Open entries and marks registered servers connected", () => {
renderWithRouter();
@@ -133,7 +160,9 @@ describe("ServerCatalog", () => {
expect(screen.queryByText("Secret Service")).not.toBeInTheDocument();
expect(within(catalogList).getByText("Connected")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("2 servers shown");
- expect(screen.getByRole("button", { name: "View Globalping" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Actions for Globalping" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "View Globalping" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "View Public Notes" })).toBeInTheDocument();
expect(screen.queryByText(/registration coming soon/i)).not.toBeInTheDocument();
});
@@ -142,8 +171,9 @@ describe("ServerCatalog", () => {
const user = userEvent.setup();
renderWithRouter();
- const viewButton = screen.getByRole("button", { name: "View Globalping" });
- await user.click(viewButton);
+ const actionsButton = screen.getByRole("button", { name: "Actions for Globalping" });
+ await user.click(actionsButton);
+ await user.click(screen.getByRole("menuitem", { name: "View details" }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByRole("heading", { name: "Globalping" })).toBeInTheDocument();
@@ -153,10 +183,107 @@ describe("ServerCatalog", () => {
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
- await waitFor(() => expect(viewButton).toHaveFocus());
+ await waitFor(() => expect(actionsButton).toHaveFocus());
+
+ await user.click(screen.getByRole("button", { name: "Add" }));
+ expect(mockRegisterCatalogServer).toHaveBeenCalledWith("open-available");
+ });
+
+ it("reports catalog registration failures", async () => {
+ const user = userEvent.setup();
+ mockRegisterCatalogServer.mockRejectedValue(new Error("network detail must not leak"));
+ renderWithRouter();
+
+ await user.click(screen.getByRole("button", { name: "Add" }));
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Unable to add this server. Try again.",
+ );
+ expect(screen.queryByText(/network detail/i)).not.toBeInTheDocument();
+ });
+
+ it("registers an available server and refreshes the catalog", async () => {
+ const user = userEvent.setup();
+ const refetch = vi.fn().mockResolvedValue(undefined);
+ mockUseQuery.mockReturnValue(queryResult({ refetch }));
+ renderWithRouter();
+
+ await user.click(screen.getByRole("button", { name: "Add" }));
+
+ await waitFor(() => expect(mockRegisterCatalogServer).toHaveBeenCalledWith("open-available"));
+ expect(refetch).toHaveBeenCalledOnce();
+ const card = screen.getByRole("heading", { name: "Public Notes" }).closest("article")!;
+ expect(within(card).getByText("Connected")).toBeInTheDocument();
+ expect(within(card).queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
+ });
+
+ it("keeps successful registration when catalog refresh fails", async () => {
+ const user = userEvent.setup();
+ const refetch = vi.fn().mockRejectedValue(new Error("refresh failed"));
+ mockUseQuery.mockReturnValue(queryResult({ refetch }));
+ renderWithRouter();
+
+ const card = screen.getByRole("heading", { name: "Public Notes" }).closest("article")!;
+ await user.click(within(card).getByRole("button", { name: "Add" }));
+
+ expect(await within(card).findByText("Connected")).toBeInTheDocument();
+ await waitFor(() => expect(refetch).toHaveBeenCalledOnce());
+ expect(screen.queryByText("Unable to add this server. Try again.")).not.toBeInTheDocument();
+ expect(within(card).queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
+ });
- await user.click(screen.getByRole("button", { name: "View Public Notes" }));
- expect(within(screen.getByRole("dialog")).getByText("Not connected")).toBeInTheDocument();
+ it("tracks concurrent registrations independently", async () => {
+ const user = userEvent.setup();
+ const secondAvailable = {
+ ...openAvailable,
+ id: "open-weather",
+ name: "Public Weather",
+ };
+ let resolveNotes!: (value: Awaited>) => void;
+ let resolveWeather!: (value: Awaited>) => void;
+ mockRegisterCatalogServer.mockImplementation(
+ (id) =>
+ new Promise((resolve) => {
+ if (id === openAvailable.id) resolveNotes = resolve;
+ if (id === secondAvailable.id) resolveWeather = resolve;
+ }),
+ );
+ mockUseQuery.mockReturnValue(
+ queryResult({
+ data: { ...response, servers: [openAvailable, secondAvailable], total: 2 },
+ }),
+ );
+ renderWithRouter();
+
+ const notesCard = screen.getByRole("heading", { name: "Public Notes" }).closest("article")!;
+ const weatherCard = screen.getByRole("heading", { name: "Public Weather" }).closest("article")!;
+ await user.click(within(notesCard).getByRole("button", { name: "Add" }));
+ await user.click(within(weatherCard).getByRole("button", { name: "Add" }));
+
+ expect(within(notesCard).getByRole("button", { name: "Adding…" })).toBeDisabled();
+ expect(within(weatherCard).getByRole("button", { name: "Adding…" })).toBeDisabled();
+
+ resolveWeather({ success: true, server_id: "weather", message: "Registered" });
+ await waitFor(() => expect(within(weatherCard).getByText("Connected")).toBeInTheDocument());
+ expect(within(notesCard).getByRole("button", { name: "Adding…" })).toBeDisabled();
+
+ resolveNotes({ success: true, server_id: "notes", message: "Registered" });
+ await waitFor(() => expect(within(notesCard).getByText("Connected")).toBeInTheDocument());
+ });
+
+ it("shows connected status in details opened from the action menu", async () => {
+ const user = userEvent.setup();
+ mockUseQuery.mockReturnValue(
+ queryResult({ data: { ...response, servers: [{ ...openAvailable, is_registered: true }] } }),
+ );
+ renderWithRouter();
+
+ const actionsButton = screen.getByRole("button", { name: "Actions for Public Notes" });
+ await user.click(actionsButton);
+ await user.click(screen.getByRole("menuitem", { name: "View details" }));
+ const dialog = screen.getByRole("dialog");
+ expect(within(dialog).getByText("Connected")).toBeInTheDocument();
+ await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
});
it("renders safe remote logos and falls back when loading fails", () => {
diff --git a/src/pages/ServerCatalog.tsx b/src/pages/ServerCatalog.tsx
index b982b6f..eb7a2ba 100644
--- a/src/pages/ServerCatalog.tsx
+++ b/src/pages/ServerCatalog.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useIntl } from "react-intl";
+import { registerCatalogServer } from "@/api/catalog";
import {
CatalogResults,
CatalogServerDetailsDialog,
@@ -146,7 +147,13 @@ function CatalogPageLayout({ children }: { children: ReactNode }) {
export function ServerCatalog() {
const intl = useIntl();
const [selectedServer, setSelectedServer] = useState(null);
- const lastViewTriggerRef = useRef(null);
+ const [addingServerIds, setAddingServerIds] = useState>(() => new Set());
+ const [registeredServerIds, setRegisteredServerIds] = useState>(
+ () => new Set(),
+ );
+ const [registrationError, setRegistrationError] = useState(null);
+ const addingServerIdsRef = useRef(new Set());
+ const lastViewTriggerRef = useRef(null);
const { data, error, isLoading, refetch } = useQuery(CATALOG_PATH);
const { filters, updateQuery, applyFilters } = useCatalogFilters();
const [search, setSearch] = useState(filters.search);
@@ -167,7 +174,15 @@ export function ServerCatalog() {
// until Add filters is pressed, so an unapplied draft must never reach the grid.
const activeFilters = useMemo(() => ({ ...filters, search }), [filters, search]);
- const openServers = useMemo(() => getOpenServers(data?.servers ?? []), [data?.servers]);
+ const openServers = useMemo(
+ () =>
+ getOpenServers(data?.servers ?? []).map((server) =>
+ registeredServerIds.has(server.id) && !server.is_registered
+ ? { ...server, is_registered: true }
+ : server,
+ ),
+ [data?.servers, registeredServerIds],
+ );
const servers = useMemo(
() => filterOpenServers(openServers, activeFilters),
[openServers, activeFilters],
@@ -193,18 +208,52 @@ export function ServerCatalog() {
: "mcpServer.catalog.noResults";
const activeFilterCount = filters.category.length + filters.provider.length + filters.tags.length;
- const handleView = useCallback((server: CatalogServer, trigger: HTMLButtonElement) => {
+ const handleView = useCallback((server: CatalogServer, trigger: HTMLElement) => {
lastViewTriggerRef.current = trigger;
setSelectedServer(server);
}, []);
+ const handleAdd = useCallback(
+ async (server: CatalogServer) => {
+ if (addingServerIdsRef.current.has(server.id)) return;
+
+ addingServerIdsRef.current.add(server.id);
+ setAddingServerIds(new Set(addingServerIdsRef.current));
+ setRegistrationError(null);
+ try {
+ const result = await registerCatalogServer(server.id);
+ if (!result.success) {
+ setRegistrationError(
+ result.message || intl.formatMessage({ id: "mcpServer.catalog.addError" }),
+ );
+ return;
+ }
+
+ setRegisteredServerIds((current) => new Set(current).add(server.id));
+
+ try {
+ await refetch();
+ } catch {
+ // Registration already succeeded. Keep optimistic connected state
+ // instead of misreporting a refresh failure as an add failure.
+ }
+ } catch {
+ setRegistrationError(intl.formatMessage({ id: "mcpServer.catalog.addError" }));
+ } finally {
+ addingServerIdsRef.current.delete(server.id);
+ setAddingServerIds(new Set(addingServerIdsRef.current));
+ }
+ },
+ [intl, refetch],
+ );
+
const handleDetailsOpenChange = useCallback((open: boolean) => {
if (open) return;
setSelectedServer(null);
window.setTimeout(() => lastViewTriggerRef.current?.focus(), 0);
}, []);
- if (isLoading) {
+ if (isLoading && !data) {
return (
@@ -214,7 +263,7 @@ export function ServerCatalog() {
);
}
- if (error?.status === 404) {
+ if (error?.status === 404 && !data) {
return (
@@ -224,7 +273,7 @@ export function ServerCatalog() {
);
}
- if (error) {
+ if (error && !data) {
return (
@@ -262,10 +311,22 @@ export function ServerCatalog() {
onApply={applyFilters}
/>
+ {registrationError && (
+
+ setRegistrationError(null)}
+ />
+
+ )}
+
void handleAdd(server)}
+ addingServerIds={addingServerIds}
/>