From 687cb09351ea13f1d5949a06354c0008d8cf151e Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Fri, 14 Aug 2026 13:03:47 +0100
Subject: [PATCH 1/2] Add server catalog card actions
Signed-off-by: Vishu Bhatnagar
---
e2e/server-catalog.spec.ts | 73 +++++++++++++
src/api/catalog.test.ts | 30 ++++++
src/api/catalog.ts | 11 ++
.../server-catalog/CatalogResults.tsx | 102 +++++++++++++++---
src/i18n/locales/en-US/mcpServer.json | 6 ++
src/i18n/locales/es-ES/mcpServer.json | 6 ++
src/i18n/locales/pt-BR/mcpServer.json | 6 ++
src/pages/ServerCatalog.test.tsx | 53 ++++++++-
src/pages/ServerCatalog.tsx | 41 ++++++-
9 files changed, 308 insertions(+), 20 deletions(-)
create mode 100644 e2e/server-catalog.spec.ts
create mode 100644 src/api/catalog.test.ts
create mode 100644 src/api/catalog.ts
diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts
new file mode 100644
index 0000000..fc1a5c4
--- /dev/null
+++ b/e2e/server-catalog.spec.ts
@@ -0,0 +1,73 @@
+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" })).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..7c0a554 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, KeyRound, Lock, 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 requiresAuth = server.auth_type !== "Open";
return (
@@ -78,10 +90,15 @@ function CatalogCard({
- {server.is_registered && (
-
- {intl.formatMessage({ id: "mcpServer.catalog.connected" })}
-
+ {requiresAuth && (
+
+ {server.requires_api_key ? (
+
+ ) : (
+
+ )}
+ {intl.formatMessage({ id: "mcpServer.catalog.authRequired" })}
+
)}
@@ -92,18 +109,65 @@ function CatalogCard({
{server.description}
-
+
+ {server.is_registered ? (
+ <>
+
+
+ {intl.formatMessage({ id: "mcpServer.catalog.connected" })}
+
+
+
+
+
+
+ {
+ if (actionsTriggerRef.current) onView(actionsTriggerRef.current);
+ }}
+ >
+ {intl.formatMessage({ id: "mcpServer.catalog.viewDetails" })}
+
+
+
+ >
+ ) : (
+
+ )}
+
@@ -190,10 +254,14 @@ export function CatalogResults({
servers,
emptyStateMessageId,
onView,
+ onAdd,
+ addingServerId,
}: {
servers: CatalogServer[];
emptyStateMessageId: string;
- onView: (server: CatalogServer, trigger: HTMLButtonElement) => void;
+ onView: (server: CatalogServer, trigger: HTMLElement) => void;
+ onAdd: (server: CatalogServer) => void;
+ addingServerId: string | null;
}) {
const intl = useIntl();
const announcedCount = useDebouncedValue(servers.length, 300);
@@ -213,6 +281,8 @@ export function CatalogResults({
key={server.id}
server={server}
onView={(trigger) => onView(server, trigger)}
+ onAdd={() => onAdd(server)}
+ isAdding={addingServerId === server.id}
/>
))}
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index 7215ead..820033c 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -26,6 +26,12 @@
"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.authRequired": "Auth required",
"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..9ae059d 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -26,6 +26,12 @@
"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.authRequired": "Autenticación requerida",
"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..f34f0b1 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -26,6 +26,12 @@
"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.authRequired": "Autenticação necessária",
"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..dacd9f6 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", () => {
@@ -133,6 +143,8 @@ 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: "Actions for Globalping" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "View Globalping" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "View Public Notes" })).toBeInTheDocument();
expect(screen.queryByText(/registration coming soon/i)).not.toBeInTheDocument();
@@ -155,8 +167,45 @@ describe("ServerCatalog", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await waitFor(() => expect(viewButton).toHaveFocus());
- await user.click(screen.getByRole("button", { name: "View Public Notes" }));
- expect(within(screen.getByRole("dialog")).getByText("Not connected")).toBeInTheDocument();
+ 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();
+ });
+
+ 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(
);
+
+ await user.click(screen.getByRole("button", { name: "Actions for Public Notes" }));
+ await user.click(screen.getByRole("menuitem", { name: "View details" }));
+ expect(within(screen.getByRole("dialog")).getByText("Connected")).toBeInTheDocument();
});
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..496a500 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,9 @@ function CatalogPageLayout({ children }: { children: ReactNode }) {
export function ServerCatalog() {
const intl = useIntl();
const [selectedServer, setSelectedServer] = useState
(null);
- const lastViewTriggerRef = useRef(null);
+ const [addingServerId, setAddingServerId] = useState(null);
+ const [registrationError, setRegistrationError] = useState(null);
+ const lastViewTriggerRef = useRef(null);
const { data, error, isLoading, refetch } = useQuery(CATALOG_PATH);
const { filters, updateQuery, applyFilters } = useCatalogFilters();
const [search, setSearch] = useState(filters.search);
@@ -193,11 +196,33 @@ 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) => {
+ setAddingServerId(server.id);
+ setRegistrationError(null);
+ try {
+ const result = await registerCatalogServer(server.id);
+ if (!result.success) {
+ setRegistrationError(
+ result.message || intl.formatMessage({ id: "mcpServer.catalog.addError" }),
+ );
+ return;
+ }
+ await refetch();
+ } catch {
+ setRegistrationError(intl.formatMessage({ id: "mcpServer.catalog.addError" }));
+ } finally {
+ setAddingServerId(null);
+ }
+ },
+ [intl, refetch],
+ );
+
const handleDetailsOpenChange = useCallback((open: boolean) => {
if (open) return;
setSelectedServer(null);
@@ -262,10 +287,22 @@ export function ServerCatalog() {
onApply={applyFilters}
/>
+ {registrationError && (
+
+ setRegistrationError(null)}
+ />
+
+ )}
+
void handleAdd(server)}
+ addingServerId={addingServerId}
/>
From 00c124a2da4ec2db735dfb0bdee9e9d7f4ec9510 Mon Sep 17 00:00:00 2001
From: Vishu Bhatnagar
Date: Fri, 14 Aug 2026 15:13:20 +0100
Subject: [PATCH 2/2] Fix server catalog review feedback
Signed-off-by: Vishu Bhatnagar
---
e2e/server-catalog.spec.ts | 3 +-
.../server-catalog/CatalogResults.tsx | 59 ++++++------
src/i18n/locales/en-US/mcpServer.json | 2 -
src/i18n/locales/es-ES/mcpServer.json | 2 -
src/i18n/locales/pt-BR/mcpServer.json | 2 -
src/pages/ServerCatalog.test.tsx | 90 +++++++++++++++++--
src/pages/ServerCatalog.tsx | 42 +++++++--
7 files changed, 149 insertions(+), 51 deletions(-)
diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts
index fc1a5c4..7a0f5a7 100644
--- a/e2e/server-catalog.spec.ts
+++ b/e2e/server-catalog.spec.ts
@@ -68,6 +68,7 @@ test.describe("Server catalog", () => {
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" })).toBeVisible();
+ 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/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx
index 7c0a554..fc4a9ee 100644
--- a/src/components/server-catalog/CatalogResults.tsx
+++ b/src/components/server-catalog/CatalogResults.tsx
@@ -1,6 +1,6 @@
import { useId, useRef, useState } from "react";
import type { ReactNode } from "react";
-import { CircleCheck, EllipsisVertical, FileText, KeyRound, Lock, Plus } from "lucide-react";
+import { CircleCheck, EllipsisVertical, FileText, Plus } from "lucide-react";
import { useIntl } from "react-intl";
import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder";
@@ -81,7 +81,7 @@ function CatalogCard({
const intl = useIntl();
const headingId = useId();
const actionsTriggerRef = useRef(null);
- const requiresAuth = server.auth_type !== "Open";
+ const isOpeningDetailsRef = useRef(false);
return (
@@ -90,16 +90,6 @@ function CatalogCard({
- {requiresAuth && (
-
- {server.requires_api_key ? (
-
- ) : (
-
- )}
- {intl.formatMessage({ id: "mcpServer.catalog.authRequired" })}
-
- )}
@@ -131,10 +121,19 @@ function CatalogCard({
-
+ {
+ if (!isOpeningDetailsRef.current) return;
+ event.preventDefault();
+ isOpeningDetailsRef.current = false;
+ }}
+ >
{
- if (actionsTriggerRef.current) onView(actionsTriggerRef.current);
+ if (!actionsTriggerRef.current) return;
+ isOpeningDetailsRef.current = true;
+ onView(actionsTriggerRef.current);
}}
>
{intl.formatMessage({ id: "mcpServer.catalog.viewDetails" })}
@@ -157,18 +156,20 @@ function CatalogCard({
)}
-
+ {!server.is_registered && (
+
+ )}
@@ -255,13 +256,13 @@ export function CatalogResults({
emptyStateMessageId,
onView,
onAdd,
- addingServerId,
+ addingServerIds,
}: {
servers: CatalogServer[];
emptyStateMessageId: string;
onView: (server: CatalogServer, trigger: HTMLElement) => void;
onAdd: (server: CatalogServer) => void;
- addingServerId: string | null;
+ addingServerIds: ReadonlySet;
}) {
const intl = useIntl();
const announcedCount = useDebouncedValue(servers.length, 300);
@@ -282,7 +283,7 @@ export function CatalogResults({
server={server}
onView={(trigger) => onView(server, trigger)}
onAdd={() => onAdd(server)}
- isAdding={addingServerId === server.id}
+ isAdding={addingServerIds.has(server.id)}
/>
))}
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index 820033c..e424e68 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -24,14 +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.authRequired": "Auth required",
"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 9ae059d..32f3dd7 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -24,14 +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.authRequired": "Autenticación requerida",
"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 f34f0b1..421f6e3 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -24,14 +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.authRequired": "Autenticação necessária",
"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 dacd9f6..936204e 100644
--- a/src/pages/ServerCatalog.test.tsx
+++ b/src/pages/ServerCatalog.test.tsx
@@ -132,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();
@@ -145,7 +162,7 @@ describe("ServerCatalog", () => {
expect(screen.getByRole("status")).toHaveTextContent("2 servers shown");
expect(screen.getByRole("button", { name: "Actions for Globalping" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "View Globalping" })).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();
});
@@ -154,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();
@@ -165,7 +183,7 @@ 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");
@@ -194,6 +212,63 @@ describe("ServerCatalog", () => {
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();
+ });
+
+ 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 () => {
@@ -203,9 +278,12 @@ describe("ServerCatalog", () => {
);
renderWithRouter();
- await user.click(screen.getByRole("button", { name: "Actions for Public Notes" }));
+ const actionsButton = screen.getByRole("button", { name: "Actions for Public Notes" });
+ await user.click(actionsButton);
await user.click(screen.getByRole("menuitem", { name: "View details" }));
- expect(within(screen.getByRole("dialog")).getByText("Connected")).toBeInTheDocument();
+ 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 496a500..eb7a2ba 100644
--- a/src/pages/ServerCatalog.tsx
+++ b/src/pages/ServerCatalog.tsx
@@ -147,8 +147,12 @@ function CatalogPageLayout({ children }: { children: ReactNode }) {
export function ServerCatalog() {
const intl = useIntl();
const [selectedServer, setSelectedServer] = useState(null);
- const [addingServerId, setAddingServerId] = useState(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();
@@ -170,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],
@@ -203,7 +215,10 @@ export function ServerCatalog() {
const handleAdd = useCallback(
async (server: CatalogServer) => {
- setAddingServerId(server.id);
+ 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);
@@ -213,11 +228,20 @@ export function ServerCatalog() {
);
return;
}
- await refetch();
+
+ 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 {
- setAddingServerId(null);
+ addingServerIdsRef.current.delete(server.id);
+ setAddingServerIds(new Set(addingServerIdsRef.current));
}
},
[intl, refetch],
@@ -229,7 +253,7 @@ export function ServerCatalog() {
window.setTimeout(() => lastViewTriggerRef.current?.focus(), 0);
}, []);
- if (isLoading) {
+ if (isLoading && !data) {
return (
@@ -239,7 +263,7 @@ export function ServerCatalog() {
);
}
- if (error?.status === 404) {
+ if (error?.status === 404 && !data) {
return (
@@ -249,7 +273,7 @@ export function ServerCatalog() {
);
}
- if (error) {
+ if (error && !data) {
return (
@@ -302,7 +326,7 @@ export function ServerCatalog() {
emptyStateMessageId={emptyStateMessageId}
onView={handleView}
onAdd={(server) => void handleAdd(server)}
- addingServerId={addingServerId}
+ addingServerIds={addingServerIds}
/>