# User-managed MCP servers
URL: /docs/integrations/tools/react-mcp

Let end users add and authenticate MCP servers from the browser with @assistant-ui/react-mcp.

- href

  https\://npmjs.com/package/@assistant-ui/react-mcp

`@assistant-ui/react-mcp`

is the user-facing layer for MCP. Where the

- href

  /docs/integrations/tools/mcp

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.

- as

  h2

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 automatically`

The 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.

- as

  h2

Setup

- as

  h3

Install

- packages

  - @assistant-ui/react-mcp

* as

  h3

Mount the manager

Declare your connectors and provide the `mcp` scope on `useAui`. No provider wrapper, no imperative hooks:

- title

  app/providers.tsx

`"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

  - href

    \#storage

  Storage

  )

- `oauthRedirectUri` — `${window.location.origin}/mcp/callback`

- `autoConnect` — `true` (connect on mount when usable auth is persisted)

* as

  h3

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:

- items

  - CLI
  - Manual

* urls

  - https\://r.assistant-ui.com/mcp-config.json

#### Main Component

- packages

  - @assistant-ui/react-mcp
  - @assistant-ui/store

* code

  "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> );

- lang

  tsx

- code

  "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> );

* title

  app/page.tsx

`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:

- title

  app/mcp/page.tsx

`"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).

- as

  h3

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.

- as

  h3

Handle the OAuth callback

Add a route at `/mcp/callback` (or whatever you set `oauthRedirectUri` to):

- title

  app/mcp/callback/page.tsx

`"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.

- as

  h3

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 });`

- as

  h2

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 the `aui-mcp:` prefix in `window.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.

- as

  h2

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.

- as

  h2

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 McpServerByIdProvider`

Imperative methods — `useAui` + resolve in a callback (never during render — see the

- href

  https\://github.com/assistant-ui/assistant-ui/blob/main/.claude/skills/tap/SKILL.md

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" });`

- as

  h2

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 `McpCustomStorage` against a server endpoint)

* as

  h2

Related

- href

  /docs/integrations/tools/mcp

Server-side MCPApp-developer-controlled MCP servers wired into the API route.

- href

  /docs/guides/tools

Tools and tool UIBuild custom renderers for tool calls and approvals.