WebMCP provider

Expose your app's frontend tools to a WebMCP-capable browser with unstable_useWebMcpProvider.

WebMCP is a browser-native way for a page to publish tools to the agent the user is running, through document.modelContext (with navigator.modelContext as a fallback). A page calls registerTool and the agent can call it.

By default the explainer exposes a registered tool to the page itself, to same-origin documents in the same frame tree, and to the browser's built-in agent. Reaching an author-provided agent in another origin needs the exposedTo option, which this hook does not set, so treat the audience as the built-in agent unless that changes.

unstable_useWebMcpProvider connects the two directions you already have. Every frontend tool registered in the assistant-ui model context is published to the browser's WebMCP host, and the results come back through the same Tool contract your chat runtime uses. You define a tool once; both your assistant and the user's browser agent can call it.

Warning

This hook is unstable_. WebMCP itself is an emerging browser API and the shape of this hook will change with it. It is exempt from the usual append-only surface guarantee.

Usage

Mount it once, anywhere inside your AuiProvider:

app/page.tsx
import { unstable_useWebMcpProvider } from "@assistant-ui/react";

const WebMcpTools = () => {
  const { status, registeredToolNames } = unstable_useWebMcpProvider();

  if (status === "unsupported") return null;
  return <span>Exposed to your browser: {registeredToolNames.join(", ")}</span>;
};

status is "unsupported" when the page has no modelContext — every browser without WebMCP today — and "active" once the provider is running. The check runs when the hook mounts and is not repeated: WebMCP defines no availability event, and the provider does not poll, so an extension that injects modelContext into an already-rendered page is only picked up on the next mount. registeredToolNames is the sorted list of names the provider is publishing; a name that the host refuses (because the page already registered it, or because the page's tools permission is off) drops out of the list once the refusal arrives. A name appears for the commit in which its registration is set up, so treat the list as what the provider intends to have live rather than a synchronous read of the host.

With no filter, the provider publishes every enabled frontend tool that has an execute; backend tools, disabled tools, and tools with no client-side implementation are skipped. A tool authored without a type counts as frontend when it has an execute, since that is what separates the deprecated type-less form from a backend or human tool. A filter you pass replaces that default rather than narrowing it — see below.

Choosing which tools to publish

Pass a filter to choose which tools to publish. It runs for every tool on every model-context change, and changing the function re-syncs the registrations. The inline arrow below is a new function on every render, which is fine: each tool's converted schema is cached against the tool object, so a re-sync that changes nothing costs no schema conversion for tools registered with useAssistantTool or a toolkit. Tools added through ModelContextRegistry.addTool are rebuilt on every read, so they miss that cache; the re-sync still registers nothing new, it just re-converts.

Your filter replaces the default predicate, it does not run after it. A name-only filter like PUBLIC_TOOLS.has(name) will therefore publish a backend or disabled tool whose name is in the set. To narrow the default instead of replacing it, compose against the exported predicate:

import {
  unstable_useWebMcpProvider,
  unstable_defaultWebMcpFilter,
} from "@assistant-ui/react";

const PUBLIC_TOOLS = new Set(["search_docs", "get_order_status"]);

unstable_useWebMcpProvider({
  filter: (name, tool) =>
    unstable_defaultWebMcpFilter(name, tool) && PUBLIC_TOOLS.has(name),
});

Replacement is deliberate: it is also the only way to widen the set — publishing a tool the default would skip — which an unstable_ hook should not take away from you.

A tool the browser agent can call is a tool anyone driving that browser can call, without your assistant's system prompt in the loop. Publish the tools you would be comfortable exposing as an unauthenticated API for the current session, and keep destructive ones out.

Lifecycle

The provider owns its registrations:

  • A tool that leaves the model context is unregistered.
  • Changing a tool's description, or replacing the tool object with a different parameters schema, re-registers it under the new signature; changing only its execute implementation does not, because the live registration calls through to the latest version. A provider that keeps one tool object and edits the schema inside it is not observed: detecting that means reading parameters on every sync, which costs a schema conversion for every tool on every model-context change.
  • Unmounting unregisters everything.
  • A registration the host rejects is dropped with a console.warn, and its name is left alone — the provider never unregisters a tool it does not own. That name is then not retried while the tool stays in the model context, so a permanent collision warns once rather than on every sync. The trade-off is that a transient collision is not retried either: if the page later frees the name, the provider does not notice. Removing the tool from the model context and adding it back retries it, as does remounting.

Calls are executed through the same path as a frontend tool call: Standard Schema parameters are validated first (including experimental_onSchemaValidationError), toModelOutput shapes the result if you define it, and the host's AbortSignal is merged with the registration's own lifetime, so an unregistered tool cancels in-flight work. human() is not available — a WebMCP caller has no assistant-ui composer to answer with.

API

type Unstable_WebMcpProviderOptions = {
  filter?: (name: string, tool: Tool<any, any>) => boolean;
};

type Unstable_WebMcpProviderResult = {
  status: "unsupported" | "active";
  registeredToolNames: readonly string[];
};