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 ? ( + )}
    @@ -92,18 +109,65 @@ function CatalogCard({ {server.description}

    -
    +
    + {server.is_registered ? ( + <> + + + + + + + + { + 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 ? ( - - )}

    @@ -131,10 +121,19 @@ function CatalogCard({