Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions e2e/server-catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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"],
Expand Down Expand Up @@ -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 }) => {
Expand Down
14 changes: 13 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -53129,4 +53141,4 @@
}
}
}
}
}
63 changes: 62 additions & 1 deletion src/api/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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" }],
});
});
});
40 changes: 39 additions & 1 deletion src/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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<CatalogGatewayDeleteResponse>(
`/v1/gateways/${encodeURIComponent(gatewayId)}`,
);
}

/** Test the catalog server URL using stored credentials when backend has them. */
export function testCatalogServer(url: string): Promise<GatewayTestResponse> {
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<GatewayTestResponse>("/v1/mcp-servers/test", request);
}

/** Preview caller-visible virtual servers affected by disconnecting a gateway. */
export function getGatewayImpactPreview(gatewayId: string): Promise<GatewayImpactPreview> {
return api.get<GatewayImpactPreview>(
`/v1/gateways/${encodeURIComponent(gatewayId)}/impact-preview`,
);
}
12 changes: 10 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ interface RequestOptions {
export interface ResponseWithMeta<T> {
data: T;
status: number;
headers: Headers;
}

async function requestWithMeta<T>(
Expand Down Expand Up @@ -192,11 +193,11 @@ async function requestWithMeta<T>(

// 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<T>(path: string, options: RequestOptions = {}): Promise<T> {
Expand Down Expand Up @@ -241,6 +242,13 @@ export const api = {
return requestWithMeta<T>(path, { method: "POST", body, ...opts });
},

deleteWithMeta<T>(
path: string,
opts?: Omit<RequestOptions, "method" | "body">,
): Promise<ResponseWithMeta<T>> {
return requestWithMeta<T>(path, { method: "DELETE", ...opts });
},

put<T>(path: string, body?: unknown, opts?: Omit<RequestOptions, "method" | "body">): Promise<T> {
return request<T>(path, { method: "PUT", body, ...opts });
},
Expand Down
47 changes: 46 additions & 1 deletion src/components/server-catalog/CatalogResults.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,25 @@ const availableServer: CatalogServer = {
is_registered: false,
};

function catalogResults(server: CatalogServer, addingServerIds: ReadonlySet<string> = new Set()) {
function catalogResults(
server: CatalogServer,
addingServerIds: ReadonlySet<string> = new Set(),
testingServerIds: ReadonlySet<string> = new Set(),
disconnectingServerIds: ReadonlySet<string> = new Set(),
) {
return (
<CatalogResults
servers={[server]}
emptyStateMessageId="mcpServer.catalog.empty"
onView={vi.fn()}
onAdd={vi.fn()}
addingServerIds={addingServerIds}
onTest={vi.fn()}
onDisconnect={vi.fn()}
testingServerIds={testingServerIds}
disconnectingServerIds={disconnectingServerIds}
canTest={false}
canDisconnect={false}
/>
);
}
Expand Down Expand Up @@ -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();
});
});
Loading
Loading