# @assistant-ui/react-mcp Specification External API spec for the MCP integration package. Mirrors `@assistant-ui/react-o11y`: scope-augmented store types, tap-backed resources, Radix-style unstyled primitives. ## Scope (v1) `react-mcp` is the **user-facing** configuration surface for MCP servers in an assistant-ui app. Two ways a server reaches the user: - **Connector** — A preset declared by the app developer (`defineConnector(...)`). User just connects (and authenticates). - **Custom server** — User supplies URL, name, auth, via ``. Hide the add UI to disable. Both share one connection lifecycle, one persisted state surface, and one tool registration path. **Tools only.** v1 lists and invokes tools, registering them as **frontend tools** with `modelContext` so a connected chat runtime sees them automatically. Resources, prompts, sampling, server-pushed list updates, and resumable sessions are deferred. **Three auth modes only:** OAuth (PKCE + RFC 7591 DCR), Bearer, None. **No auto-reconnect.** Connect/disconnect is user-driven. A failed connection sets `connectionState: "error"` and stops; the UI surfaces a reconnect button. ## Design principles - **One entry point.** `McpManagerResource` — mount via `useAui({ mcp: McpManagerResource(...) })`. No provider wrapper, no imperative hooks. - **Tap-first.** Connection lifecycle, tool lists, and tool registration are tap state. Components read via `useAuiState`; methods called via `aui.mcp().x()` in callbacks (never during render). - **One source of truth.** Persisted state goes through `MCPStorage` (a tap resource). `McpLocalStorage` is the default; swap by passing a different resource. - **Unstyled primitives.** `data-*` attributes for styling, no CSS, no business logic — matches `SpanPrimitive`. - **Token refresh is internal.** Refresh runs inside the OAuth strategy on 401. A failed refresh transitions to `authRequired`. ## Package layout ``` packages/react-mcp/ ├── src/ │ ├── mcp-scope.ts ScopeRegistry augmentation │ ├── connector.ts defineConnector() │ ├── resources/ │ │ ├── McpManagerResource.ts root; auto-mounts modelContext │ │ ├── McpServerResource.ts per-server │ │ └── storage/ │ │ ├── McpLocalStorage.ts │ │ ├── McpMemoryStorage.ts │ │ └── McpCustomStorage.ts │ ├── auth/ │ │ ├── types.ts MCPAuthConfig, MCPPersistedAuthState │ │ ├── createOAuthProvider.ts OAuthClientProvider implementation │ │ └── buildHeaders.ts for bearer / none │ ├── context/ │ │ └── McpServerByIdProvider.tsx scopes a subtree to one server │ ├── primitives/ │ │ ├── manager.ts barrel (McpManagerPrimitive.*) │ │ ├── manager/{Root,Connectors,CustomServers,AddCustomTrigger}.tsx │ │ ├── server.ts barrel (McpServerPrimitive.*) │ │ ├── server/{Root,Icon,Name,Status,Error,ConnectButton,DisconnectButton,RemoveButton,OAuthLink,Tools,ToolName}.tsx │ │ ├── addForm.ts barrel (McpAddFormPrimitive.*) │ │ └── addForm/{Root,NameField,UrlField,AuthSelect,AuthFields,Submit,Cancel,Error}.tsx │ ├── hooks/ │ │ └── useMcpOAuthCallback.tsx │ └── index.ts └── SPEC.md ``` ## Public API surface After the v0.1 simplification, the package's runtime surface is: | Export | Purpose | | --- | --- | | `McpManagerResource` | Root tap resource — mount via `useAui({ mcp: McpManagerResource({...}) })`. Auto-registers connected tools with `modelContext`. | | `McpServerResource` | Per-server resource (advanced — used internally by `McpManagerResource`) | | `McpLocalStorage`, `McpMemoryStorage`, `McpCustomStorage` | Storage resource factories | | `defineConnector` | Identity-typed helper for `MCPConnector` objects | | `McpManagerPrimitive.*`, `McpServerPrimitive.*`, `McpAddFormPrimitive.*` | Unstyled UI primitives | | `McpServerByIdProvider` | Scope a subtree to one server (used by iteration primitives; useful standalone) | | `useMcpOAuthCallback`, `McpOAuthCallback` | OAuth callback page handlers | There is no `MCPProvider` (mount the resource directly with `useAui`), no `useMcpManager` (`useAui().mcp()` in callbacks per the [tap skill](/.claude/skills/tap/SKILL.md)), no `useMcpTools` (auto-registered via `modelContext`), no `canAddCustom` (hide the add-UI to disable), no `mcpRuntimeToolsToAiSdkTools` (the runtime sees tools through `modelContext`). ## 1. Types ### 1.1 Connector ```ts type MCPConnector = { id: string; name: string; url: string; icon?: string; auth: MCPAuthConfig; connectionTimeout?: number; }; defineConnector(c: MCPConnector): MCPConnector; ``` ### 1.2 Custom server record (persisted) ```ts type MCPCustomServerRecord = { id: string; name: string; url: string; auth: MCPAuthConfig; connectionTimeout?: number; createdAt: number; }; ``` ### 1.3 Live state ```ts type MCPServerKind = "connector" | "custom"; type MCPConnectionState = | "disconnected" | "authRequired" | "authPending" | "connecting" | "connected" | "error"; type MCPToolInfo = { name: string; description?: string; inputSchema: unknown }; type MCPServerState = { id: string; kind: MCPServerKind; name: string; url: string; icon?: string; connectionState: MCPConnectionState; lastError: { message: string } | null; tools: MCPToolInfo[]; authorizationUrl: string | null; }; type MCPManagerState = { servers: MCPServerState[]; connectors: MCPServerState[]; customServers: MCPServerState[]; isHydrated: boolean; }; ``` ### 1.4 Scope registration ```ts declare module "@assistant-ui/store" { interface ScopeRegistry { mcp: { methods: MCPManagerMethods }; mcpServer: { methods: MCPServerMethods; meta: { source: "mcp"; query: { id: string } }; }; } } type MCPManagerMethods = { getState: () => MCPManagerState; server: (lookup: { id: string }) => MCPServerMethods; addCustomServer: (input: { name: string; url: string; auth: MCPAuthConfig; connectionTimeout?: number }) => Promise; removeServer: (id: string) => Promise; }; type MCPServerMethods = { getState: () => MCPServerState; connect: () => Promise; disconnect: () => Promise; remove: () => Promise; callTool: (name: string, args: unknown) => Promise; readResource: (uri: string) => Promise; completeAuth: (callbackUrl: string) => Promise; }; ``` `McpManagerResource` calls `attachTransformScopes` to **auto-mount the `modelContext` scope** when no ancestor provides it. When a chat runtime adapter (which mounts `modelContext` itself) is present, `react-mcp` registers its tools into the existing scope; otherwise it brings one along. ## 2. Storage ```ts type MCPStorage = { loadCustomServers: () => Promise; saveCustomServers: (records: MCPCustomServerRecord[]) => Promise; loadAuthState: (serverId: string) => Promise; saveAuthState: (serverId: string, state: MCPPersistedAuthState) => Promise; clearAuthState: (serverId: string) => Promise; }; type MCPPersistedAuthState = { tokens?: OAuthTokens; clientInformation?: OAuthClientInformationFull; codeVerifier?: string; token?: string; // bearer }; McpLocalStorage(opts?: { keyPrefix?: string; storage?: Storage }): ResourceElement; McpMemoryStorage(): ResourceElement; McpCustomStorage(impl: MCPStorage): ResourceElement; ``` `McpLocalStorage` defaults to `globalThis.localStorage` under the `aui-mcp:` prefix. Tokens are plain text — production apps should use `McpCustomStorage` against a server endpoint. ## 3. Mounting ```tsx // One line — no provider component. function App() { const aui = useAui({ mcp: McpManagerResource({ connectors: [ defineConnector({ id: "linear", name: "Linear", url: "https://mcp.linear.app", auth: { type: "oauth", scopes: ["read"] }, }), ], // optional: // storage: McpCustomStorage({ ... }), // autoConnect: false, // connectionTimeout: 10_000, // oauthRedirectUri: "https://app.example.com/mcp/callback", }), }); return ; } ``` Defaults baked in: - `storage` → `McpLocalStorage()` - `oauthRedirectUri` → `${window.location.origin}/mcp/callback` - `autoConnect` → `true` - `connectionTimeout` → optional timeout in milliseconds; disabled by default. Set it on the manager as a default or on a server entry to bound the MCP readiness flow (`connect()` plus `listTools()`). ## 4. Auth ```ts type MCPAuthConfig = | { type: "none" } | { type: "bearer"; token?: string } | { type: "oauth"; scopes?: string[]; authorizationEndpoint?: string; tokenEndpoint?: string; registrationEndpoint?: string; clientId?: string; clientSecret?: string; }; ``` ### OAuth (PKCE + DCR) The OAuth strategy implements the MCP SDK's `OAuthClientProvider`. The SDK handles discovery, DCR, PKCE, token exchange, and refresh; this provider only mediates `MCPStorage` reads/writes and the redirect step. The server id is embedded in the OAuth `state` parameter so a single `/mcp/callback` route routes back to the right server without app-level wiring. Flow: 1. `aui.mcp().server({ id }).connect()` → SDK starts auth. 2. `redirectToAuthorization` stores `authorizationUrl` on server state, transitions to `authRequired`. **The package does not auto-navigate** — render `` (an anchor) or open a popup. 3. User returns to `oauthRedirectUri`. Mount `` there. 4. Callback reads `?state=&code=`, derives the server id, calls `server.completeAuth(window.location.href)`. 5. Server transitions to `connecting → connected`. Refresh tokens are rotated automatically; a failed refresh moves to `authRequired`. ## 5. Primitives Same conventions as `SpanPrimitive`: `forwardRef`, Radix `Primitive.`, namespaced `Element`/`Props`, `data-*` rendering. ```tsx import { McpManagerPrimitive, McpServerPrimitive, McpAddFormPrimitive } from "@assistant-ui/react-mcp"; {/* same shape; RemoveButton is visible here */} ``` `Connectors` / `CustomServers` wrap each iteration in `McpServerByIdProvider`. Each interactive primitive renders only when the relevant state matches (`ConnectButton` only when `state ∈ {disconnected, error, authRequired}`, etc.). The add form owns its own draft state and submits via `aui.mcp().addCustomServer(...)`: ```tsx closeDialog()}> ``` ## 6. Lifecycle ``` mount McpManagerResource → mount storage; await loadCustomServers() → for each (connector | custom): mount McpServerResource (disconnected) → if autoConnect && usable auth: server.connect() → on connect: state = connecting → connected; listTools() → toolkit memo recomputes; modelContext.register(toolkit) (re-registers on change) ``` `McpServerResource.connect()`: 1. `state = "connecting"`. 2. Build transport with the appropriate auth provider. 3. `client.connect(transport)` — on `UnauthorizedError` set `authorizationUrl` and transition to `"authRequired"`; on other errors set `lastError` and transition to `"error"`. 4. On success: `listTools()`, transition to `"connected"`. `completeAuth(url)`: parse `code`, call `transport.finishAuth(code)`, retry `client.connect()`. ## 7. OAuth callback ```tsx useMcpOAuthCallback(opts?): { status; serverId; error }; void onError?: (err: Error) => void > {(result) => /* optional render */} ; ``` Reads `window.location` (override with `url` prop), extracts `state`/`code`, resolves the server, runs `completeAuth`. Pure client-side — mount under `"use client"`. ## 8. Tool integration Connected tools auto-register as **frontend tools** in `modelContext`. With names prefixed `serverId__toolName` (collisions across servers). When a chat runtime is mounted (anywhere in the tree), the model sees them and can call them — no manual wiring. For manual invocation outside a chat, use the standard tap pattern: ```ts const aui = useAui(); // inside an event handler: const out = await aui.mcp().server({ id: "linear" }).callTool("search", { q: "..." }); ``` ## 9. Errors Errors surface as rejected promises on the manager/server methods. Tool failures populate `lastError` on the server state and transition `connectionState` to `"error"`. The `McpServerPrimitive.Error` primitive renders `lastError.message`. ## 10. SSR - `McpLocalStorage` no-ops on the server; `isHydrated` flips on mount. - `McpOAuthCallback` requires the browser — must render under `"use client"`. ## 11. End-to-end example ```tsx // app/providers.tsx "use client"; import { AuiProvider, useAui } from "@assistant-ui/store"; 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({ mcp: McpManagerResource({ connectors }) }); return {children}; } ``` ```tsx // app/mcp/page.tsx — connector list with built-in primitives "use client"; import { McpManagerPrimitive, McpServerPrimitive } from "@assistant-ui/react-mcp"; export default function McpPage() { return (

Connectors

Connect Authorize ↗ Disconnect
); } ``` ```tsx // app/mcp/callback/page.tsx "use client"; import { McpOAuthCallback } from "@assistant-ui/react-mcp"; import { useRouter } from "next/navigation"; export default function Callback() { const router = useRouter(); return router.replace("/mcp")} />; } ``` ```tsx // app/chat/page.tsx — chat runtime sees MCP tools through modelContext // (no useMcpTools / no adapter call — the manager registers them itself) "use client"; import { useChatRuntime } from "@assistant-ui/react-ai-sdk"; export function Chat() { const runtime = useChatRuntime({ api: "/api/chat" }); /* … */ } ``` ## 12. Deferred / non-goals - Resources, prompts, sampling, server-pushed tool list updates - Auto-reconnect (manual reconnect only) - Tool enable/disable persistence - Per-tool consent UI - API-key / custom-headers / custom-strategy auth - Default styling (apps theme via `data-*`; shadcn wrappers belong in `@assistant-ui/ui`) - Storage encryption out of the box (escape hatch is `McpCustomStorage` against an app-controlled backend) - Automatic `MCPAppRenderer` wiring — apps mount `Tools({ mcpApp: MCPAppRenderer({ ... }) })` themselves when they want widget rendering