Drop-in shadcn dialog that lists MCP connectors and custom servers, with inline OAuth/bearer auth controls and an add form.
McpConfigDialog is a shadcn template that wraps the @assistant-ui/react-mcp primitives. Render it anywhere inside an McpManagerResource-mounted client and your users get a styled MCP server panel — no manual primitive composition.
Getting Started
Install the package and the component
npm install @assistant-ui/react-mcpnpx shadcn@latest add @assistant-ui/mcp-configThe @assistant-ui namespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/mcp-config.jsonMain Component
npm install @assistant-ui/react-mcp @assistant-ui/store"use client";import { type FC, type ReactNode, useState, isValidElement } from "react";import { useAuiState } from "@assistant-ui/store";import { McpAddFormPrimitive, McpManagerPrimitive, McpServerPrimitive, type MCPConnectionState,} from "@assistant-ui/react-mcp";import { Loader2Icon, PlugIcon, PlugZapIcon, PlusIcon, ServerIcon, ShieldAlertIcon, Trash2Icon, XIcon,} from "lucide-react";import { Badge } from "@/components/ui/badge";import { Button, buttonVariants } from "@/components/ui/button";import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";import { Label } from "@/components/ui/label";import { Separator } from "@/components/ui/separator";import { cn } from "@/lib/utils";const inputClassName = "border-input selection:bg-primary selection:text-primary-foreground file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-1 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40";export namespace McpConfigDialog { export type Props = { /** Trigger element. Defaults to a ghost button with a plug icon. */ children?: ReactNode; };}/** * Drop-in MCP server configuration dialog. Lists app-defined connectors and * user-added custom servers, with inline auth controls and an add form. * * Mount the manager once at the root of your app: * ```tsx * useAui({ mcp: McpManagerResource({ connectors }) }); * ``` * then render `<McpConfigDialog />` anywhere inside the provider. */export const McpConfigDialog: FC<McpConfigDialog.Props> = ({ children }) => { return ( <Dialog> {isValidElement(children) ? ( <DialogTrigger render={children} /> ) : ( <DialogTrigger render={ <Button variant="outline" size="sm" className="aui-mcp-config-trigger gap-2" /> } > <PlugIcon className="size-4" /> MCP servers </DialogTrigger> )} <DialogContent className="aui-mcp-config-content sm:max-w-lg"> <DialogHeader> <DialogTitle>MCP servers</DialogTitle> <DialogDescription> Connect to Model Context Protocol servers to expose their tools to this assistant. </DialogDescription> </DialogHeader> <McpManagerPrimitive.Root> <div className="flex flex-col gap-4"> <ConnectorsSection /> <Separator /> <CustomServersSection /> </div> </McpManagerPrimitive.Root> </DialogContent> </Dialog> );};McpConfigDialog.displayName = "McpConfigDialog";const ConnectorsSection: FC = () => { return ( <section className="aui-mcp-connectors flex flex-col gap-2"> <SectionTitle>Connectors</SectionTitle> <div className="flex flex-col gap-2"> <McpManagerPrimitive.Connectors> {() => <ServerCard />} </McpManagerPrimitive.Connectors> </div> </section> );};const CustomServersSection: FC = () => { const [showForm, setShowForm] = useState(false); return ( <section className="aui-mcp-custom-servers flex flex-col gap-2"> <SectionTitle>Custom servers</SectionTitle> <div className="flex flex-col gap-2"> <McpManagerPrimitive.CustomServers> {() => <ServerCard />} </McpManagerPrimitive.CustomServers> </div> {!showForm && ( <McpManagerPrimitive.AddCustomTrigger className={cn( buttonVariants({ variant: "outline" }), "aui-mcp-add-trigger h-9 justify-start gap-2 rounded-lg px-3 text-sm", )} onClick={() => setShowForm(true)} > <PlusIcon className="size-4" /> Add server </McpManagerPrimitive.AddCustomTrigger> )} {showForm && <AddServerForm onClose={() => setShowForm(false)} />} </section> );};const SectionTitle: FC<{ children: ReactNode }> = ({ children }) => ( <h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> {children} </h3>);const ServerCard: FC = () => { return ( <McpServerPrimitive.Root className={cn( "aui-mcp-server-card flex flex-col gap-2 rounded-lg border p-3", "data-[connection-state=error]:border-destructive/40", )} > <div className="flex items-center gap-3"> <ServerAvatar /> <div className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-sm font-medium"> <McpServerPrimitive.Name /> </span> <StatusLine /> </div> <div className="flex items-center gap-1"> <ServerActions /> <McpServerPrimitive.RemoveButton className={cn( buttonVariants({ variant: "ghost", size: "icon" }), "aui-mcp-server-remove text-muted-foreground hover:text-destructive size-7", )} > <Trash2Icon className="size-4" /> <span className="sr-only">Remove</span> </McpServerPrimitive.RemoveButton> </div> </div> <ServerError /> </McpServerPrimitive.Root> );};const ServerAvatar: FC = () => { const icon = useAuiState((s) => s.mcpServer.icon ?? null); const name = useAuiState((s) => s.mcpServer.name); return ( <div className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-md border"> {icon ? ( <img src={icon} alt={name} className="size-full object-cover" /> ) : ( <ServerIcon className="size-4" /> )} </div> );};const STATUS_VARIANT: Record< MCPConnectionState, "default" | "secondary" | "destructive"> = { connected: "default", connecting: "secondary", authRequired: "secondary", authPending: "secondary", error: "destructive", disconnected: "secondary",};const STATUS_LABEL: Record<MCPConnectionState, string> = { connected: "Connected", connecting: "Connecting…", authRequired: "Auth required", authPending: "Authorizing…", error: "Error", disconnected: "Disconnected",};const StatusLine: FC = () => { const status = useAuiState((s) => s.mcpServer.connectionState); const variant = STATUS_VARIANT[status]; const label = STATUS_LABEL[status]; return ( <div className="text-muted-foreground flex items-center gap-1.5 text-xs"> <Badge variant={variant}> {status === "connecting" && ( <Loader2Icon className="size-3 animate-spin" /> )} {label} </Badge> </div> );};const ServerError: FC = () => { const message = useAuiState((s) => s.mcpServer.lastError?.message ?? null); if (!message) return null; return ( <div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border px-2 py-1.5 text-xs"> <ShieldAlertIcon className="mt-0.5 size-3.5 shrink-0" /> <span className="break-words">{message}</span> </div> );};const ServerActions: FC = () => ( <div className="flex flex-wrap gap-2"> <McpServerPrimitive.ConnectButton className={cn( buttonVariants({ variant: "default", size: "sm" }), "aui-mcp-server-connect h-8 gap-2 text-xs", )} > <PlugZapIcon className="size-3.5" /> Connect </McpServerPrimitive.ConnectButton> <McpServerPrimitive.OAuthLink className={cn( buttonVariants({ variant: "default", size: "sm" }), "aui-mcp-server-authorize h-8 gap-2 text-xs", )} > Authorize </McpServerPrimitive.OAuthLink> <McpServerPrimitive.DisconnectButton className={cn( buttonVariants({ variant: "outline", size: "sm" }), "aui-mcp-server-disconnect h-8 text-xs", )} > Disconnect </McpServerPrimitive.DisconnectButton> </div>);const AddServerForm: FC<{ onClose: () => void }> = ({ onClose }) => { return ( <McpAddFormPrimitive.Root onSubmitted={onClose} onCancel={onClose}> <div className="aui-mcp-add-form flex flex-col gap-3 rounded-lg border p-3"> <div className="flex items-center justify-between"> <h4 className="text-sm font-medium">New server</h4> <McpAddFormPrimitive.Cancel type="button" className={cn( buttonVariants({ variant: "ghost", size: "icon" }), "text-muted-foreground size-7", )} > <XIcon className="size-4" /> <span className="sr-only">Close</span> </McpAddFormPrimitive.Cancel> </div> <FormRow label="Name"> <McpAddFormPrimitive.NameField placeholder="My MCP server" className={inputClassName} /> </FormRow> <FormRow label="URL"> <McpAddFormPrimitive.UrlField placeholder="https://example.com/mcp" className={inputClassName} /> </FormRow> <FormRow label="Auth"> <McpAddFormPrimitive.AuthSelect className="aui-mcp-auth-select bg-background h-9 w-full rounded-md border px-2 text-sm" /> <div className={cn( // Style the default `<input>` inside AuthFields without // needing to thread useAddForm out of the primitive. Mirrors // the shadcn <Input> look. "[&_input]:border-input empty:hidden [&_input]:flex [&_input]:h-9 [&_input]:w-full [&_input]:rounded-md [&_input]:border [&_input]:bg-transparent [&_input]:px-3 [&_input]:py-1 [&_input]:text-sm [&_input]:transition-colors [&_input]:outline-none", "[&_input:focus-visible]:border-ring [&_input:focus-visible]:ring-ring/50 [&_input:focus-visible]:ring-[3px]", "[&_input::placeholder]:text-muted-foreground", )} > <McpAddFormPrimitive.AuthFields /> </div> </FormRow> <McpAddFormPrimitive.Error className="text-destructive text-xs" /> <div className="flex justify-end gap-2"> <McpAddFormPrimitive.Cancel type="button" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} > Cancel </McpAddFormPrimitive.Cancel> <McpAddFormPrimitive.Submit type="submit" className={cn(buttonVariants({ size: "sm" }))} > Add server </McpAddFormPrimitive.Submit> </div> </div> </McpAddFormPrimitive.Root> );};const FormRow: FC<{ label: string; children: ReactNode }> = ({ label, children,}) => ( <div className="flex flex-col gap-1.5"> <Label className="text-xs">{label}</Label> <div className="flex flex-col gap-2">{children}</div> </div>);shadcn/ui dependencies
npm install @base-ui/reactimport { mergeProps } from "@base-ui/react/merge-props";import { useRender } from "@base-ui/react/use-render";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const badgeVariants = cva( "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-colors focus-visible:ring-1 [&>svg]:pointer-events-none [&>svg]:size-3", { variants: { variant: { default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "bg-destructive focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90 text-white", outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", link: "text-primary underline-offset-4 [a&]:hover:underline", }, }, defaultVariants: { variant: "default", }, },);function Badge({ className, variant = "default", render, ...props}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { return useRender({ defaultTagName: "span", props: mergeProps<"span">( { className: cn(badgeVariants({ variant }), className), }, props, ), render, state: { slot: "badge", variant, }, });}export { Badge, badgeVariants };import { Button as ButtonPrimitive } from "@base-ui/react/button";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const buttonVariants = cva( "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-1 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-1 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-muted/70 text-foreground hover:bg-muted aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-muted/50 dark:hover:bg-muted border-transparent", secondary: "bg-secondary text-secondary-foreground aria-expanded:bg-secondary aria-expanded:text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", icon: "size-8", "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "icon-lg": "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, },);function Button({ className, variant = "default", size = "default", ...props}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { return ( <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> );}export { Button, buttonVariants };"use client";import * as React from "react";import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";import { XIcon } from "lucide-react";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";function Dialog({ ...props }: DialogPrimitive.Root.Props) { return <DialogPrimitive.Root data-slot="dialog" {...props} />;}function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;}function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;}function DialogClose({ ...props }: DialogPrimitive.Close.Props) { return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;}function DialogOverlay({ className, ...props}: DialogPrimitive.Backdrop.Props) { return ( <DialogPrimitive.Backdrop data-slot="dialog-overlay" className={cn( "data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs", className, )} {...props} /> );}function DialogContent({ className, children, showCloseButton = true, ...props}: DialogPrimitive.Popup.Props & { showCloseButton?: boolean;}) { return ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Popup data-slot="dialog-content" className={cn( "bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-5 text-sm ring-1 duration-100 outline-none sm:max-w-sm", className, )} {...props} > {children} {showCloseButton && ( <DialogPrimitive.Close data-slot="dialog-close" render={ <Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" /> } > <XIcon /> <span className="sr-only">Close</span> </DialogPrimitive.Close> )} </DialogPrimitive.Popup> </DialogPortal> );}function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} /> );}function DialogFooter({ className, showCloseButton = false, children, ...props}: React.ComponentProps<"div"> & { showCloseButton?: boolean;}) { return ( <div data-slot="dialog-footer" className={cn( "bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 sm:flex-row sm:justify-end", className, )} {...props} > {children} {showCloseButton && ( <DialogPrimitive.Close render={<Button variant="outline" />}> Close </DialogPrimitive.Close> )} </div> );}function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { return ( <DialogPrimitive.Title data-slot="dialog-title" className={cn( "cn-font-heading text-base leading-none font-medium", className, )} {...props} /> );}function DialogDescription({ className, ...props}: DialogPrimitive.Description.Props) { return ( <DialogPrimitive.Description data-slot="dialog-description" className={cn( "text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className, )} {...props} /> );}export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger,};import * as React from "react";import { Input as InputPrimitive } from "@base-ui/react/input";import { cn } from "@/lib/utils";function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( <InputPrimitive type={type} data-slot="input" className={cn( "file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 bg-muted/60 focus-visible:bg-background h-9 w-full min-w-0 rounded-lg border border-transparent px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm", className, )} {...props} /> );}export { Input };import type { ComponentProps } from "react";import { cn } from "@/lib/utils";function Label({ className, ...props }: ComponentProps<"label">) { return ( <label data-slot="label" className={cn( "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className, )} {...props} /> );}export { Label };"use client";import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";import { cn } from "@/lib/utils";function Separator({ className, orientation = "horizontal", ...props}: SeparatorPrimitive.Props) { return ( <SeparatorPrimitive data-slot="separator" orientation={orientation} className={cn( "bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", className, )} {...props} /> );}export { Separator };Mount the manager
"use client";
import { AuiConfig, AuiProvider, useAui } from "@assistant-ui/react";
import { McpManagerResource, defineConnector } from "@assistant-ui/react-mcp";
const connectors = [
defineConnector({
id: "linear",
name: "Linear",
url: "https://mcp.linear.app",
auth: { type: "oauth", scopes: ["read"] },
}),
];
export function Providers({ children }: { children: React.ReactNode }) {
const aui = useAui();
const config = AuiConfig({ mcp: McpManagerResource({ connectors }) });
return (
<AuiProvider extends={aui} config={config}>
{children}
</AuiProvider>
);
}Drop the dialog in
import { McpConfigDialog } from "@/components/assistant-ui/mcp-config";
export default function Page() {
return (
<header className="flex items-center justify-between">
<h1>My app</h1>
<McpConfigDialog />
</header>
);
}That's it. The default trigger is an outline "🔌 MCP servers" button. Click it to open the dialog, which renders a Connectors list (your app-defined presets with connect/authorize/disconnect controls), a Custom servers list (user-added entries with the same controls plus a remove button), and an inline Add server form for URL + name + auth selection.
Override the trigger (optional)
Pass children to swap the default button for your own:
<McpConfigDialog>
<Button variant="ghost">Servers</Button>
</McpConfigDialog>Wire up the OAuth callback
Required for any OAuth-enabled connector or custom server. See the OAuth callback section in the react-mcp guide for the route handler.