From ffe133afa372986bae2b1eaead8ead4fd42d8d3c Mon Sep 17 00:00:00 2001 From: Wassim Chegham Date: Tue, 23 Jun 2026 13:09:06 +0200 Subject: [PATCH 1/3] feat(portal): always show MCP section with inline create in run/profile flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #1105. The MCP Servers section on Submit Run, Profile create, and New Profile Version was gated on a non-empty server list, hiding it entirely when zero servers existed and breaking in-flow creation. Extract the CreateMcpServer form into a reusable McpServerForm component and a shared McpServerCreateDialog. Always render the MCP section with an empty-state affordance and a "New MCP server…" action that opens an in-place dialog. After creating a server inline it is added to the list and auto-selected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/components/McpServerCreateDialog.tsx | 37 ++ apps/portal/src/components/McpServerForm.tsx | 397 ++++++++++++++++++ .../src/components/ProfileCreateForm.tsx | 87 +++- apps/portal/src/pages/CreateMcpServer.tsx | 379 +---------------- apps/portal/src/pages/NewProfileVersion.tsx | 87 +++- apps/portal/src/pages/SubmitRun.tsx | 89 ++-- 6 files changed, 635 insertions(+), 441 deletions(-) create mode 100644 apps/portal/src/components/McpServerCreateDialog.tsx create mode 100644 apps/portal/src/components/McpServerForm.tsx diff --git a/apps/portal/src/components/McpServerCreateDialog.tsx b/apps/portal/src/components/McpServerCreateDialog.tsx new file mode 100644 index 000000000..cf5e902bd --- /dev/null +++ b/apps/portal/src/components/McpServerCreateDialog.tsx @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, +} from "@/components/ui/dialog"; +import { McpServerForm } from "@/components/McpServerForm"; +import type { McpServerDocument } from "@/types"; + +interface McpServerCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: (server: McpServerDocument) => void; +} + +export function McpServerCreateDialog({ open, onOpenChange, onCreated }: McpServerCreateDialogProps) { + return ( + + + + New MCP Server + + Register an MCP server without leaving this flow. + + +
+ +
+
+
+ ); +} diff --git a/apps/portal/src/components/McpServerForm.tsx b/apps/portal/src/components/McpServerForm.tsx new file mode 100644 index 000000000..00aab7026 --- /dev/null +++ b/apps/portal/src/components/McpServerForm.tsx @@ -0,0 +1,397 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { + McpServerDocument, + McpTransportType, + McpServerHeader, + McpSessionMode, +} from "@/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { Plus, Trash2, Loader2, ArrowLeft } from "lucide-react"; +import { toast } from "sonner"; +import { SecretInput } from "@/components/ui/secret-input"; + +const SLUG_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + +/** Convert a display name to a kebab-case slug */ +function nameToSlug(text: string): string { + return text + .toLowerCase() + .replace(/['']/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +/** Convert a kebab-case slug to a Title Case display name */ +function slugToName(slug: string): string { + return slug + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +interface McpServerFormProps { + onCreated: (server: McpServerDocument) => void; + onCancel?: () => void; + className?: string; + showCancel?: boolean; + stickyFooter?: boolean; +} + +export function McpServerForm({ + onCreated, + onCancel, + className, + showCancel = true, + stickyFooter = false, +}: McpServerFormProps) { + const [slug, setSlug] = useState(""); + const [name, setName] = useState(""); + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); + const [nameManuallyEdited, setNameManuallyEdited] = useState(false); + const [type, setType] = useState("http"); + const [url, setUrl] = useState(""); + const [command, setCommand] = useState(""); + const [args, setArgs] = useState(""); + const [envPairs, setEnvPairs] = useState([]); + const [sessionMode, setSessionMode] = useState("stateless"); + const [version, setVersion] = useState(""); + const [description, setDescription] = useState(""); + const [headers, setHeaders] = useState([]); + + const isStdio = type === "stdio"; + + const handleNameChange = (value: string) => { + setName(value); + setNameManuallyEdited(true); + if (!slugManuallyEdited) { + setSlug(nameToSlug(value)); + } + }; + + const handleSlugChange = (value: string) => { + const lower = value.toLowerCase(); + setSlug(lower); + setSlugManuallyEdited(true); + if (!nameManuallyEdited) { + setName(slugToName(lower)); + } + }; + + const createMutation = useMutation({ + mutationFn: api.createMcpServer, + onSuccess: (data) => { + toast.success(`MCP server "${data.name}" created`); + onCreated(data); + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : "Failed to create MCP server"); + }, + }); + + const isValid = slug && SLUG_REGEX.test(slug) && name && (isStdio ? !!command : !!url); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!isValid) return; + + if (isStdio) { + createMutation.mutate({ + _id: slug, + name, + type, + command, + args: args.trim() ? args.trim().split(/\s+/) : undefined, + env: envPairs.length > 0 ? Object.fromEntries(envPairs.filter(p => p.name && p.value).map(p => [p.name, p.value])) : undefined, + sessionMode, + version: version.trim() || undefined, + ...(description ? { description } : {}), + }); + } else { + createMutation.mutate({ + _id: slug, + name, + type, + url, + ...(description ? { description } : {}), + ...(headers.length > 0 ? { headers: headers.filter(h => h.name && h.value) } : {}), + }); + } + }; + + const addHeader = () => { + setHeaders([...headers, { name: "", value: "" }]); + }; + + const updateHeader = (index: number, field: "name" | "value", val: string) => { + const updated = [...headers]; + updated[index] = { ...updated[index], [field]: val }; + setHeaders(updated); + }; + + const removeHeader = (index: number) => { + setHeaders(headers.filter((_, i) => i !== index)); + }; + + const addEnvPair = () => setEnvPairs([...envPairs, { name: "", value: "" }]); + const updateEnvPair = (index: number, field: "name" | "value", val: string) => { + const updated = [...envPairs]; + updated[index] = { ...updated[index], [field]: val }; + setEnvPairs(updated); + }; + const removeEnvPair = (index: number) => setEnvPairs(envPairs.filter((_, i) => i !== index)); + + return ( +
+ + + Server Details + Configure the remote MCP server connection + + +
+
+ + handleSlugChange(e.target.value)} + pattern="[a-z0-9][a-z0-9-]*[a-z0-9]" + className="font-mono" + /> +

+ Lowercase letters, numbers, and hyphens only +

+ {slug && !SLUG_REGEX.test(slug) && ( +

+ Invalid slug format +

+ )} +
+
+ + handleNameChange(e.target.value)} + /> +
+
+ +
+
+ + +
+ {!isStdio ? ( +
+ + setUrl(e.target.value)} + className="font-mono text-sm" + /> +
+ ) : ( +
+ + setCommand(e.target.value)} + className="font-mono text-sm" + /> +
+ )} +
+ + {isStdio && ( + <> +
+ + setArgs(e.target.value)} + className="font-mono text-sm" + /> +

Space-separated arguments

+
+
+
+ + setVersion(e.target.value)} + className="font-mono text-sm" + /> +

Pins npm package version

+
+
+ + +
+
+ + )} + +
+ +