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 }) })
│
├─ Tap 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 tap 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" },
}),
];
export function Providers({ children }: { children: React.ReactNode }) {
const aui = useAui({ mcp: McpManagerResource({ connectors }) });
return <AuiProvider value={aui}>{children}</AuiProvider>;
}Defaults baked in:
storage—McpLocalStorage()(override for production; see Storage)oauthRedirectUri—${window.location.origin}/mcp/callbackautoConnect—true(connect on mount when usable auth is persisted)
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 https://r.assistant-ui.com/mcp-config.jsonMain Component
npm install @assistant-ui/react-mcp @assistant-ui/store"use client";import { type FC, type ReactNode, useState } 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 { Input } from "@/components/ui/input";import { Label } from "@/components/ui/label";import { Separator } from "@/components/ui/separator";import { cn } from "@/lib/utils";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> <DialogTrigger asChild> {children ?? ( <Button variant="outline" size="sm" className="aui-mcp-config-trigger gap-2" > <PlugIcon className="size-4" /> MCP servers </Button> )} </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 asChild> <Button variant="outline" className="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 </Button> </McpManagerPrimitive.AddCustomTrigger> )} {showForm && <AddServerForm onClose={() => setShowForm(false)} />} </section> );};const SectionTitle: FC<{ children: ReactNode }> = ({ children }) => ( <h3 className="font-medium text-muted-foreground text-xs uppercase tracking-wide"> {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 font-medium text-sm"> <McpServerPrimitive.Name /> </span> <StatusLine /> </div> <div className="flex items-center gap-1"> <ServerActions /> <McpServerPrimitive.RemoveButton asChild> <Button variant="ghost" size="icon" className="aui-mcp-server-remove size-7 text-muted-foreground hover:text-destructive" > <Trash2Icon className="size-4" /> <span className="sr-only">Remove</span> </Button> </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="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted text-muted-foreground"> {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="flex items-center gap-1.5 text-muted-foreground 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="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-2 py-1.5 text-destructive 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 asChild> <Button size="sm" variant="default" className="aui-mcp-server-connect h-8 gap-2 text-xs" > <PlugZapIcon className="size-3.5" /> Connect </Button> </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 asChild> <Button size="sm" variant="outline" className="aui-mcp-server-disconnect h-8 text-xs" > Disconnect </Button> </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="font-medium text-sm">New server</h4> <McpAddFormPrimitive.Cancel asChild> <Button type="button" variant="ghost" size="icon" className="size-7 text-muted-foreground" > <XIcon className="size-4" /> <span className="sr-only">Close</span> </Button> </McpAddFormPrimitive.Cancel> </div> <FormRow label="Name"> <McpAddFormPrimitive.NameField asChild> <Input placeholder="My MCP server" /> </McpAddFormPrimitive.NameField> </FormRow> <FormRow label="URL"> <McpAddFormPrimitive.UrlField asChild> <Input placeholder="https://example.com/mcp" /> </McpAddFormPrimitive.UrlField> </FormRow> <FormRow label="Auth"> <McpAddFormPrimitive.AuthSelect className="aui-mcp-auth-select h-9 w-full rounded-md border bg-background 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. "empty:hidden [&_input]:flex [&_input]:h-9 [&_input]:w-full [&_input]:rounded-md [&_input]:border [&_input]:border-input [&_input]:bg-transparent [&_input]:px-3 [&_input]:py-1 [&_input]:text-sm [&_input]:shadow-xs [&_input]:outline-none [&_input]:transition-[color,box-shadow]", "[&_input:focus-visible]:border-ring [&_input:focus-visible]:ring-[3px] [&_input:focus-visible]:ring-ring/50", "[&_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 asChild> <Button type="button" variant="ghost" size="sm"> Cancel </Button> </McpAddFormPrimitive.Cancel> <McpAddFormPrimitive.Submit asChild> <Button type="submit" size="sm"> Add server </Button> </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>);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 { useChatRuntime } from "@assistant-ui/react-ai-sdk";
export function Chat() {
const runtime = useChatRuntime({ api: "/api/chat" });
/* … */
}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. See the tap conventions.
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 — see the tap skill for why):
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" });v1 scope
What ships:
- Tool listing and invocation, auto-registered as frontend tools
- OAuth (PKCE + DCR), bearer, none
- StreamableHTTP transport
- Manual connect/disconnect
What's deferred:
- Resources, 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)