From ed7de08fdaf8845932231558df59ffcd0d63fc1c Mon Sep 17 00:00:00 2001 From: Luis Leon Date: Tue, 24 Mar 2026 23:35:56 -0600 Subject: [PATCH] Enhance Add Server dialog with transport URL validation and OCI support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Updates the Add Server dialog to support `streamable-http` transport and enforces URL requirements for transports that require them. **What changed:** - **Validation for transport URLs** — The dialog now prevents submission if a URL is missing for `sse` or `streamable-http` transport types. - **OCI as default registry** — New packages now default to the `oci` registry type. - **Improved UI for Remotes** — Replaced the remote type text input with a dropdown for `stdio`, `sse`, and `streamable-http`. - **Full schema URL** — Updated the default schema to the canonical MCP server schema URL. - **Unit tests** — Added a comprehensive test suite for the `AddServerDialog` component to ensure validation and payload structure are correct. # Change Type /kind feature # Changelog ```release-note Improved Add Server dialog with transport URL validation, OCI registry defaults, and a new test suite. ``` --- .../__tests__/add-server-dialog.test.tsx | 121 ++++++++++++++++++ ui/components/add-server-dialog.tsx | 84 +++++++++--- 2 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 ui/components/__tests__/add-server-dialog.test.tsx diff --git a/ui/components/__tests__/add-server-dialog.test.tsx b/ui/components/__tests__/add-server-dialog.test.tsx new file mode 100644 index 00000000..3fd2276b --- /dev/null +++ b/ui/components/__tests__/add-server-dialog.test.tsx @@ -0,0 +1,121 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { AddServerDialog } from "../add-server-dialog" +import { createServerV0 } from "@/lib/admin-api" +import { toast } from "sonner" + +vi.mock("@/lib/admin-api", () => ({ + createServerV0: vi.fn(), +})) + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +describe("AddServerDialog", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(createServerV0).mockResolvedValue({ + data: { server: { name: "io.navteca/hello-mcp" } }, + } as never) + }) + + it("defaults new packages to oci registry type", async () => { + const user = userEvent.setup() + + render( {}} onServerAdded={() => {}} />) + + await user.click(screen.getByRole("button", { name: "Add Package" })) + + expect(screen.getByDisplayValue("oci")).toBeInTheDocument() + }) + + it("prevents submit when package transport is streamable-http without URL", async () => { + const user = userEvent.setup() + + render( {}} onServerAdded={() => {}} />) + + await user.type(screen.getByLabelText("Server Name *"), "io.navteca/hello-mcp") + await user.type(screen.getByLabelText("Version *"), "0.1.8") + await user.type(screen.getByLabelText("Description *"), "MCP server built with FastMCP") + + await user.click(screen.getByRole("button", { name: "Add Package" })) + await user.type(screen.getByPlaceholderText("Package identifier"), "docker.io/luisgleon/my-mcp-server:0.1.8") + await user.type(screen.getByPlaceholderText("Version"), "0.1.8") + await user.click(screen.getByRole("radio", { name: "streamable-http" })) + + expect( + screen.getByPlaceholderText("Transport URL (required) e.g. http://localhost:8080/mcp"), + ).toBeInTheDocument() + + await user.click(screen.getByRole("button", { name: "Create Server" })) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Package transport URL is required for streamable-http") + }) + expect(createServerV0).not.toHaveBeenCalled() + }) + + it("sends transport URL in package payload for streamable-http", async () => { + const user = userEvent.setup() + + render( {}} onServerAdded={() => {}} />) + + await user.type(screen.getByLabelText("Server Name *"), "io.navteca/hello-mcp") + await user.type(screen.getByLabelText("Version *"), "0.1.8") + await user.type(screen.getByLabelText("Description *"), "MCP server built with FastMCP") + + await user.click(screen.getByRole("button", { name: "Add Package" })) + await user.type(screen.getByPlaceholderText("Package identifier"), "docker.io/luisgleon/my-mcp-server:0.1.8") + await user.type(screen.getByPlaceholderText("Version"), "0.1.8") + await user.click(screen.getByRole("radio", { name: "streamable-http" })) + await user.type( + screen.getByPlaceholderText("Transport URL (required) e.g. http://localhost:8080/mcp"), + "http://localhost:8080/mcp", + ) + + await user.click(screen.getByRole("button", { name: "Create Server" })) + + await waitFor(() => { + expect(createServerV0).toHaveBeenCalledTimes(1) + }) + + const callArg = vi.mocked(createServerV0).mock.calls[0]?.[0] + expect(callArg?.throwOnError).toBe(true) + expect(callArg?.body.$schema).toBe("https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json") + expect(callArg?.body.packages).toEqual([ + { + identifier: "docker.io/luisgleon/my-mcp-server:0.1.8", + version: "0.1.8", + registryType: "oci", + transport: { + type: "streamable-http", + url: "http://localhost:8080/mcp", + }, + }, + ]) + }) + + it("prevents submit when remote transport requires URL and it is empty", async () => { + const user = userEvent.setup() + + render( {}} onServerAdded={() => {}} />) + + await user.type(screen.getByLabelText("Server Name *"), "io.navteca/hello-mcp") + await user.type(screen.getByLabelText("Version *"), "0.1.8") + await user.type(screen.getByLabelText("Description *"), "MCP server built with FastMCP") + + await user.click(screen.getByRole("button", { name: "Add Remote" })) + await user.click(screen.getByRole("button", { name: "Create Server" })) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith("Remote URL is required for sse") + }) + expect(createServerV0).not.toHaveBeenCalled() + }) +}) diff --git a/ui/components/add-server-dialog.tsx b/ui/components/add-server-dialog.tsx index 45c68377..b83cb4b4 100644 --- a/ui/components/add-server-dialog.tsx +++ b/ui/components/add-server-dialog.tsx @@ -21,7 +21,7 @@ export function AddServerDialog({ open, onOpenChange, onServerAdded }: AddServer const [loading, setLoading] = useState(false) // Form fields - const [schema, setSchema] = useState("2025-10-17") + const [schema, setSchema] = useState("https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json") const [name, setName] = useState("") const [title, setTitle] = useState("") const [description, setDescription] = useState("") @@ -31,11 +31,13 @@ export function AddServerDialog({ open, onOpenChange, onServerAdded }: AddServer const [repositoryUrl, setRepositoryUrl] = useState("") // Dynamic fields - const [packages, setPackages] = useState>([]) + const [packages, setPackages] = useState>([]) const [remotes, setRemotes] = useState>([]) + const transportRequiresUrl = (transportType: string) => transportType === "sse" || transportType === "streamable-http" + const resetForm = () => { - setSchema("2025-10-17") + setSchema("https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json") setName("") setTitle("") setDescription("") @@ -92,23 +94,48 @@ export function AddServerDialog({ open, onOpenChange, onServerAdded }: AddServer } if (packages.length > 0) { - server.packages = packages - .filter(p => p.identifier.trim() && p.version.trim()) - .map(p => ({ + const filteredPackages = packages.filter(p => p.identifier.trim() && p.version.trim()) + + for (const p of filteredPackages) { + const transportType = (p.transport || "stdio").trim() + if (transportRequiresUrl(transportType) && !p.transportUrl.trim()) { + throw new Error(`Package transport URL is required for ${transportType}`) + } + } + + server.packages = filteredPackages.map(p => { + const transportType = (p.transport || "stdio").trim() + const transport = { + type: transportType, + ...(transportRequiresUrl(transportType) ? { url: p.transportUrl.trim() } : {}), + } + + return { identifier: p.identifier.trim(), version: p.version.trim(), - registryType: p.registryType as 'npm' | 'pypi' | 'docker', - transport: { type: p.transport || 'stdio' }, - })) + registryType: p.registryType.trim(), + transport, + } + }) } if (remotes.length > 0) { - server.remotes = remotes - .filter(r => r.type.trim()) - .map(r => ({ - type: r.type.trim(), - url: r.url.trim() || undefined, - })) + const filteredRemotes = remotes.filter(r => r.type.trim()) + + for (const r of filteredRemotes) { + const remoteType = r.type.trim() + if (transportRequiresUrl(remoteType) && !r.url.trim()) { + throw new Error(`Remote URL is required for ${remoteType}`) + } + } + + server.remotes = filteredRemotes.map(r => { + const remoteType = r.type.trim() + return { + type: remoteType, + ...(transportRequiresUrl(remoteType) ? { url: r.url.trim() } : { url: r.url.trim() || undefined }), + } + }) } // Create server @@ -130,7 +157,7 @@ export function AddServerDialog({ open, onOpenChange, onServerAdded }: AddServer } const addPackage = () => { - setPackages([...packages, { identifier: "", version: "", registryType: "npm", transport: "stdio" }]) + setPackages([...packages, { identifier: "", version: "", registryType: "oci", transport: "stdio", transportUrl: "" }]) } const removePackage = (index: number) => { @@ -297,6 +324,7 @@ export function AddServerDialog({ open, onOpenChange, onServerAdded }: AddServer className="px-3 py-2 border rounded-md bg-background text-foreground border-input focus:outline-none focus:ring-2 focus:ring-ring" disabled={loading} > + @@ -327,6 +355,17 @@ export function AddServerDialog({ open, onOpenChange, onServerAdded }: AddServer ))} + {transportRequiresUrl(pkg.transport) && ( +
+ updatePackage(index, "transportUrl", e.target.value)} + disabled={loading} + className="w-full" + /> +
+ )} ))} @@ -355,15 +394,18 @@ export function AddServerDialog({ open, onOpenChange, onServerAdded }: AddServer {remotes.map((remote, index) => (
- updateRemote(index, "type", e.target.value)} + className="w-40 px-3 py-2 border rounded-md bg-background text-foreground border-input focus:outline-none focus:ring-2 focus:ring-ring" disabled={loading} - className="w-40" - /> + > + + + + updateRemote(index, "url", e.target.value)} disabled={loading}