From a7d411bef40a059f9ceae923c352a5dcee73ee0a Mon Sep 17 00:00:00 2001
From: Marek Dano
Date: Thu, 27 Aug 2026 15:44:53 +0100
Subject: [PATCH 1/4] feat: add Quick Add dialog to Connect MCP Server form for
open mcp servers only
Signed-off-by: Marek Dano
---
e2e/quick-add-server.spec.ts | 136 ++++++++++++++
.../mcp-servers/MCPServerForm.test.tsx | 99 ++++++++++-
src/components/mcp-servers/MCPServerForm.tsx | 53 +++++-
.../mcp-servers/QuickAddServerDialog.test.tsx | 147 +++++++++++++++
.../mcp-servers/QuickAddServerDialog.tsx | 168 ++++++++++++++++++
src/components/server-catalog/CatalogLogo.tsx | 52 ++++++
.../server-catalog/CatalogResults.tsx | 51 +-----
src/config/quickAddServers.ts | 19 ++
src/hooks/useMCPServerForm.test.ts | 46 ++++-
src/hooks/useMCPServerForm.ts | 25 ++-
src/i18n/locales/en-US/mcpServer.json | 9 +
src/i18n/locales/es-ES/mcpServer.json | 9 +
src/i18n/locales/pt-BR/mcpServer.json | 9 +
13 files changed, 764 insertions(+), 59 deletions(-)
create mode 100644 e2e/quick-add-server.spec.ts
create mode 100644 src/components/mcp-servers/QuickAddServerDialog.test.tsx
create mode 100644 src/components/mcp-servers/QuickAddServerDialog.tsx
create mode 100644 src/components/server-catalog/CatalogLogo.tsx
create mode 100644 src/config/quickAddServers.ts
diff --git a/e2e/quick-add-server.spec.ts b/e2e/quick-add-server.spec.ts
new file mode 100644
index 00000000..1571017b
--- /dev/null
+++ b/e2e/quick-add-server.spec.ts
@@ -0,0 +1,136 @@
+import { test, expect } from "./fixtures/api-mock";
+import { APP } from "./utils/paths";
+import type { CatalogServer } from "../src/generated/types";
+
+const DEEPWIKI: CatalogServer = {
+ id: "deepwiki",
+ name: "DeepWiki",
+ category: "RAG-as-a-Service",
+ url: "https://mcp.deepwiki.com/mcp",
+ auth_type: "Open",
+ provider: "Devin",
+ description: "Knowledge base with deep learning integration",
+ transport: null,
+ logo_url: "/static/catalog-icons/deepwiki.png",
+};
+
+const EXA_SEARCH: CatalogServer = {
+ id: "exa-search",
+ name: "Exa Search",
+ category: "RAG-as-a-Service",
+ url: "https://mcp.exa.ai/mcp",
+ auth_type: "Open",
+ provider: "Exa",
+ description: "AI-powered search engine for retrieving web content",
+ transport: "SSE",
+};
+
+async function mockCatalog(page: import("@playwright/test").Page, servers: CatalogServer[]) {
+ await page.route("**/v1/catalog*", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ servers,
+ total: servers.length,
+ categories: [],
+ auth_types: [],
+ providers: [],
+ }),
+ });
+ });
+}
+
+async function openQuickAddDialog(page: import("@playwright/test").Page) {
+ await page.route("**/gateways?*", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ gateways: [], nextCursor: null }),
+ });
+ });
+
+ await page.goto(APP.SERVERS);
+ await page.waitForLoadState("networkidle");
+
+ await page.getByRole("button", { name: /Connect/i }).click();
+ await page.getByRole("button", { name: /Quick add from catalog/i }).click();
+ await expect(
+ page.getByRole("dialog").getByRole("heading", { name: "Connect MCP server" }),
+ ).toBeVisible();
+}
+
+test.describe("Quick Add server dialog", () => {
+ test.beforeEach(async ({ page, apiMock }) => {
+ await apiMock.mockSession();
+ await apiMock.mockPermissions();
+
+ await page.addInitScript(() => {
+ sessionStorage.setItem("mcpgateway_token", "mock-token-12345");
+ });
+ });
+
+ test("pre-fills the connect form from a picked catalog entry and submits a new gateway", async ({
+ page,
+ }) => {
+ await mockCatalog(page, [DEEPWIKI, EXA_SEARCH]);
+ await openQuickAddDialog(page);
+
+ // Only the curated entries render, in the configured order.
+ await expect(page.getByRole("radio", { name: /DeepWiki/i })).toBeVisible();
+ await expect(page.getByRole("radio", { name: /Exa Search/i })).toBeVisible();
+
+ const continueButton = page.getByRole("button", { name: "Continue" });
+ await expect(continueButton).toBeDisabled();
+
+ // The radio input is visually hidden (sr-only); a real user clicks the visible
+ // card, which the associated forwards to the input.
+ await page.getByText("DeepWiki", { exact: true }).click();
+ await expect(page.getByRole("radio", { name: /DeepWiki/i })).toBeChecked();
+ await expect(continueButton).toBeEnabled();
+ await continueButton.click();
+
+ await expect(page.getByRole("dialog")).not.toBeVisible();
+ await expect(page.getByLabel(/Name/i)).toHaveValue("DeepWiki");
+ await expect(page.getByLabel(/URL/i)).toHaveValue("https://mcp.deepwiki.com/mcp");
+ await expect(page.getByPlaceholder(/Add an optional description/i)).toHaveValue(
+ "Knowledge base with deep learning integration",
+ );
+ await expect(page.getByRole("radio", { name: "Streamable HTTP" })).toBeChecked();
+
+ const createRequest = page.waitForRequest(
+ (request) => request.url().includes("/gateways") && request.method() === "POST",
+ );
+ await page.route(
+ (url) => url.pathname.endsWith("/gateways") || url.pathname.endsWith("/api/gateways"),
+ async (route) => {
+ if (route.request().method() !== "POST") return route.fallback();
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ id: "new-gateway-1", name: "DeepWiki" }),
+ });
+ },
+ );
+
+ await page.getByRole("button", { name: /Connect server/i }).click();
+
+ const request = await createRequest;
+ const body = request.postDataJSON() as { name?: string; url?: string; transport?: string };
+ expect(body.name).toBe("DeepWiki");
+ expect(body.url).toBe("https://mcp.deepwiki.com/mcp");
+ expect(body.transport).toBe("STREAMABLEHTTP");
+ });
+
+ test("Browse full catalog closes the connect form and navigates to the full catalog", async ({
+ page,
+ }) => {
+ await mockCatalog(page, [DEEPWIKI]);
+ await openQuickAddDialog(page);
+
+ await page.getByRole("button", { name: "server catalog" }).click();
+
+ await expect(page).toHaveURL(new RegExp(APP.SERVER_CATALOG));
+ await expect(page.getByRole("dialog")).not.toBeVisible();
+ });
+});
diff --git a/src/components/mcp-servers/MCPServerForm.test.tsx b/src/components/mcp-servers/MCPServerForm.test.tsx
index 4ed00174..7f65f85f 100644
--- a/src/components/mcp-servers/MCPServerForm.test.tsx
+++ b/src/components/mcp-servers/MCPServerForm.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll, afterEach } from "vitest";
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
@@ -7,24 +7,28 @@ import { MCPServerForm } from "./MCPServerForm";
import { RouterProvider } from "@/router";
import { I18nProvider } from "@/i18n";
import { AuthProvider } from "@/auth/AuthContext";
+import { QUICK_ADD_CATALOG_IDS } from "@/config/quickAddServers";
let mockHookActive = false;
let mockHookReturnValue: Record | null = null;
vi.mock("@/hooks/useMCPServerForm", async (importOriginal) => {
const actual = (await importOriginal()) as {
- useMCPServerForm: (serverId?: string) => Record;
+ useMCPServerForm: (
+ serverId?: string,
+ initialValues?: Record,
+ ) => Record;
};
return {
...actual,
- useMCPServerForm: (serverId?: string) => {
+ useMCPServerForm: (serverId?: string, initialValues?: Record) => {
if (mockHookActive) {
return {
- ...actual.useMCPServerForm(serverId),
+ ...actual.useMCPServerForm(serverId, initialValues),
...mockHookReturnValue,
};
}
- return actual.useMCPServerForm(serverId);
+ return actual.useMCPServerForm(serverId, initialValues);
},
};
});
@@ -65,6 +69,37 @@ const server = setupServer(
teams: [{ id: "team-personal", name: "Personal team", is_personal: true }],
});
}),
+ // Quick Add dialog's catalog fetch — one curated entry is enough to exercise selection/prefill.
+ http.get("/api/v1/catalog", () => {
+ return HttpResponse.json({
+ servers: [
+ {
+ id: QUICK_ADD_CATALOG_IDS[0],
+ name: "DeepWiki",
+ category: "RAG-as-a-Service",
+ url: "https://mcp.deepwiki.com/mcp",
+ auth_type: "Open",
+ provider: "Devin",
+ description: "Knowledge base with deep learning integration",
+ transport: null,
+ },
+ {
+ id: QUICK_ADD_CATALOG_IDS[1],
+ name: "Exa Search",
+ category: "RAG-as-a-Service",
+ url: "https://mcp.exa.ai/sse",
+ auth_type: "Open",
+ provider: "Exa",
+ description: "AI-powered search engine for retrieving web content",
+ transport: "SSE",
+ },
+ ],
+ total: 2,
+ categories: [],
+ auth_types: [],
+ providers: [],
+ });
+ }),
);
beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
@@ -1217,4 +1252,58 @@ describe("MCPServerForm", () => {
});
});
});
+
+ describe("Quick Add", () => {
+ it("does not render the quick add trigger in edit mode", () => {
+ renderWithRouter( );
+ expect(
+ screen.queryByRole("button", { name: /Quick add from catalog/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens the dialog from the trigger and pre-fills the form on selection", async () => {
+ const user = userEvent.setup();
+ renderWithRouter( );
+
+ await user.click(screen.getByRole("button", { name: /Quick add from catalog/i }));
+ const dialog = screen.getByRole("dialog");
+ expect(
+ within(dialog).getByRole("heading", { name: "Connect MCP server" }),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByRole("radio", { name: /DeepWiki/i }));
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(screen.getByLabelText(/Name/i)).toHaveValue("DeepWiki");
+ expect(screen.getByLabelText(/URL/i)).toHaveValue("https://mcp.deepwiki.com/mcp");
+ expect(screen.getByPlaceholderText(/Add an optional description/i)).toHaveValue(
+ "Knowledge base with deep learning integration",
+ );
+ expect(screen.getByRole("radio", { name: "Streamable HTTP" })).toBeChecked();
+ });
+
+ it("maps a catalog entry's declared SSE transport onto the transport radio", async () => {
+ const user = userEvent.setup();
+ renderWithRouter( );
+
+ await user.click(screen.getByRole("button", { name: /Quick add from catalog/i }));
+ await user.click(screen.getByRole("radio", { name: /Exa Search/i }));
+ await user.click(screen.getByRole("button", { name: "Continue" }));
+
+ expect(screen.getByRole("radio", { name: "SSE" })).toBeChecked();
+ });
+
+ it("navigates to the full catalog and closes the form when Browse full catalog is clicked", async () => {
+ const user = userEvent.setup();
+ const onToggleSpy = vi.fn();
+ renderWithRouter( );
+
+ await user.click(screen.getByRole("button", { name: /Quick add from catalog/i }));
+ await user.click(screen.getByRole("button", { name: "server catalog" }));
+
+ expect(onToggleSpy).toHaveBeenCalled();
+ expect(window.location.pathname).toBe("/app/server-catalog");
+ });
+ });
});
diff --git a/src/components/mcp-servers/MCPServerForm.tsx b/src/components/mcp-servers/MCPServerForm.tsx
index cb329c3c..25af4573 100644
--- a/src/components/mcp-servers/MCPServerForm.tsx
+++ b/src/components/mcp-servers/MCPServerForm.tsx
@@ -7,10 +7,21 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { MCPIcon } from "@/components/icons/MCPIcon";
import { AdvancedSettings } from "@/components/mcp-servers/AdvancedSettings";
+import { QuickAddServerDialog } from "@/components/mcp-servers/QuickAddServerDialog";
import { ExposeComponentsForm } from "@/components/gateways/ExposeComponentsForm";
import { useRouter } from "@/router";
-import { useMCPServerForm, type TransportType } from "@/hooks/useMCPServerForm";
+import {
+ useMCPServerForm,
+ type MCPServerFormInitialValues,
+ type TransportType,
+} from "@/hooks/useMCPServerForm";
import { STATUS_ICON } from "@/lib/status";
+import type { CatalogServer } from "@/generated/types";
+
+/** Catalog servers only carry SSE/STREAMABLEHTTP/WEBSOCKET/null; the form only supports the first two. */
+function mapCatalogTransport(transport: string | null | undefined): TransportType {
+ return transport === "SSE" ? "SSE" : "STREAMABLEHTTP";
+}
interface MCPServerFormProps {
isOpen: boolean;
@@ -28,6 +39,8 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
const intl = useIntl();
const { navigate } = useRouter();
const [createdGateway, setCreatedGateway] = useState(null);
+ const [quickAddOpen, setQuickAddOpen] = useState(false);
+ const [prefill, setPrefill] = useState();
const {
fetchError,
name,
@@ -96,7 +109,7 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
setQueryParamName,
queryParamApiKey,
setQueryParamApiKey,
- } = useMCPServerForm(serverId);
+ } = useMCPServerForm(serverId, prefill);
const handleRedirectUriChange = useCallback(
(uri: string) => {
@@ -110,6 +123,22 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
onToggle();
};
+ const handleQuickAddSelect = useCallback((server: CatalogServer) => {
+ setPrefill({
+ name: server.name,
+ url: server.url,
+ description: server.description,
+ transport: mapCatalogTransport(server.transport),
+ });
+ setQuickAddOpen(false);
+ }, []);
+
+ const handleBrowseCatalog = useCallback(() => {
+ setQuickAddOpen(false);
+ onToggle();
+ navigate("/app/server-catalog");
+ }, [onToggle, navigate]);
+
const onSubmit = (event: React.FormEvent) => {
handleSubmit(event, (response) => {
// After successful creation, show the expose components form
@@ -183,7 +212,7 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
onToggle();
navigate("/app/server-catalog");
}}
- className="font-medium text-cyan-700 underline decoration-cyan-300 underline-offset-4 transition hover:text-cyan-800 dark:text-cyan-400 dark:decoration-cyan-700 dark:hover:text-cyan-300"
+ className="inline h-auto p-0 font-medium text-cyan-700 decoration-cyan-300 underline-offset-4 transition hover:text-cyan-800 dark:text-cyan-400 dark:decoration-cyan-700 dark:hover:text-cyan-300"
>
{chunks}
@@ -191,6 +220,17 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
},
)}
+
+ {!serverId && (
+ setQuickAddOpen(true)}
+ className="w-fit px-0 font-medium text-cyan-700 decoration-cyan-300 underline-offset-4 transition hover:text-cyan-800 dark:text-cyan-400 dark:decoration-cyan-700 dark:hover:text-cyan-300"
+ >
+ {intl.formatMessage({ id: "mcpServer.quickAdd.trigger" })}
+
+ )}
{fetchError && serverId && (
@@ -447,6 +487,13 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
+
+
>
);
}
diff --git a/src/components/mcp-servers/QuickAddServerDialog.test.tsx b/src/components/mcp-servers/QuickAddServerDialog.test.tsx
new file mode 100644
index 00000000..f68b8b33
--- /dev/null
+++ b/src/components/mcp-servers/QuickAddServerDialog.test.tsx
@@ -0,0 +1,147 @@
+import { describe, expect, it, vi } from "vitest";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import type { CatalogListResponse, CatalogServer } from "@/generated/types";
+import { useQuery } from "@/hooks/useQuery";
+import { renderWithProviders } from "@/test/test-utils";
+import { QUICK_ADD_CATALOG_IDS } from "@/config/quickAddServers";
+import { QuickAddServerDialog } from "./QuickAddServerDialog";
+
+vi.mock("@/hooks/useQuery", () => ({
+ useQuery: vi.fn(),
+}));
+
+const mockUseQuery = vi.mocked(useQuery);
+
+function catalogServer(
+ overrides: Partial & Pick,
+): CatalogServer {
+ return {
+ name: overrides.id,
+ category: "Documentation",
+ url: `https://${overrides.id}.example/mcp`,
+ auth_type: "Open",
+ provider: overrides.id,
+ description: `${overrides.id} description`,
+ ...overrides,
+ };
+}
+
+// Only the first two curated ids, plus one non-curated id that must be filtered out.
+const catalogResponse: CatalogListResponse = {
+ servers: [
+ catalogServer({ id: QUICK_ADD_CATALOG_IDS[0] }),
+ catalogServer({ id: QUICK_ADD_CATALOG_IDS[1] }),
+ catalogServer({ id: "not-curated" }),
+ ],
+ total: 3,
+ categories: [],
+ auth_types: [],
+ providers: [],
+};
+
+function mockCatalogQuery(overrides: Partial> = {}) {
+ mockUseQuery.mockReturnValue({
+ data: catalogResponse,
+ error: null,
+ isLoading: false,
+ execute: vi.fn(),
+ refetch: vi.fn(),
+ setData: vi.fn(),
+ ...overrides,
+ } as ReturnType);
+}
+
+describe("QuickAddServerDialog", () => {
+ it("renders only the curated catalog entries", () => {
+ mockCatalogQuery();
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText(QUICK_ADD_CATALOG_IDS[0])).toBeInTheDocument();
+ expect(screen.getByText(QUICK_ADD_CATALOG_IDS[1])).toBeInTheDocument();
+ expect(screen.queryByText("not-curated")).not.toBeInTheDocument();
+ });
+
+ it("disables Continue until a card is selected, then calls onSelect with the picked server", async () => {
+ mockCatalogQuery();
+ const user = userEvent.setup();
+ const onSelect = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ const continueButton = screen.getByRole("button", { name: "Continue" });
+ expect(continueButton).toBeDisabled();
+
+ await user.click(screen.getByRole("radio", { name: new RegExp(QUICK_ADD_CATALOG_IDS[0]) }));
+ expect(continueButton).toBeEnabled();
+
+ await user.click(continueButton);
+ expect(onSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ id: QUICK_ADD_CATALOG_IDS[0] }),
+ );
+ });
+
+ it("closes without selecting when Cancel is clicked", async () => {
+ mockCatalogQuery();
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ const onSelect = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("calls onBrowseCatalog when the browse-catalog link is clicked", async () => {
+ mockCatalogQuery();
+ const user = userEvent.setup();
+ const onBrowseCatalog = vi.fn();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "server catalog" }));
+ expect(onBrowseCatalog).toHaveBeenCalled();
+ });
+
+ it("shows an error state when the catalog fails to load", () => {
+ mockCatalogQuery({ data: undefined, error: { message: "network error" } });
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("Unable to load quick add servers. Try again.")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/mcp-servers/QuickAddServerDialog.tsx b/src/components/mcp-servers/QuickAddServerDialog.tsx
new file mode 100644
index 00000000..495569a4
--- /dev/null
+++ b/src/components/mcp-servers/QuickAddServerDialog.tsx
@@ -0,0 +1,168 @@
+import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
+import { useIntl } from "react-intl";
+
+import { MCPIcon } from "@/components/icons/MCPIcon";
+import { CatalogLogo } from "@/components/server-catalog/CatalogLogo";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { InlineNotification } from "@/components/ui/inline-notification";
+import { Loading } from "@/components/ui/loading";
+import { QUICK_ADD_CATALOG_IDS } from "@/config/quickAddServers";
+import type { CatalogListResponse, CatalogServer } from "@/generated/types";
+import { useQuery } from "@/hooks/useQuery";
+
+const CATALOG_PATH = "/v1/catalog?limit=1000";
+
+interface QuickAddServerDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Called with the picked catalog entry. The caller is responsible for closing the dialog. */
+ onSelect: (server: CatalogServer) => void;
+ onBrowseCatalog: () => void;
+}
+
+export function QuickAddServerDialog({
+ open,
+ onOpenChange,
+ onSelect,
+ onBrowseCatalog,
+}: QuickAddServerDialogProps) {
+ const intl = useIntl();
+ const groupLabelId = useId();
+ const [selectedId, setSelectedId] = useState(null);
+
+ const { data, error, isLoading } = useQuery(CATALOG_PATH, {
+ enabled: open,
+ });
+
+ useEffect(() => {
+ if (!open) setSelectedId(null);
+ }, [open]);
+
+ const servers = useMemo(() => {
+ if (!data?.servers) return [];
+ const byId = new Map(data.servers.map((server) => [server.id, server]));
+ return QUICK_ADD_CATALOG_IDS.map((id) => byId.get(id)).filter(
+ (server): server is CatalogServer => Boolean(server),
+ );
+ }, [data?.servers]);
+
+ const selectedServer = servers.find((server) => server.id === selectedId) ?? null;
+
+ return (
+
+
+
+
+
+
+
+
+ {intl.formatMessage({ id: "mcpServer.quickAdd.dialogTitle" })}
+
+
+
+ {intl.formatMessage({ id: "mcpServer.quickAdd.dialogDescription" })}
+
+
+
+ {isLoading && !data && }
+
+ {error && !data && (
+
+ )}
+
+ {data && servers.length === 0 && (
+
+ {intl.formatMessage({ id: "mcpServer.quickAdd.emptyState" })}
+
+ )}
+
+ {servers.length > 0 && (
+
+
+ {intl.formatMessage({ id: "mcpServer.quickAdd.radioGroupLabel" })}
+
+ {servers.map((server) => {
+ const inputId = `quick-add-${server.id}`;
+ return (
+
+
setSelectedId(server.id)}
+ className="peer sr-only"
+ />
+
+
+
+
+ {server.name}
+
+
+
+ {server.description}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {intl.formatMessage(
+ { id: "mcpServer.quickAdd.footerText" },
+ {
+ catalog: (chunks: ReactNode) => (
+
+ {chunks}
+
+ ),
+ },
+ )}
+
+
+ onOpenChange(false)}>
+ {intl.formatMessage({ id: "mcpServer.quickAdd.cancel" })}
+
+ {
+ if (selectedServer) onSelect(selectedServer);
+ }}
+ >
+ {intl.formatMessage({ id: "mcpServer.quickAdd.continue" })}
+
+
+
+
+
+ );
+}
diff --git a/src/components/server-catalog/CatalogLogo.tsx b/src/components/server-catalog/CatalogLogo.tsx
new file mode 100644
index 00000000..8c64a0ed
--- /dev/null
+++ b/src/components/server-catalog/CatalogLogo.tsx
@@ -0,0 +1,52 @@
+import { useState } from "react";
+
+import { ServerIcon } from "@/components/servers/ServerIcon";
+import type { CatalogServer } from "@/generated/types";
+
+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
+ ? parsed.href
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+export function CatalogLogo({ server }: { server: CatalogServer }) {
+ const [failedLogoUrl, setFailedLogoUrl] = useState(null);
+ const logoUrl = getSafeExternalUrl(server.logo_url);
+
+ if (!logoUrl || failedLogoUrl === logoUrl) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
setFailedLogoUrl(logoUrl)}
+ />
+
+ );
+}
diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx
index 8b46a637..5f9b3b07 100644
--- a/src/components/server-catalog/CatalogResults.tsx
+++ b/src/components/server-catalog/CatalogResults.tsx
@@ -1,11 +1,11 @@
-import { useEffect, useId, useRef, useState } from "react";
+import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
import { EllipsisVertical, FileText, Plus } from "lucide-react";
import { useIntl } from "react-intl";
import { STATUS_ICON } from "@/lib/status";
import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder";
-import { ServerIcon } from "@/components/servers/ServerIcon";
+import { CatalogLogo } from "@/components/server-catalog/CatalogLogo";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CardTag } from "@/components/ui/card-tag";
@@ -27,53 +27,6 @@ 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
- ? parsed.href
- : null;
- } catch {
- return null;
- }
-}
-
-function CatalogLogo({ server }: { server: CatalogServer }) {
- const [failedLogoUrl, setFailedLogoUrl] = useState(null);
- const logoUrl = getSafeExternalUrl(server.logo_url);
-
- if (!logoUrl || failedLogoUrl === logoUrl) {
- return (
-
-
-
- );
- }
-
- return (
-
-
setFailedLogoUrl(logoUrl)}
- />
-
- );
-}
function CatalogCard({
server,
diff --git a/src/config/quickAddServers.ts b/src/config/quickAddServers.ts
new file mode 100644
index 00000000..71fc23ec
--- /dev/null
+++ b/src/config/quickAddServers.ts
@@ -0,0 +1,19 @@
+/**
+ * Curated shortlist of catalog server ids shown in the Quick Add dialog
+ * (issue #4681). Every id must resolve to an `auth_type: "Open"` entry in
+ * the backend's `mcp-catalog.yml`, since Quick Add submits through the
+ * standard gateway-create form and can't yet complete an OAuth setup flow
+ * (blocked on https://github.com/IBM/mcp-context-forge/issues/5967).
+ *
+ * Order here is the display order in the dialog grid.
+ */
+export const QUICK_ADD_CATALOG_IDS = [
+ "deepwiki",
+ "exa-search",
+ "ferryhopper",
+ "hugging-face",
+ "remote-mcp",
+ "aws-knowledge",
+ "context-awesome",
+ "javadocs",
+] as const;
diff --git a/src/hooks/useMCPServerForm.test.ts b/src/hooks/useMCPServerForm.test.ts
index 81c1731b..64d91be9 100644
--- a/src/hooks/useMCPServerForm.test.ts
+++ b/src/hooks/useMCPServerForm.test.ts
@@ -3,7 +3,7 @@ import { renderHook, act, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "@/test/mocks/server";
import { serversApi } from "@/api/servers";
-import { useMCPServerForm } from "./useMCPServerForm";
+import { useMCPServerForm, type MCPServerFormInitialValues } from "./useMCPServerForm";
describe("useMCPServerForm", () => {
describe("Initial State", () => {
@@ -1706,6 +1706,50 @@ describe("useMCPServerForm", () => {
expect(result.current.isValid).toBe(false);
});
});
+
+ describe("initialValues (Quick Add prefill)", () => {
+ it("seeds create-mode fields from initialValues", () => {
+ const { result } = renderHook(() =>
+ useMCPServerForm(undefined, {
+ name: "DeepWiki",
+ url: "https://mcp.deepwiki.com/mcp",
+ description: "Knowledge base with deep learning integration",
+ transport: "SSE",
+ }),
+ );
+
+ expect(result.current.name).toBe("DeepWiki");
+ expect(result.current.url).toBe("https://mcp.deepwiki.com/mcp");
+ expect(result.current.description).toBe("Knowledge base with deep learning integration");
+ expect(result.current.transport).toBe("SSE");
+ });
+
+ it("applies a later initialValues object once it arrives (Quick Add picked after the form opened)", () => {
+ const { result, rerender } = renderHook(
+ ({ initialValues }: { initialValues?: MCPServerFormInitialValues }) =>
+ useMCPServerForm(undefined, initialValues),
+ { initialProps: { initialValues: undefined as MCPServerFormInitialValues | undefined } },
+ );
+
+ expect(result.current.name).toBe("");
+
+ rerender({ initialValues: { name: "DeepWiki", url: "https://mcp.deepwiki.com/mcp" } });
+
+ expect(result.current.name).toBe("DeepWiki");
+ expect(result.current.url).toBe("https://mcp.deepwiki.com/mcp");
+ });
+
+ it("does not apply initialValues in edit mode", async () => {
+ const { result } = renderHook(() =>
+ useMCPServerForm("edit-123", { name: "Should not apply" }),
+ );
+
+ await waitFor(() => {
+ expect(result.current.name).toBe("Test Server");
+ });
+ expect(result.current.name).not.toBe("Should not apply");
+ });
+ });
});
// Restore all spies after each test in this file
diff --git a/src/hooks/useMCPServerForm.ts b/src/hooks/useMCPServerForm.ts
index a95a1538..06492b19 100644
--- a/src/hooks/useMCPServerForm.ts
+++ b/src/hooks/useMCPServerForm.ts
@@ -17,6 +17,14 @@ import {
export type TransportType = "SSE" | "STREAMABLEHTTP";
export type AuthType = "none" | "basic" | "bearer" | "custom" | "oauth" | "query";
+/** Seeds a freshly-opened create-mode form, e.g. from a picked Quick Add catalog entry. */
+export interface MCPServerFormInitialValues {
+ name?: string;
+ url?: string;
+ description?: string;
+ transport?: TransportType;
+}
+
export interface CustomHeader {
id: string;
key: string;
@@ -300,7 +308,10 @@ const initialState = {
queryParamApiKey: "", // pragma: allowlist secret
};
-export function useMCPServerForm(gatewayId?: string): UseMCPServerFormReturn {
+export function useMCPServerForm(
+ gatewayId?: string,
+ initialValues?: MCPServerFormInitialValues,
+): UseMCPServerFormReturn {
const [name, setName] = useState(initialState.name);
const [url, setUrl] = useState(initialState.url);
const [description, setDescription] = useState(initialState.description);
@@ -455,6 +466,18 @@ export function useMCPServerForm(gatewayId?: string): UseMCPServerFormReturn {
}
}, [serverData, gatewayId]);
+ // Seeds a freshly-opened create-mode form from caller-supplied defaults (e.g. a
+ // picked Quick Add catalog entry). Runs once per new initialValues reference —
+ // the caller is expected to hand in a new object only when a fresh pick is made,
+ // not on every render. Edit mode owns its own prefill via the effect above.
+ useEffect(() => {
+ if (!initialValues || gatewayId) return;
+ if (initialValues.name !== undefined) setName(initialValues.name);
+ if (initialValues.url !== undefined) setUrl(initialValues.url);
+ if (initialValues.description !== undefined) setDescription(initialValues.description);
+ if (initialValues.transport !== undefined) setTransport(initialValues.transport);
+ }, [initialValues, gatewayId]);
+
// Use useQuery for POST request to create MCP gateway
const { execute: createGateway, isLoading: isCreating } = useQuery(
"/gateways",
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index bd3f862f..e80ac5d2 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -31,6 +31,15 @@
"mcpServer.form.waitingOAuth": "Waiting for OAuth…",
"mcpServer.form.saveChanges": "Save changes",
"mcpServer.form.connectServer": "Connect server",
+ "mcpServer.quickAdd.trigger": "Quick add from catalog",
+ "mcpServer.quickAdd.dialogTitle": "Connect MCP server",
+ "mcpServer.quickAdd.dialogDescription": "Pick a commonly used MCP server to pre-fill the form below.",
+ "mcpServer.quickAdd.radioGroupLabel": "Available servers",
+ "mcpServer.quickAdd.footerText": "Explore more options in the server catalog .",
+ "mcpServer.quickAdd.cancel": "Cancel",
+ "mcpServer.quickAdd.continue": "Continue",
+ "mcpServer.quickAdd.emptyState": "No quick add servers available right now.",
+ "mcpServer.quickAdd.errorState": "Unable to load quick add servers. Try again.",
"mcpServer.table.caption": "List of MCP servers with status and actions",
"mcpServer.table.name": "Name",
"mcpServer.table.components": "Components",
diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json
index c4fc58cd..27883dfa 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -31,6 +31,15 @@
"mcpServer.form.waitingOAuth": "Esperando OAuth…",
"mcpServer.form.saveChanges": "Guardar cambios",
"mcpServer.form.connectServer": "Conectar servidor",
+ "mcpServer.quickAdd.trigger": "Agregar rápidamente desde el catálogo",
+ "mcpServer.quickAdd.dialogTitle": "Conectar servidor MCP",
+ "mcpServer.quickAdd.dialogDescription": "Elige un servidor MCP de uso frecuente para completar el formulario a continuación.",
+ "mcpServer.quickAdd.radioGroupLabel": "Servidores disponibles",
+ "mcpServer.quickAdd.footerText": "Explora más opciones en el catálogo de servidores .",
+ "mcpServer.quickAdd.cancel": "Cancelar",
+ "mcpServer.quickAdd.continue": "Continuar",
+ "mcpServer.quickAdd.emptyState": "No hay servidores de agregado rápido disponibles en este momento.",
+ "mcpServer.quickAdd.errorState": "No se pudieron cargar los servidores de agregado rápido. Vuelve a intentarlo.",
"mcpServer.table.caption": "Lista de servidores MCP con estado y acciones",
"mcpServer.table.name": "Nombre",
"mcpServer.table.components": "Componentes",
diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json
index 80c78461..70f68568 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -31,6 +31,15 @@
"mcpServer.form.waitingOAuth": "Aguardando OAuth…",
"mcpServer.form.saveChanges": "Salvar alterações",
"mcpServer.form.connectServer": "Conectar servidor",
+ "mcpServer.quickAdd.trigger": "Adicionar rapidamente do catálogo",
+ "mcpServer.quickAdd.dialogTitle": "Conectar servidor MCP",
+ "mcpServer.quickAdd.dialogDescription": "Escolha um servidor MCP de uso comum para preencher o formulário abaixo.",
+ "mcpServer.quickAdd.radioGroupLabel": "Servidores disponíveis",
+ "mcpServer.quickAdd.footerText": "Explore mais opções no catálogo de servidores .",
+ "mcpServer.quickAdd.cancel": "Cancelar",
+ "mcpServer.quickAdd.continue": "Continuar",
+ "mcpServer.quickAdd.emptyState": "Nenhum servidor de adição rápida disponível no momento.",
+ "mcpServer.quickAdd.errorState": "Não foi possível carregar os servidores de adição rápida. Tente novamente.",
"mcpServer.table.caption": "Lista de servidores MCP com status e ações",
"mcpServer.table.name": "Nome",
"mcpServer.table.components": "Componentes",
From 9e9caa9aa7fb5806c2cd7a4d8035bd16f06e1934 Mon Sep 17 00:00:00 2001
From: Marek Dano
Date: Mon, 31 Aug 2026 13:29:59 +0100
Subject: [PATCH 2/4] fix: remove link to mcp server catalog from connect mcp
server form
Signed-off-by: Marek Dano
---
.../mcp-servers/MCPServerForm.test.tsx | 24 -------------------
src/components/mcp-servers/MCPServerForm.tsx | 21 ++--------------
src/i18n/locales/en-US/mcpServer.json | 2 +-
src/i18n/locales/es-ES/mcpServer.json | 2 +-
src/i18n/locales/pt-BR/mcpServer.json | 2 +-
5 files changed, 5 insertions(+), 46 deletions(-)
diff --git a/src/components/mcp-servers/MCPServerForm.test.tsx b/src/components/mcp-servers/MCPServerForm.test.tsx
index 7f65f85f..e6eee56c 100644
--- a/src/components/mcp-servers/MCPServerForm.test.tsx
+++ b/src/components/mcp-servers/MCPServerForm.test.tsx
@@ -163,13 +163,6 @@ describe("MCPServerForm", () => {
renderWithRouter( );
expect(screen.getByRole("button", { name: /Save changes/i })).toBeInTheDocument();
});
-
- it("should render link to server catalog", () => {
- renderWithRouter( );
-
- const catalogLink = screen.getByRole("button", { name: /mcp server catalog/i });
- expect(catalogLink).toBeInTheDocument();
- });
});
describe("Error States", () => {
@@ -647,23 +640,6 @@ describe("MCPServerForm", () => {
});
});
- describe("Server Catalog Navigation", () => {
- it("should navigate to server catalog when link is clicked", async () => {
- const user = userEvent.setup();
- const onToggle = vi.fn();
- renderWithRouter( );
-
- const catalogLink = screen.getByRole("button", { name: /mcp server catalog/i });
- await user.click(catalogLink);
-
- expect(onToggle).toHaveBeenCalledTimes(1);
- // Verify navigation by checking window location
- await waitFor(() => {
- expect(window.location.pathname).toBe("/app/server-catalog");
- });
- });
- });
-
describe("Accessibility", () => {
it("should have proper ARIA labels for transport type radio group", () => {
renderWithRouter( );
diff --git a/src/components/mcp-servers/MCPServerForm.tsx b/src/components/mcp-servers/MCPServerForm.tsx
index 25af4573..3c74fcf6 100644
--- a/src/components/mcp-servers/MCPServerForm.tsx
+++ b/src/components/mcp-servers/MCPServerForm.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState, type ReactNode } from "react";
+import { useCallback, useState } from "react";
import { useIntl } from "react-intl";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -201,24 +201,7 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
- {intl.formatMessage(
- { id: "mcpServer.form.intro" },
- {
- catalog: (chunks: ReactNode) => (
- {
- onToggle();
- navigate("/app/server-catalog");
- }}
- className="inline h-auto p-0 font-medium text-cyan-700 decoration-cyan-300 underline-offset-4 transition hover:text-cyan-800 dark:text-cyan-400 dark:decoration-cyan-700 dark:hover:text-cyan-300"
- >
- {chunks}
-
- ),
- },
- )}
+ {intl.formatMessage({ id: "mcpServer.form.intro" })}
{!serverId && (
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index e80ac5d2..45970d3c 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -13,7 +13,7 @@
"mcpServer.tags.addError": "Failed to add tag. Please try again.",
"mcpServer.form.connectTitle": "Connect MCP server",
"mcpServer.form.editTitle": "Edit MCP server",
- "mcpServer.form.intro": "Context Forge will discover the server's tools, resources, and prompts. The MCP server should be running and reachable. Or, choose a server from the mcp server catalog .",
+ "mcpServer.form.intro": "Context Forge will discover the server's tools, resources, and prompts. The MCP server should be running and reachable.",
"mcpServer.form.fetchError": "Failed to load server data: {error}",
"mcpServer.form.transportLabel": "Server transport type",
"mcpServer.form.nameLabel": "Name",
diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json
index 27883dfa..818895f6 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -13,7 +13,7 @@
"mcpServer.tags.addError": "No se pudo agregar la etiqueta. Inténtalo de nuevo.",
"mcpServer.form.connectTitle": "Conectar servidor MCP",
"mcpServer.form.editTitle": "Editar servidor MCP",
- "mcpServer.form.intro": "Context Forge descubrirá las herramientas, los recursos y los prompts del servidor. El servidor MCP debe estar en ejecución y ser accesible. O elige un servidor del catálogo de servidores MCP .",
+ "mcpServer.form.intro": "Context Forge descubrirá las herramientas, los recursos y los prompts del servidor. El servidor MCP debe estar en ejecución y ser accesible.",
"mcpServer.form.fetchError": "No se pudieron cargar los datos del servidor: {error}",
"mcpServer.form.transportLabel": "Tipo de transporte del servidor",
"mcpServer.form.nameLabel": "Nombre",
diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json
index 70f68568..9356990e 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -13,7 +13,7 @@
"mcpServer.tags.addError": "Falha ao adicionar a tag. Tente novamente.",
"mcpServer.form.connectTitle": "Conectar servidor MCP",
"mcpServer.form.editTitle": "Editar servidor MCP",
- "mcpServer.form.intro": "O Context Forge vai descobrir as ferramentas, os recursos e os prompts do servidor. O servidor MCP deve estar em execução e acessível. Ou escolha um servidor no catálogo de servidores MCP .",
+ "mcpServer.form.intro": "O Context Forge vai descobrir as ferramentas, os recursos e os prompts do servidor. O servidor MCP deve estar em execução e acessível.",
"mcpServer.form.fetchError": "Falha ao carregar os dados do servidor: {error}",
"mcpServer.form.transportLabel": "Tipo de transporte do servidor",
"mcpServer.form.nameLabel": "Nome",
From 68dee0d4b4351b6da437026f6828f92cf8346d67 Mon Sep 17 00:00:00 2001
From: Marek Dano
Date: Mon, 31 Aug 2026 14:05:24 +0100
Subject: [PATCH 3/4] fix: address comments
Signed-off-by: Marek Dano
---
src/components/mcp-servers/MCPServerForm.tsx | 3 +-
.../mcp-servers/QuickAddServerDialog.test.tsx | 45 +++++++++++++++++++
.../mcp-servers/QuickAddServerDialog.tsx | 41 +++++++++--------
3 files changed, 70 insertions(+), 19 deletions(-)
diff --git a/src/components/mcp-servers/MCPServerForm.tsx b/src/components/mcp-servers/MCPServerForm.tsx
index 3c74fcf6..599cc9c5 100644
--- a/src/components/mcp-servers/MCPServerForm.tsx
+++ b/src/components/mcp-servers/MCPServerForm.tsx
@@ -18,7 +18,8 @@ import {
import { STATUS_ICON } from "@/lib/status";
import type { CatalogServer } from "@/generated/types";
-/** Catalog servers only carry SSE/STREAMABLEHTTP/WEBSOCKET/null; the form only supports the first two. */
+// QuickAddServerDialog only surfaces entries with SSE, STREAMABLEHTTP, or no
+// transport set, so anything else here defaults to STREAMABLEHTTP.
function mapCatalogTransport(transport: string | null | undefined): TransportType {
return transport === "SSE" ? "SSE" : "STREAMABLEHTTP";
}
diff --git a/src/components/mcp-servers/QuickAddServerDialog.test.tsx b/src/components/mcp-servers/QuickAddServerDialog.test.tsx
index f68b8b33..c5d51508 100644
--- a/src/components/mcp-servers/QuickAddServerDialog.test.tsx
+++ b/src/components/mcp-servers/QuickAddServerDialog.test.tsx
@@ -41,6 +41,21 @@ const catalogResponse: CatalogListResponse = {
providers: [],
};
+// A curated id whose catalog entry has since drifted off the Quick Add contract:
+// non-Open auth and/or an unsupported transport must still be excluded.
+function catalogResponseWith(overrides: Partial): CatalogListResponse {
+ return {
+ servers: [
+ catalogServer({ id: QUICK_ADD_CATALOG_IDS[0], ...overrides }),
+ catalogServer({ id: QUICK_ADD_CATALOG_IDS[1] }),
+ ],
+ total: 2,
+ categories: [],
+ auth_types: [],
+ providers: [],
+ };
+}
+
function mockCatalogQuery(overrides: Partial> = {}) {
mockUseQuery.mockReturnValue({
data: catalogResponse,
@@ -70,6 +85,36 @@ describe("QuickAddServerDialog", () => {
expect(screen.queryByText("not-curated")).not.toBeInTheDocument();
});
+ it("excludes a curated entry that is no longer Open auth", () => {
+ mockCatalogQuery({ data: catalogResponseWith({ auth_type: "oauth" }) });
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByText(QUICK_ADD_CATALOG_IDS[0])).not.toBeInTheDocument();
+ expect(screen.getByText(QUICK_ADD_CATALOG_IDS[1])).toBeInTheDocument();
+ });
+
+ it("excludes a curated entry with an unsupported transport", () => {
+ mockCatalogQuery({ data: catalogResponseWith({ transport: "WEBSOCKET" }) });
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByText(QUICK_ADD_CATALOG_IDS[0])).not.toBeInTheDocument();
+ expect(screen.getByText(QUICK_ADD_CATALOG_IDS[1])).toBeInTheDocument();
+ });
+
it("disables Continue until a card is selected, then calls onSelect with the picked server", async () => {
mockCatalogQuery();
const user = userEvent.setup();
diff --git a/src/components/mcp-servers/QuickAddServerDialog.tsx b/src/components/mcp-servers/QuickAddServerDialog.tsx
index 495569a4..801afb3a 100644
--- a/src/components/mcp-servers/QuickAddServerDialog.tsx
+++ b/src/components/mcp-servers/QuickAddServerDialog.tsx
@@ -12,12 +12,26 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { InlineNotification } from "@/components/ui/inline-notification";
+import { Label } from "@/components/ui/label";
import { Loading } from "@/components/ui/loading";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { QUICK_ADD_CATALOG_IDS } from "@/config/quickAddServers";
import type { CatalogListResponse, CatalogServer } from "@/generated/types";
import { useQuery } from "@/hooks/useQuery";
const CATALOG_PATH = "/v1/catalog?limit=1000";
+// Quick Add submits through the standard gateway-create form, which can't yet
+// complete an OAuth setup flow, and only supports these two transports.
+const OPEN_AUTH_TYPE = "Open";
+const SUPPORTED_TRANSPORTS: ReadonlySet = new Set(["SSE", "STREAMABLEHTTP"]);
+
+function isQuickAddEligible(server: CatalogServer | undefined): server is CatalogServer {
+ if (!server) return false;
+ return (
+ server.auth_type === OPEN_AUTH_TYPE &&
+ (server.transport == null || SUPPORTED_TRANSPORTS.has(server.transport))
+ );
+}
interface QuickAddServerDialogProps {
open: boolean;
@@ -48,9 +62,7 @@ export function QuickAddServerDialog({
const servers = useMemo(() => {
if (!data?.servers) return [];
const byId = new Map(data.servers.map((server) => [server.id, server]));
- return QUICK_ADD_CATALOG_IDS.map((id) => byId.get(id)).filter(
- (server): server is CatalogServer => Boolean(server),
- );
+ return QUICK_ADD_CATALOG_IDS.map((id) => byId.get(id)).filter(isQuickAddEligible);
}, [data?.servers]);
const selectedServer = servers.find((server) => server.id === selectedId) ?? null;
@@ -88,8 +100,9 @@ export function QuickAddServerDialog({
)}
{servers.length > 0 && (
-
@@ -100,18 +113,10 @@ export function QuickAddServerDialog({
const inputId = `quick-add-${server.id}`;
return (
-
setSelectedId(server.id)}
- className="peer sr-only"
- />
-
+
@@ -122,11 +127,11 @@ export function QuickAddServerDialog({
{server.description}
-
+
);
})}
-
+
)}
From 572426f69edcd5f3b08afeb26ca67febc562d6d8 Mon Sep 17 00:00:00 2001
From: Marek Dano
Date: Tue, 1 Sep 2026 10:16:09 +0100
Subject: [PATCH 4/4] fix: restore inline catalog link per design feedback
Signed-off-by: Marek Dano
---
e2e/quick-add-server.spec.ts | 2 +-
.../mcp-servers/MCPServerForm.test.tsx | 23 +++++++-----
src/components/mcp-servers/MCPServerForm.tsx | 36 ++++++++++++-------
src/i18n/locales/en-US/mcpServer.json | 3 +-
src/i18n/locales/es-ES/mcpServer.json | 3 +-
src/i18n/locales/pt-BR/mcpServer.json | 3 +-
6 files changed, 41 insertions(+), 29 deletions(-)
diff --git a/e2e/quick-add-server.spec.ts b/e2e/quick-add-server.spec.ts
index 1571017b..aa4d5b65 100644
--- a/e2e/quick-add-server.spec.ts
+++ b/e2e/quick-add-server.spec.ts
@@ -54,7 +54,7 @@ async function openQuickAddDialog(page: import("@playwright/test").Page) {
await page.waitForLoadState("networkidle");
await page.getByRole("button", { name: /Connect/i }).click();
- await page.getByRole("button", { name: /Quick add from catalog/i }).click();
+ await page.getByRole("button", { name: /mcp server catalog/i }).click();
await expect(
page.getByRole("dialog").getByRole("heading", { name: "Connect MCP server" }),
).toBeVisible();
diff --git a/src/components/mcp-servers/MCPServerForm.test.tsx b/src/components/mcp-servers/MCPServerForm.test.tsx
index e6eee56c..c9cd57ff 100644
--- a/src/components/mcp-servers/MCPServerForm.test.tsx
+++ b/src/components/mcp-servers/MCPServerForm.test.tsx
@@ -1230,18 +1230,23 @@ describe("MCPServerForm", () => {
});
describe("Quick Add", () => {
- it("does not render the quick add trigger in edit mode", () => {
- renderWithRouter( );
- expect(
- screen.queryByRole("button", { name: /Quick add from catalog/i }),
- ).not.toBeInTheDocument();
+ it("navigates directly to the full catalog in edit mode instead of opening quick add", async () => {
+ const user = userEvent.setup();
+ const onToggleSpy = vi.fn();
+ renderWithRouter( );
+
+ await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(onToggleSpy).toHaveBeenCalled();
+ expect(window.location.pathname).toBe("/app/server-catalog");
});
- it("opens the dialog from the trigger and pre-fills the form on selection", async () => {
+ it("opens the dialog from the catalog link and pre-fills the form on selection", async () => {
const user = userEvent.setup();
renderWithRouter( );
- await user.click(screen.getByRole("button", { name: /Quick add from catalog/i }));
+ await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
const dialog = screen.getByRole("dialog");
expect(
within(dialog).getByRole("heading", { name: "Connect MCP server" }),
@@ -1263,7 +1268,7 @@ describe("MCPServerForm", () => {
const user = userEvent.setup();
renderWithRouter( );
- await user.click(screen.getByRole("button", { name: /Quick add from catalog/i }));
+ await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
await user.click(screen.getByRole("radio", { name: /Exa Search/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -1275,7 +1280,7 @@ describe("MCPServerForm", () => {
const onToggleSpy = vi.fn();
renderWithRouter( );
- await user.click(screen.getByRole("button", { name: /Quick add from catalog/i }));
+ await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
await user.click(screen.getByRole("button", { name: "server catalog" }));
expect(onToggleSpy).toHaveBeenCalled();
diff --git a/src/components/mcp-servers/MCPServerForm.tsx b/src/components/mcp-servers/MCPServerForm.tsx
index 599cc9c5..501002c2 100644
--- a/src/components/mcp-servers/MCPServerForm.tsx
+++ b/src/components/mcp-servers/MCPServerForm.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState } from "react";
+import { useCallback, useState, type ReactNode } from "react";
import { useIntl } from "react-intl";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -202,19 +202,29 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
- {intl.formatMessage({ id: "mcpServer.form.intro" })}
+ {intl.formatMessage(
+ { id: "mcpServer.form.intro" },
+ {
+ catalog: (chunks: ReactNode) => (
+ {
+ if (serverId) {
+ onToggle();
+ navigate("/app/server-catalog");
+ } else {
+ setQuickAddOpen(true);
+ }
+ }}
+ className="inline h-auto p-0 font-medium text-cyan-700 decoration-cyan-300 underline-offset-4 transition hover:text-cyan-800 hover:no-underline dark:text-cyan-400 dark:decoration-cyan-700 dark:hover:text-cyan-300"
+ >
+ {chunks}
+
+ ),
+ },
+ )}
-
- {!serverId && (
-
setQuickAddOpen(true)}
- className="w-fit px-0 font-medium text-cyan-700 decoration-cyan-300 underline-offset-4 transition hover:text-cyan-800 dark:text-cyan-400 dark:decoration-cyan-700 dark:hover:text-cyan-300"
- >
- {intl.formatMessage({ id: "mcpServer.quickAdd.trigger" })}
-
- )}
{fetchError && serverId && (
diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json
index 45970d3c..8b568815 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -13,7 +13,7 @@
"mcpServer.tags.addError": "Failed to add tag. Please try again.",
"mcpServer.form.connectTitle": "Connect MCP server",
"mcpServer.form.editTitle": "Edit MCP server",
- "mcpServer.form.intro": "Context Forge will discover the server's tools, resources, and prompts. The MCP server should be running and reachable.",
+ "mcpServer.form.intro": "Context Forge will discover the server's tools, resources, and prompts. The MCP server should be running and reachable. Or, choose a server from the mcp server catalog .",
"mcpServer.form.fetchError": "Failed to load server data: {error}",
"mcpServer.form.transportLabel": "Server transport type",
"mcpServer.form.nameLabel": "Name",
@@ -31,7 +31,6 @@
"mcpServer.form.waitingOAuth": "Waiting for OAuth…",
"mcpServer.form.saveChanges": "Save changes",
"mcpServer.form.connectServer": "Connect server",
- "mcpServer.quickAdd.trigger": "Quick add from catalog",
"mcpServer.quickAdd.dialogTitle": "Connect MCP server",
"mcpServer.quickAdd.dialogDescription": "Pick a commonly used MCP server to pre-fill the form below.",
"mcpServer.quickAdd.radioGroupLabel": "Available servers",
diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json
index 818895f6..88515d27 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -13,7 +13,7 @@
"mcpServer.tags.addError": "No se pudo agregar la etiqueta. Inténtalo de nuevo.",
"mcpServer.form.connectTitle": "Conectar servidor MCP",
"mcpServer.form.editTitle": "Editar servidor MCP",
- "mcpServer.form.intro": "Context Forge descubrirá las herramientas, los recursos y los prompts del servidor. El servidor MCP debe estar en ejecución y ser accesible.",
+ "mcpServer.form.intro": "Context Forge descubrirá las herramientas, los recursos y los prompts del servidor. El servidor MCP debe estar en ejecución y ser accesible. O elige un servidor del catálogo de servidores MCP .",
"mcpServer.form.fetchError": "No se pudieron cargar los datos del servidor: {error}",
"mcpServer.form.transportLabel": "Tipo de transporte del servidor",
"mcpServer.form.nameLabel": "Nombre",
@@ -31,7 +31,6 @@
"mcpServer.form.waitingOAuth": "Esperando OAuth…",
"mcpServer.form.saveChanges": "Guardar cambios",
"mcpServer.form.connectServer": "Conectar servidor",
- "mcpServer.quickAdd.trigger": "Agregar rápidamente desde el catálogo",
"mcpServer.quickAdd.dialogTitle": "Conectar servidor MCP",
"mcpServer.quickAdd.dialogDescription": "Elige un servidor MCP de uso frecuente para completar el formulario a continuación.",
"mcpServer.quickAdd.radioGroupLabel": "Servidores disponibles",
diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json
index 9356990e..cb1728c7 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -13,7 +13,7 @@
"mcpServer.tags.addError": "Falha ao adicionar a tag. Tente novamente.",
"mcpServer.form.connectTitle": "Conectar servidor MCP",
"mcpServer.form.editTitle": "Editar servidor MCP",
- "mcpServer.form.intro": "O Context Forge vai descobrir as ferramentas, os recursos e os prompts do servidor. O servidor MCP deve estar em execução e acessível.",
+ "mcpServer.form.intro": "O Context Forge vai descobrir as ferramentas, os recursos e os prompts do servidor. O servidor MCP deve estar em execução e acessível. Ou escolha um servidor no catálogo de servidores MCP .",
"mcpServer.form.fetchError": "Falha ao carregar os dados do servidor: {error}",
"mcpServer.form.transportLabel": "Tipo de transporte do servidor",
"mcpServer.form.nameLabel": "Nome",
@@ -31,7 +31,6 @@
"mcpServer.form.waitingOAuth": "Aguardando OAuth…",
"mcpServer.form.saveChanges": "Salvar alterações",
"mcpServer.form.connectServer": "Conectar servidor",
- "mcpServer.quickAdd.trigger": "Adicionar rapidamente do catálogo",
"mcpServer.quickAdd.dialogTitle": "Conectar servidor MCP",
"mcpServer.quickAdd.dialogDescription": "Escolha um servidor MCP de uso comum para preencher o formulário abaixo.",
"mcpServer.quickAdd.radioGroupLabel": "Servidores disponíveis",