Let end users add and authenticate MCP servers from the browser with @assistant-ui/react-mcp.
@assistant-ui/react-mcp is the user-facing layer for MCP. Where the server-side MCP guide wires a single fixed set of MCP servers into your API route, this package lets end users see a list of connectors, sign in via OAuth, paste a custom server URL, and have the resulting tool catalog flow into the chat — automatically.
Two ways a server reaches the user:
- Connector — a preset declared by the app developer with
defineConnector(...). The user just clicks Connect (and completes auth). - Custom server — the user supplies the URL, name, and auth in
<McpAddFormPrimitive>. Hide the add UI to disable.
Both flow through one connection lifecycle, one persisted state surface, and one tool registration path.
How it works
useAui({ mcp: McpManagerResource({ connectors }) })
│
├─ Resource — connection lifecycle, server lookup, OAuth/bearer auth
├─ Auto-mounts the modelContext scope when no chat runtime provides one
└─ Registers connected tools as frontend tools — your chat sees them automaticallyThe manager is a single resource. Mount it with useAui like any other scope. OAuth (PKCE + RFC 7591 dynamic client registration), bearer, and "no auth" are first-class. Token refresh runs inside the MCP SDK on 401; this package mediates persistence and the redirect step.
Setup
Install
npm install @assistant-ui/react-mcpMount the manager
Declare your connectors and provide the mcp scope on useAui. No provider wrapper, no imperative hooks:
"use client";
import { 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"] },
icon: "/icons/linear.svg",
}),
defineConnector({
id: "weather",
name: "Weather",
url: "https://mcp.example.com/weather",
auth: { type: "none" },
connectionTimeout: 10_000,
}),
];
export function Providers({ children }: { children: React.ReactNode }) {
const aui = useAui({
mcp: McpManagerResource({
connectors,
connectionTimeout: 15_000,
}),
});
return <AuiProvider value={aui}>{children}</AuiProvider>;
}Defaults and useful options:
storage—McpLocalStorage()(override for production; see Storage)oauthRedirectUri—${window.location.origin}/mcp/callbackautoConnect—true(connect on mount when usable auth is persisted)connectionTimeout— optional timeout in milliseconds. Set it on the manager as a default or on a connector/custom server to bound the MCP readiness flow (connect()pluslistTools()) with a clear error.- Connector
idvalues must be unique. The id is used for server lookup, OAuth routing, and model-visible tool names such aslinear__search.
Pick your UI
You have two options:
Drop-in shadcn dialog (recommended for most apps) — install the mcp-config component via the assistant-ui registry, then render <McpConfigDialog /> anywhere inside the provider. You get a styled trigger, server cards, status badges, error banners, and the add form for free:
npx shadcn@latest add @assistant-ui/mcp-configThe @assistant-uinamespace 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 shadow-xs transition-[color,box-shadow] 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-[3px] 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]:shadow-xs [&_input]:transition-[color,box-shadow] [&_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-[color,box-shadow] focus-visible:ring-[3px] [&>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-3 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_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: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", 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-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", 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-4 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( "border-input 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:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 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-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 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 };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>
);
}Pass children to override the trigger:
<McpConfigDialog>
<Button variant="ghost">Servers</Button>
</McpConfigDialog>Compose your own from primitives — three namespaces, all unstyled and data-*-driven. The iteration primitives take a render function so the body re-runs per server with the right scope:
"use client";
import {
McpManagerPrimitive,
McpServerPrimitive,
} from "@assistant-ui/react-mcp";
const ServerCard = () => (
<McpServerPrimitive.Root>
<McpServerPrimitive.Icon />
<McpServerPrimitive.Name />
<McpServerPrimitive.Status />
<McpServerPrimitive.ConnectButton>Connect</McpServerPrimitive.ConnectButton>
<McpServerPrimitive.DisconnectButton>Disconnect</McpServerPrimitive.DisconnectButton>
<McpServerPrimitive.OAuthLink>Authorize ↗</McpServerPrimitive.OAuthLink>
<McpServerPrimitive.RemoveButton>Remove</McpServerPrimitive.RemoveButton>
<McpServerPrimitive.Error />
</McpServerPrimitive.Root>
);
export default function McpPage() {
return (
<McpManagerPrimitive.Root>
<h2>Connectors</h2>
<McpManagerPrimitive.Connectors>
{() => <ServerCard />}
</McpManagerPrimitive.Connectors>
<h2>Your servers</h2>
<McpManagerPrimitive.CustomServers>
{() => <ServerCard />}
</McpManagerPrimitive.CustomServers>
<McpManagerPrimitive.AddCustomTrigger>
Add custom server
</McpManagerPrimitive.AddCustomTrigger>
</McpManagerPrimitive.Root>
);
}To disable custom servers entirely, just don't render AddCustomTrigger and CustomServers.
The iteration primitives wrap each item in an McpServerByIdProvider, so the nested <McpServerPrimitive.*> automatically reads the right scope. <ConnectButton>, <DisconnectButton>, <OAuthLink> and <RemoveButton> only render when the relevant state matches — no manual gating. <RemoveButton> is also hidden on connector items (which the user can't remove).
Add the custom-server form
<McpAddFormPrimitive.Root onSubmitted={() => closeDialog()}>
<McpAddFormPrimitive.NameField />
<McpAddFormPrimitive.UrlField />
<McpAddFormPrimitive.AuthSelect /> {/* none | bearer | oauth */}
<McpAddFormPrimitive.AuthFields /> {/* token or scope input depending on selection */}
<McpAddFormPrimitive.Error />
<McpAddFormPrimitive.Submit>Add</McpAddFormPrimitive.Submit>
<McpAddFormPrimitive.Cancel>Cancel</McpAddFormPrimitive.Cancel>
</McpAddFormPrimitive.Root>The form owns its own draft state and submits via aui.mcp().addCustomServer(...). Pass a render function to AuthFields to fully customize it.
Handle the OAuth callback
Add a route at /mcp/callback (or whatever you set oauthRedirectUri to):
"use client";
import { McpOAuthCallback } from "@assistant-ui/react-mcp";
import { useRouter } from "next/navigation";
import { Providers } from "../../providers";
export default function Callback() {
const router = useRouter();
return (
<Providers>
<McpOAuthCallback onComplete={() => router.replace("/mcp")} />
</Providers>
);
}The callback reads ?state=...&code=... from the URL, derives the target server id (encoded in the OAuth state parameter automatically), and calls completeAuth on the right server.
That's it — the chat sees your tools
McpManagerResource registers connected tools as frontend tools with the modelContext scope. Any chat runtime mounted in the same store (e.g. @assistant-ui/react-ai-sdk's useChatRuntime) sees them and exposes them to the model — no useMcpTools hook, no adapter call.
"use client";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
export function Chat() {
const runtime = useChatRuntime({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
/* … */
}sendAutomaticallyWhen sends completed frontend tool results back to the server so the model can continue after a tool call. useChatRuntime() targets /api/chat by default; to point at a different endpoint, see Custom transport.
Tool names are prefixed serverId__toolName to avoid collisions across connected servers. The toolkit re-registers whenever a server connects / disconnects or its tool list changes.
If no chat runtime is mounted, McpManagerResource brings its own minimal modelContext along. Tools are still callable directly:
// In an event handler, never in render.
const aui = useAui();
const out = await aui.mcp().server({ id: "linear" }).callTool("search", { q });Storage
All persisted state — custom server records, OAuth tokens, PKCE verifiers, DCR client info — goes through a single MCPStorage resource. Three built-ins:
McpLocalStorage()— default. Stores under theaui-mcp:prefix inwindow.localStorage.McpMemoryStorage()— in-process Map. Use for SSR/tests where localStorage is absent.McpCustomStorage({...})— bring your own load/save. Use for app-controlled backends (e.g. POST to your API).
import { McpManagerResource, McpCustomStorage } from "@assistant-ui/react-mcp";
const aui = useAui({
mcp: McpManagerResource({
connectors,
storage: McpCustomStorage({
loadCustomServers: async () => fetch("/api/mcp/servers").then((r) => r.json()),
saveCustomServers: async (records) =>
fetch("/api/mcp/servers", { method: "PUT", body: JSON.stringify(records) }),
loadAuthState: async (id) =>
fetch(`/api/mcp/auth/${id}`).then((r) => (r.ok ? r.json() : null)),
saveAuthState: async (id, state) =>
fetch(`/api/mcp/auth/${id}`, { method: "PUT", body: JSON.stringify(state) }),
clearAuthState: async (id) =>
fetch(`/api/mcp/auth/${id}`, { method: "DELETE" }),
}),
}),
});McpLocalStorage stores tokens in plain text and is XSS-exposed. For anything beyond local prototyping, use McpCustomStorage against an HTTP-only-cookie-backed endpoint, or wrap localStorage with your own encrypted serializer.
Auth
Three modes, declared per-connector or per-custom-record:
{ type: "none" } // no auth header
{ type: "bearer", token?: "…" } // Authorization: Bearer …
{ type: "oauth", // PKCE + DCR + refresh
scopes?: ["read"],
authorizationEndpoint?: "…", // overrides RFC 8414 discovery
tokenEndpoint?: "…",
registrationEndpoint?: "…",
clientId?: "…", // skip DCR with a static client
clientSecret?: "…",
}The OAuth provider implements the MCP SDK's OAuthClientProvider. The SDK handles discovery (RFC 8414), DCR (RFC 7591), PKCE, token exchange, and refresh; this package mediates MCPStorage reads/writes and the redirect step. The server id is embedded in the OAuth state parameter so a single /mcp/callback route knows which server to complete.
State & methods
Render-time state — useAuiState:
import { useAuiState } from "@assistant-ui/store";
const isHydrated = useAuiState((s) => s.mcp.isHydrated);
const connectionState = useAuiState((s) => s.mcpServer.connectionState);
// ^ requires McpServerByIdProviderImperative methods: useAui + resolve in a callback (never during render):
const aui = useAui();
// inside an event handler:
await aui.mcp().addCustomServer({ name, url, auth: { type: "bearer", token } });
await aui.mcp().server({ id }).connect();
await aui.mcp().server({ id }).callTool("echo", { text: "hi" });
// Build a paginated resource browser/preview UI.
type ResourcePage = {
resources: Array<{ uri: string; name?: string }>;
nextCursor?: string;
};
const server = aui.mcp().server({ id });
const resources: ResourcePage["resources"] = [];
let nextCursor: string | undefined;
do {
const page = (await (nextCursor === undefined
? server.listResources()
: server.listResources({ cursor: nextCursor }))) as ResourcePage;
resources.push(...page.resources);
nextCursor = page.nextCursor;
} while (nextCursor !== undefined);
const firstResource = resources[0];
if (firstResource) {
const preview = await server.readResource(firstResource.uri);
}v1 scope
What ships:
- Tool listing and invocation, auto-registered as frontend tools
- Resource listing and reads for app-built browsers or preview panes
- OAuth (PKCE + DCR), bearer, none
- StreamableHTTP transport
- Manual connect/disconnect
What's deferred:
- Prompts, sampling
- Auto-reconnect with backoff
- Per-tool enable/disable persistence
- Per-tool consent prompts
- Out-of-the-box token encryption (use
McpCustomStorageagainst a server endpoint)