# WebMCP provider
URL: /docs/tools/webmcp

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

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

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.

> [!warn]
>
> 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`:

```
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[];
};
```