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
89 changes: 62 additions & 27 deletions e2e/quick-add-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,36 @@ async function mockCatalog(page: import("@playwright/test").Page, servers: Catal
});
}

async function mockRegister(
page: import("@playwright/test").Page,
{ status = 200 }: { status?: number } = {},
) {
await page.route("**/v1/catalog/*/register", async (route) => {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(
status === 200
? { success: true, server_id: "new-gateway-1", message: "registered" }
: { detail: "boom" },
),
});
});
}

// The detected-components step lists the gateway's tools, resources, and prompts.
async function mockComponentLists(page: import("@playwright/test").Page) {
for (const resource of ["tools", "resources", "prompts"]) {
await page.route(`**/${resource}?*`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
}
}

async function openQuickAddDialog(page: import("@playwright/test").Page) {
await page.route("**/gateways?*", async (route) => {
await route.fulfill({
Expand Down Expand Up @@ -70,10 +100,12 @@ test.describe("Quick Add server dialog", () => {
});
});

test("pre-fills the connect form from a picked catalog entry and submits a new gateway", async ({
test("registers a picked catalog entry and lands on the detected components step", async ({
page,
}) => {
await mockCatalog(page, [DEEPWIKI, EXA_SEARCH]);
await mockComponentLists(page);
await mockRegister(page);
await openQuickAddDialog(page);

// Only the curated entries render, in the configured order.
Expand All @@ -88,38 +120,41 @@ test.describe("Quick Add server dialog", () => {
await page.getByText("DeepWiki", { exact: true }).click();
await expect(page.getByRole("radio", { name: /DeepWiki/i })).toBeChecked();
await expect(continueButton).toBeEnabled();

const registerRequest = page.waitForRequest(
(request) =>
request.url().includes("/v1/catalog/deepwiki/register") && request.method() === "POST",
);
await continueButton.click();
// The dialog owns the scope now, so the register body has to carry it.
expect((await registerRequest).postDataJSON()).toMatchObject({
visibility: "private",
team_id: null,
});

// The connect form is skipped: Quick Add registers through the catalog endpoint.
await expect(
page.getByRole("heading", { name: "Expose MCP tools, resources, and prompts" }),
).toBeVisible();
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();
await expect(page.getByLabel(/URL/i)).not.toBeVisible();
});

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" }),
});
},
);
test("keeps the dialog open and reports the failure when registration fails", async ({
page,
}) => {
await mockCatalog(page, [DEEPWIKI, EXA_SEARCH]);
await mockRegister(page, { status: 500 });
await openQuickAddDialog(page);

await page.getByRole("button", { name: /Connect server/i }).click();
await page.getByText("DeepWiki", { exact: true }).click();
await page.getByRole("button", { name: "Continue" }).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");
await expect(page.getByText("Unable to connect this server. Try again.")).toBeVisible();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Expose MCP tools, resources, and prompts" }),
).not.toBeVisible();
});

test("Browse full catalog closes the connect form and navigates to the full catalog", async ({
Expand Down
72 changes: 56 additions & 16 deletions src/components/mcp-servers/MCPServerForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,18 @@ let mockHookReturnValue: Record<string, unknown> | null = null;

vi.mock("@/hooks/useMCPServerForm", async (importOriginal) => {
const actual = (await importOriginal()) as {
useMCPServerForm: (
serverId?: string,
initialValues?: Record<string, unknown>,
) => Record<string, unknown>;
useMCPServerForm: (serverId?: string) => Record<string, unknown>;
};
return {
...actual,
useMCPServerForm: (serverId?: string, initialValues?: Record<string, unknown>) => {
useMCPServerForm: (serverId?: string) => {
if (mockHookActive) {
return {
...actual.useMCPServerForm(serverId, initialValues),
...actual.useMCPServerForm(serverId),
...mockHookReturnValue,
};
}
return actual.useMCPServerForm(serverId, initialValues);
return actual.useMCPServerForm(serverId);
},
};
});
Expand Down Expand Up @@ -69,7 +66,15 @@ 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.
// Quick Add registers the picked entry through the catalog endpoint rather than the form.
http.post("/api/v1/catalog/:catalogId/register", () => {
return HttpResponse.json({
success: true,
server_id: "quick-add-gateway-1",
message: "registered",
});
}),
// Quick Add dialog's catalog fetch.
http.get("/api/v1/catalog", () => {
return HttpResponse.json({
servers: [
Expand Down Expand Up @@ -1242,7 +1247,7 @@ describe("MCPServerForm", () => {
expect(window.location.pathname).toBe("/app/server-catalog");
});

it("opens the dialog from the catalog link and pre-fills the form on selection", async () => {
it("opens the dialog from the catalog link and advances to the detected components step on selection", async () => {
const user = userEvent.setup();
renderWithRouter(<MCPServerForm {...defaultProps} />);

Expand All @@ -1255,24 +1260,59 @@ describe("MCPServerForm", () => {
await user.click(screen.getByRole("radio", { name: /DeepWiki/i }));
await user.click(screen.getByRole("button", { name: "Continue" }));

// The connect form is skipped entirely: the gateway already exists by this point.
expect(
await screen.findByRole("heading", { name: "Expose MCP tools, resources, and prompts" }),
).toBeInTheDocument();
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.queryByLabelText(/URL/i)).not.toBeInTheDocument();
});

// The user never passes through the connect form, so the scope the components step builds
// the virtual server with has to come from the dialog rather than the form's defaults.
it("carries the visibility chosen in the dialog into the exposed virtual server", async () => {
let exposedBody: Record<string, unknown> | undefined;
server.use(
http.post("/api/servers", async ({ request }) => {
exposedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: "virtual-server-1" });
}),
);
expect(screen.getByRole("radio", { name: "Streamable HTTP" })).toBeChecked();
const user = userEvent.setup();
renderWithRouter(<MCPServerForm {...defaultProps} />);

await user.click(screen.getByRole("button", { name: /mcp server catalog/i }));
await user.click(screen.getByRole("radio", { name: /DeepWiki/i }));
await user.click(screen.getByRole("combobox", { name: "Visibility" }));
await user.click(screen.getByRole("option", { name: "Internal" }));
await user.click(screen.getByRole("button", { name: "Continue" }));

await user.click(await screen.findByRole("button", { name: "Expose components" }));

await waitFor(() => expect(exposedBody).toBeDefined());
expect(exposedBody).toMatchObject({ visibility: "public" });
});

it("maps a catalog entry's declared SSE transport onto the transport radio", async () => {
it("keeps the dialog open and reports the failure when registration fails", async () => {
server.use(
http.post("/api/v1/catalog/:catalogId/register", () => {
return new HttpResponse(null, { status: 500 });
}),
);
const user = userEvent.setup();
renderWithRouter(<MCPServerForm {...defaultProps} />);

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" }));

expect(screen.getByRole("radio", { name: "SSE" })).toBeChecked();
expect(
await screen.findByText("Unable to connect this server. Try again."),
).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Expose MCP tools, resources, and prompts" }),
).not.toBeInTheDocument();
});

it("navigates to the full catalog and closes the form when Browse full catalog is clicked", async () => {
Expand Down
63 changes: 33 additions & 30 deletions src/components/mcp-servers/MCPServerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,15 @@ 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 {
QuickAddServerDialog,
type QuickAddConnection,
} from "@/components/mcp-servers/QuickAddServerDialog";
import { ExposeComponentsForm } from "@/components/gateways/ExposeComponentsForm";
import { useRouter } from "@/router";
import {
useMCPServerForm,
type MCPServerFormInitialValues,
type TransportType,
} from "@/hooks/useMCPServerForm";
import { useMCPServerForm, type TransportType } from "@/hooks/useMCPServerForm";
import { STATUS_ICON } from "@/lib/status";
import type { CatalogServer } from "@/generated/types";

// 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";
}
import type { Visibility } from "@/types/server";

interface MCPServerFormProps {
isOpen: boolean;
Expand All @@ -34,14 +27,16 @@ interface MCPServerFormProps {
interface CreatedGatewayInfo {
id: string;
name: string;
/** Scope for the virtual server built at the components step. Quick Add picks its own. */
visibility: Visibility;
teamId: string;
}

export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServerFormProps) {
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 @@ -110,7 +105,7 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
setQueryParamName,
queryParamApiKey,
setQueryParamApiKey,
} = useMCPServerForm(serverId, prefill);
} = useMCPServerForm(serverId);

const handleRedirectUriChange = useCallback(
(uri: string) => {
Expand All @@ -124,12 +119,15 @@ 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),
// Quick Add registers through the catalog endpoint, so the gateway already exists by the
// time this runs and the connect form is skipped entirely. Its scope comes from the dialog
// rather than the form, which the user never passed through.
const handleQuickAddConnected = useCallback((connection: QuickAddConnection) => {
setCreatedGateway({
id: connection.gatewayId,
name: connection.serverName,
visibility: connection.visibility,
teamId: connection.teamId,
});
setQuickAddOpen(false);
}, []);
Expand All @@ -154,6 +152,8 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
const gatewayInfo: CreatedGatewayInfo = {
id: gatewayId,
name: name,
visibility,
teamId,
};
setCreatedGateway(gatewayInfo);
} else {
Expand All @@ -175,8 +175,8 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
<ExposeComponentsForm
gatewayId={createdGateway.id}
gatewayName={createdGateway.name}
visibility={visibility}
teamId={teamId}
visibility={createdGateway.visibility}
teamId={createdGateway.teamId}
oauthNotification={oauthNotification}
clearOAuthNotification={clearOAuthNotification}
fetchToolsNotification={fetchToolsNotification}
Expand Down Expand Up @@ -217,7 +217,7 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
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"
className="inline h-auto p-0 font-medium text-cyan-700 transition hover:text-cyan-800 hover:no-underline dark:text-cyan-400 dark:hover:text-cyan-300"
>
{chunks}
</Button>
Expand Down Expand Up @@ -482,12 +482,15 @@ export function MCPServerForm({ isOpen, onToggle, serverId, onSuccess }: MCPServ
</div>
</div>

<QuickAddServerDialog
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
onSelect={handleQuickAddSelect}
onBrowseCatalog={handleBrowseCatalog}
/>
{/* Mounted only while open: the dialog loads the catalog and the caller's teams. */}
{quickAddOpen && (
<QuickAddServerDialog
open
onOpenChange={setQuickAddOpen}
onConnected={handleQuickAddConnected}
onBrowseCatalog={handleBrowseCatalog}
/>
)}
</>
);
}
Loading