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
136 changes: 136 additions & 0 deletions e2e/quick-add-server.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <label> 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();
});
});
99 changes: 94 additions & 5 deletions src/components/mcp-servers/MCPServerForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,34 @@
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";
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<string, unknown> | null = null;

vi.mock("@/hooks/useMCPServerForm", async (importOriginal) => {
const actual = (await importOriginal()) as {
useMCPServerForm: (serverId?: string) => Record<string, unknown>;
useMCPServerForm: (
serverId?: string,
initialValues?: Record<string, unknown>,
) => Record<string, unknown>;
};
return {
...actual,
useMCPServerForm: (serverId?: string) => {
useMCPServerForm: (serverId?: string, initialValues?: Record<string, unknown>) => {
if (mockHookActive) {
return {
...actual.useMCPServerForm(serverId),
...actual.useMCPServerForm(serverId, initialValues),
...mockHookReturnValue,
};
}
return actual.useMCPServerForm(serverId);
return actual.useMCPServerForm(serverId, initialValues);
},
};
});
Expand Down Expand Up @@ -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" }));
Expand Down Expand Up @@ -1217,4 +1252,58 @@ describe("MCPServerForm", () => {
});
});
});

describe("Quick Add", () => {
it("does not render the quick add trigger in edit mode", () => {
renderWithRouter(<MCPServerForm isOpen={true} onToggle={vi.fn()} serverId="edit-123" />);
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(<MCPServerForm {...defaultProps} />);

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(<MCPServerForm {...defaultProps} />);

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(<MCPServerForm isOpen={true} onToggle={onToggleSpy} />);

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");
});
});
});
53 changes: 50 additions & 3 deletions src/components/mcp-servers/MCPServerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,20 @@ 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 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;
Expand All @@ -27,6 +38,8 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
const intl = useIntl();
const { navigate } = useRouter();
const [createdGateway, setCreatedGateway] = useState<CreatedGatewayInfo | null>(null);
const [quickAddOpen, setQuickAddOpen] = useState(false);
const [prefill, setPrefill] = useState<MCPServerFormInitialValues | undefined>();
const {
fetchError,
name,
Expand Down Expand Up @@ -95,7 +108,7 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
setQueryParamName,
queryParamApiKey,
setQueryParamApiKey,
} = useMCPServerForm(serverId);
} = useMCPServerForm(serverId, prefill);

const handleRedirectUriChange = useCallback(
(uri: string) => {
Expand All @@ -109,6 +122,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<HTMLFormElement>) => {
handleSubmit(event, (response) => {
// After successful creation, show the expose components form
Expand Down Expand Up @@ -182,14 +211,25 @@ 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}
</Button>
),
},
)}
</p>

{!serverId && (
<Button
type="button"
variant="link"
onClick={() => 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" })}
</Button>
)}
</div>

{fetchError && serverId && (
Expand Down Expand Up @@ -446,6 +486,13 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
</form>
</div>
</div>

<QuickAddServerDialog
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
onSelect={handleQuickAddSelect}
onBrowseCatalog={handleBrowseCatalog}
/>
</>
);
}
Loading
Loading