Skip to content
Open
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
74 changes: 74 additions & 0 deletions e2e/server-catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
30 changes: 30 additions & 0 deletions src/api/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
11 changes: 11 additions & 0 deletions src/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -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<CatalogServerRegisterResponse> {
return api.post<CatalogServerRegisterResponse>(
`/v1/catalog/${encodeURIComponent(catalogId)}/register`,
);
}
123 changes: 97 additions & 26 deletions src/components/server-catalog/CatalogResults.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -33,7 +39,7 @@ function getSafeCatalogLogoUrl(logoUrl: string | null | undefined): string | nul

function CatalogLogo({ server }: { server: CatalogServer }) {
const [failedLogoUrl, setFailedLogoUrl] = useState<string | null>(null);
const logoUrl = getSafeCatalogLogoUrl(server.logo_url);
const logoUrl = getSafeExternalUrl(server.logo_url);

if (!logoUrl || failedLogoUrl === logoUrl) {
return (
Expand Down Expand Up @@ -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<HTMLButtonElement | null>(null);
const isOpeningDetailsRef = useRef(false);

return (
<li className="min-w-0">
Expand All @@ -78,11 +90,6 @@ function CatalogCard({
<CardContent className="flex flex-1 flex-col px-5 py-5">
<div className="flex items-start justify-between gap-3">
<CatalogLogo server={server} />
{server.is_registered && (
<StatusDot tone="success" className="text-sm text-muted-foreground">
{intl.formatMessage({ id: "mcpServer.catalog.connected" })}
</StatusDot>
)}
</div>

<h2 id={headingId} className="mt-4 truncate text-sm font-medium text-foreground">
Expand All @@ -92,19 +99,77 @@ function CatalogCard({
{server.description}
</p>

<div className="mt-auto pt-4">
<Button
type="button"
variant="outline"
size="xs"
aria-label={intl.formatMessage(
{ id: "mcpServer.catalog.viewServer" },
{ name: server.name },
)}
onClick={(event) => onView(event.currentTarget)}
>
{intl.formatMessage({ id: "mcpServer.catalog.view" })}
</Button>
<div className="mt-auto flex min-h-6 items-center gap-3 pt-4">
{server.is_registered ? (
<>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
<CircleCheck className="size-4 text-green-500" aria-hidden="true" />
{intl.formatMessage({ id: "mcpServer.catalog.connected" })}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
ref={actionsTriggerRef}
type="button"
variant="ghost"
size="icon-xs"
aria-label={intl.formatMessage(
{ id: "mcpServer.catalog.actionsFor" },
{ name: server.name },
)}
>
<EllipsisVertical className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
onCloseAutoFocus={(event) => {
if (!isOpeningDetailsRef.current) return;
event.preventDefault();
isOpeningDetailsRef.current = false;
}}
>
<DropdownMenuItem
onSelect={() => {
if (!actionsTriggerRef.current) return;
isOpeningDetailsRef.current = true;
onView(actionsTriggerRef.current);
}}
>
{intl.formatMessage({ id: "mcpServer.catalog.viewDetails" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<Button
type="button"
variant="outline"
size="xs"
disabled={isAdding}
onClick={onAdd}
>
<Plus className="size-3.5" aria-hidden="true" />
{isAdding
? intl.formatMessage({ id: "mcpServer.catalog.adding" })
: intl.formatMessage({ id: "mcpServer.catalog.add" })}
</Button>
)}

{!server.is_registered && (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={intl.formatMessage(
{ id: "mcpServer.catalog.viewServer" },
{ name: server.name },
)}
onClick={(event) => onView(event.currentTarget)}
>
<FileText className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
)}
</div>
</CardContent>
</article>
Expand Down Expand Up @@ -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<string>;
}) {
const intl = useIntl();
const announcedCount = useDebouncedValue(servers.length, 300);
Expand All @@ -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)}
/>
))}
</ul>
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/en-US/mcpServer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/es-ES/mcpServer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/i18n/locales/pt-BR/mcpServer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading