Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

435 lines
16 KiB
Markdown

# @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 `<McpAddFormPrimitive.*>`. 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<string>;
removeServer: (id: string) => Promise<void>;
};
type MCPServerMethods = {
getState: () => MCPServerState;
connect: () => Promise<void>;
disconnect: () => Promise<void>;
remove: () => Promise<void>;
callTool: (name: string, args: unknown) => Promise<unknown>;
readResource: (uri: string) => Promise<unknown>;
completeAuth: (callbackUrl: string) => Promise<void>;
};
```
`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<MCPCustomServerRecord[]>;
saveCustomServers: (records: MCPCustomServerRecord[]) => Promise<void>;
loadAuthState: (serverId: string) => Promise<MCPPersistedAuthState | null>;
saveAuthState: (serverId: string, state: MCPPersistedAuthState) => Promise<void>;
clearAuthState: (serverId: string) => Promise<void>;
};
type MCPPersistedAuthState = {
tokens?: OAuthTokens;
clientInformation?: OAuthClientInformationFull;
codeVerifier?: string;
token?: string; // bearer
};
McpLocalStorage(opts?: { keyPrefix?: string; storage?: Storage }): ResourceElement<MCPStorage>;
McpMemoryStorage(): ResourceElement<MCPStorage>;
McpCustomStorage(impl: MCPStorage): ResourceElement<MCPStorage>;
```
`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 <AuiProvider value={aui}><Page /></AuiProvider>;
}
```
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 `<McpServerPrimitive.OAuthLink>` (an anchor) or open a popup.
3. User returns to `oauthRedirectUri`. Mount `<McpOAuthCallback />` 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.<tag>`, namespaced `Element`/`Props`, `data-*` rendering.
```tsx
import { McpManagerPrimitive, McpServerPrimitive, McpAddFormPrimitive } from "@assistant-ui/react-mcp";
<McpManagerPrimitive.Root>
<McpManagerPrimitive.Connectors>
<McpServerPrimitive.Root>
<McpServerPrimitive.Icon />
<McpServerPrimitive.Name />
<McpServerPrimitive.Status />
<McpServerPrimitive.ConnectButton />
<McpServerPrimitive.DisconnectButton />
<McpServerPrimitive.OAuthLink />
<McpServerPrimitive.Error />
</McpServerPrimitive.Root>
</McpManagerPrimitive.Connectors>
<McpManagerPrimitive.CustomServers>
{/* same shape; RemoveButton is visible here */}
</McpManagerPrimitive.CustomServers>
<McpManagerPrimitive.AddCustomTrigger />
</McpManagerPrimitive.Root>
```
`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
<McpAddFormPrimitive.Root onSubmitted={(id) => closeDialog()}>
<McpAddFormPrimitive.NameField />
<McpAddFormPrimitive.UrlField />
<McpAddFormPrimitive.AuthSelect />
<McpAddFormPrimitive.AuthFields />
<McpAddFormPrimitive.Error />
<McpAddFormPrimitive.Submit />
<McpAddFormPrimitive.Cancel />
</McpAddFormPrimitive.Root>
```
## 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 };
<McpOAuthCallback
url?
onComplete?: (serverId: string) => void
onError?: (err: Error) => void
>
{(result) => /* optional render */}
</McpOAuthCallback>;
```
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 <AuiProvider value={aui}>{children}</AuiProvider>;
}
```
```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 (
<McpManagerPrimitive.Root>
<h2>Connectors</h2>
<McpManagerPrimitive.Connectors>
<McpServerPrimitive.Root>
<McpServerPrimitive.Name />
<McpServerPrimitive.Status />
<McpServerPrimitive.ConnectButton>Connect</McpServerPrimitive.ConnectButton>
<McpServerPrimitive.OAuthLink>Authorize </McpServerPrimitive.OAuthLink>
<McpServerPrimitive.DisconnectButton>Disconnect</McpServerPrimitive.DisconnectButton>
<McpServerPrimitive.Error />
</McpServerPrimitive.Root>
</McpManagerPrimitive.Connectors>
</McpManagerPrimitive.Root>
);
}
```
```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 <McpOAuthCallback onComplete={() => 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