diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts index 27ece6bc..e654804e 100644 --- a/e2e/server-catalog.spec.ts +++ b/e2e/server-catalog.spec.ts @@ -70,6 +70,7 @@ async function mockCatalog(page: import("@playwright/test").Page, servers: Catal test.describe("Server catalog page", () => { test.beforeEach(async ({ page, apiMock }) => { await apiMock.mockSession(); + await apiMock.mockPermissions(); await page.addInitScript(() => { sessionStorage.setItem("mcpgateway_token", "mock-token-12345"); @@ -224,7 +225,7 @@ test.describe("Server catalog page", () => { await expect(page.getByRole("heading", { name: "Globalping" })).toBeVisible(); }); - test("adds an open server without refetching its card", async ({ page, apiMock }) => { + test("adds an open server then refreshes its gateway id", async ({ page, apiMock }) => { let registered = false; let catalogCalls = 0; let registerCalls = 0; @@ -235,7 +236,13 @@ test.describe("Server catalog page", () => { status: 200, contentType: "application/json", body: JSON.stringify({ - servers: [{ ...OPEN_SERVER, is_registered: registered }], + servers: [ + { + ...OPEN_SERVER, + is_registered: registered, + gateway_id: registered ? "gateway-public-notes" : null, + }, + ], total: 1, categories: ["Productivity"], auth_types: ["Open"], @@ -276,7 +283,9 @@ test.describe("Server catalog page", () => { await expect(page.getByRole("button", { name: "Add Public Notes" })).toHaveCount(0); await expect(page.getByRole("button", { name: "View Public Notes" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Actions for Public Notes" })).toBeVisible(); - expect(catalogCalls).toBe(catalogCallsBeforeAdd); + await expect.poll(() => catalogCalls).toBe(catalogCallsBeforeAdd + 1); + await page.getByRole("button", { name: "Actions for Public Notes" }).click(); + await expect(page.getByRole("menuitem", { name: "Disconnect" })).toBeVisible(); }); test("removes a stale server and moves focus to its 404 notification", async ({ page }) => { diff --git a/openapi.json b/openapi.json index 30026ea8..7fb971f6 100644 --- a/openapi.json +++ b/openapi.json @@ -39786,6 +39786,18 @@ "description": "Whether server is already registered", "default": false }, + "gateway_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Gateway Id", + "description": "ID of the caller-visible gateway matched to this catalog server" + }, "is_available": { "type": "boolean", "title": "Is Available", @@ -53129,4 +53141,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/api/catalog.test.ts b/src/api/catalog.test.ts index 51c3d7b5..e2ee7478 100644 --- a/src/api/catalog.test.ts +++ b/src/api/catalog.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import { http, HttpResponse } from "msw"; import { server } from "@/test/mocks/server"; -import { registerCatalogServer } from "./catalog"; +import { + disconnectCatalogGateway, + getGatewayImpactPreview, + registerCatalogServer, + testCatalogServer, +} from "./catalog"; describe("registerCatalogServer", () => { it("POSTs the URL-encoded catalog id through the API proxy", async () => { @@ -27,4 +32,60 @@ describe("registerCatalogServer", () => { message: "Registered", }); }); + + it("DELETEs an encoded gateway ID and preserves async lifecycle metadata", async () => { + let requestPath = ""; + server.use( + http.delete("*/api/v1/gateways/:gatewayId", ({ request }) => { + requestPath = new URL(request.url).pathname; + return HttpResponse.json( + { status: "deleting" }, + { status: 202, headers: { "Retry-After": "2" } }, + ); + }), + ); + + const result = await disconnectCatalogGateway("gateway/id"); + + expect(requestPath).toBe("/api/v1/gateways/gateway%2Fid"); + expect(result.status).toBe(202); + expect(result.headers.get("Retry-After")).toBe("2"); + }); + + it("tests catalog URL with a safe fixed GET request", async () => { + let body: unknown; + server.use( + http.post("*/api/v1/mcp-servers/test", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ statusCode: 204, latencyMs: 21 }); + }), + ); + + await expect(testCatalogServer("https://catalog.example/mcp")).resolves.toEqual({ + statusCode: 204, + latencyMs: 21, + }); + expect(body).toEqual({ + method: "GET", + baseUrl: "https://catalog.example/mcp", + path: "", + headers: { Accept: "text/event-stream" }, + }); + }); + + it("gets only the backend-provided disconnect impact preview", async () => { + server.use( + http.get("*/api/v1/gateways/:gatewayId/impact-preview", () => + HttpResponse.json({ + gatewayId: "gateway-1", + servers: [{ id: "server-1", name: "Visible server" }], + }), + ), + ); + + await expect(getGatewayImpactPreview("gateway-1")).resolves.toEqual({ + gatewayId: "gateway-1", + servers: [{ id: "server-1", name: "Visible server" }], + }); + }); }); diff --git a/src/api/catalog.ts b/src/api/catalog.ts index cdf08a0d..d946f2cd 100644 --- a/src/api/catalog.ts +++ b/src/api/catalog.ts @@ -1,5 +1,17 @@ import { api } from "./client"; -import type { CatalogServerRegisterResponse } from "@/generated/types"; +import type { + CatalogServerRegisterResponse, + GatewayRead, + GatewayTestRequest, + GatewayTestResponse, +} from "@/generated/types"; + +export interface GatewayImpactPreview { + gatewayId: string; + servers: Array<{ id: string; name: string }>; +} + +export type CatalogGatewayDeleteResponse = GatewayRead | { status?: string; message?: string }; /** Register an open catalog entry through the authenticated BFF proxy. */ export async function registerCatalogServer( @@ -9,3 +21,29 @@ export async function registerCatalogServer( `/v1/catalog/${encodeURIComponent(catalogId)}/register`, ); } + +/** Delete gateway selected by caller-visible catalog registration state. */ +export function disconnectCatalogGateway(gatewayId: string) { + return api.deleteWithMeta( + `/v1/gateways/${encodeURIComponent(gatewayId)}`, + ); +} + +/** Test the catalog server URL using stored credentials when backend has them. */ +export function testCatalogServer(url: string): Promise { + const request: GatewayTestRequest = { + method: "GET", + baseUrl: url, + path: "", + // Streamable HTTP MCP servers require this for a GET connection check. + headers: { Accept: "text/event-stream" }, + }; + return api.post("/v1/mcp-servers/test", request); +} + +/** Preview caller-visible virtual servers affected by disconnecting a gateway. */ +export function getGatewayImpactPreview(gatewayId: string): Promise { + return api.get( + `/v1/gateways/${encodeURIComponent(gatewayId)}/impact-preview`, + ); +} diff --git a/src/api/client.ts b/src/api/client.ts index d326a28b..6e0de44e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -123,6 +123,7 @@ interface RequestOptions { export interface ResponseWithMeta { data: T; status: number; + headers: Headers; } async function requestWithMeta( @@ -192,11 +193,11 @@ async function requestWithMeta( // 204 No Content if (response.status === 204) { - return { data: undefined as T, status: response.status }; + return { data: undefined as T, status: response.status, headers: response.headers }; } const data = (await response.json()) as T; - return { data, status: response.status }; + return { data, status: response.status, headers: response.headers }; } async function request(path: string, options: RequestOptions = {}): Promise { @@ -241,6 +242,13 @@ export const api = { return requestWithMeta(path, { method: "POST", body, ...opts }); }, + deleteWithMeta( + path: string, + opts?: Omit, + ): Promise> { + return requestWithMeta(path, { method: "DELETE", ...opts }); + }, + put(path: string, body?: unknown, opts?: Omit): Promise { return request(path, { method: "PUT", body, ...opts }); }, diff --git a/src/components/server-catalog/CatalogResults.test.tsx b/src/components/server-catalog/CatalogResults.test.tsx index 7a7c5a9f..5adeb0ca 100644 --- a/src/components/server-catalog/CatalogResults.test.tsx +++ b/src/components/server-catalog/CatalogResults.test.tsx @@ -18,7 +18,12 @@ const availableServer: CatalogServer = { is_registered: false, }; -function catalogResults(server: CatalogServer, addingServerIds: ReadonlySet = new Set()) { +function catalogResults( + server: CatalogServer, + addingServerIds: ReadonlySet = new Set(), + testingServerIds: ReadonlySet = new Set(), + disconnectingServerIds: ReadonlySet = new Set(), +) { return ( ); } @@ -64,4 +75,38 @@ describe("CatalogResults", () => { expect(await screen.findByRole("menu")).toHaveAttribute("data-align", "end"); }); + + it("shows testing and disconnecting status on the affected card", () => { + const connectedServer = { ...availableServer, is_registered: true, gateway_id: "gateway-1" }; + const { rerender } = renderWithProviders( + catalogResults(connectedServer, new Set(), new Set([connectedServer.id])), + ); + + expect(screen.getByText("Testing connection…")).toHaveAttribute("role", "status"); + + rerender(catalogResults(connectedServer, new Set(), new Set(), new Set([connectedServer.id]))); + + expect(screen.getByText("Disconnecting…")).toHaveAttribute("role", "status"); + expect(screen.queryByText("Connected")).not.toBeInTheDocument(); + }); + + it("routes bundled catalog logos through the BFF", () => { + const { container } = renderWithProviders( + catalogResults({ ...availableServer, logo_url: "/static/catalog-icons/asana.png" }), + ); + + const logo = container.querySelector("img"); + + expect(logo).toHaveAttribute("src", "/api/static/catalog-icons/asana.png"); + expect(logo).toHaveClass("size-full", "object-contain"); + expect(logo?.parentElement).not.toHaveClass("bg-muted"); + }); + + it("rejects local logo paths outside the catalog icon directory", () => { + const { container } = renderWithProviders( + catalogResults({ ...availableServer, logo_url: "/static/admin.png" }), + ); + + expect(container.querySelector("img")).not.toBeInTheDocument(); + }); }); diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx index 1dbe6956..1178c74d 100644 --- a/src/components/server-catalog/CatalogResults.tsx +++ b/src/components/server-catalog/CatalogResults.tsx @@ -25,9 +25,18 @@ import type { CatalogServer } from "@/generated/types"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { getTagLabels } from "@/utils/tags"; +const EMPTY_PENDING_IDS: ReadonlySet = new Set(); +const CATALOG_ICON_PATH = /^\/static\/catalog-icons\/[A-Za-z0-9][A-Za-z0-9._-]*\.png$/; + function getSafeExternalUrl(value: string | null | undefined): string | null { if (!value) return null; + // Catalog icons are packaged by the API under this fixed path. Route them + // through the authenticated BFF so the browser never needs an API origin. + if (CATALOG_ICON_PATH.test(value)) { + return `/api${value}`; + } + try { const parsed = new URL(value); return parsed.protocol === "https:" && !parsed.username && !parsed.password @@ -51,14 +60,11 @@ function CatalogLogo({ server }: { server: CatalogServer }) { } return ( -