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 ? ( + <> + + + + + + + { + 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} />