# WebMCP

> @frontmcp/plugin-webmcp offers the tools of a FrontMCP server that runs in a web page to the agents in the user's browser, through WebMCP's document.modelContext. New in FrontMCP 1.9.

Source: https://frontmcp.dev/reference/plugins/webmcp

`@frontmcp/plugin-webmcp` is for a website that runs a FrontMCP server in the page, built with [`create()`](https://frontmcp.dev/reference/sdk/create), often under [`@frontmcp/react`](https://frontmcp.dev/reference/react). [WebMCP](https://webmachinelearning.github.io/webmcp/) is a browser API, `document.modelContext`, through which a page offers tools to the agents in the user's browser: the browser's own assistant, an extension, the developer tools. The plugin registers the server's tools there, keeps them in step as tools come and go, and runs each call an agent makes through the server's own flow, on the `"webmcp"` [call surface](https://frontmcp.dev/reference/sdk/register-tool#the-webmcp-call-surface), so `availableWhen`, authorities, plugin hooks and limits apply as for any other client. New in FrontMCP 1.9. WebMCP is a draft, behind a flag or an origin trial in Chrome and Edge; the plugin follows the draft of 2026-09-30.

```ts
import { WebMcpPlugin } from "@frontmcp/plugin-webmcp";

const server = await create({ info, tools, plugins: [WebMcpPlugin.init({ prefix?, include?, exposedTo?, authContext?, modelContext? })] });
```

---

## Reference

### `WebMcpPlugin.init(options)`

Install the package, and add the plugin to the server the page creates:

```bash
npm install @frontmcp/plugin-webmcp
```

```ts help-desk.ts
import { create } from "@frontmcp/sdk";
import { WebMcpPlugin } from "@frontmcp/plugin-webmcp";
import { CloseTicket, ExportTickets, FillReplyForm, SearchTickets } from "./help-desk.tools";

export function startHelpDesk() {
  return create({
    info: { name: "help-desk", version: "1.0.0" },
    tools: [SearchTickets, CloseTicket, FillReplyForm, ExportTickets],
    plugins: [WebMcpPlugin.init({ prefix: "desk." })],
  });
}
```

[See more examples below.](#usage)

- **It exposes the whole server.** Installed on one app, it still offers every app's tools: what the `"webmcp"` surface may list, not what that app has. Install it once: a second copy registers the same names, and the browser refuses them.
- **The bare class is `init()`.** Since 1.9.4, `plugins: [WebMcpPlugin]` registers the tools with the default options, as `WebMcpPlugin.init()` does (seen in Node with a stand-in `modelContext`). Before, it started and registered nothing.
- **Options are checked by `init()`.** A `modelContext` without a `registerTool` function throws a `ZodError`: `modelContext must have a registerTool function`.
- **Without WebMCP it does nothing.** In Node, and in a browser without `document.modelContext`, the server works as before and no tool is offered. `isWebMcpSupported()` tells you which.
- It isn't part of `@frontmcp/plugins`, which is for servers on Node. It imports only `@frontmcp/sdk`, `@frontmcp/utils` and `@frontmcp/lazy-zod`.

#### Options

All optional.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `prefix` | `string` | `""` | Put before every name the page offers, like `"desk."`, to keep the page's tools apart from other scripts'. Characters WebMCP doesn't allow become `_`. |
| `include` | `(tool) => boolean` | every tool | Which listed tools to offer. `tool` is the tool as `tools/list` describes it: `name`, `title`, `description`, `inputSchema`, `annotations`. Runs after `availableWhen` and authorities. |
| `exposedTo` | `string[]` | | Other origins the tools are offered to, like the page that has yours in an iframe. Passed to WebMCP's `registerTool()`. |
| `authContext` | `DirectAuthContext`, or a function returning one | an anonymous caller, `anon:webmcp` | Who the server sees calling: `{ user, token?, sessionId?, extra? }`, as for [`create()`](https://frontmcp.dev/reference/sdk/create#who-is-calling). A function is called again for every listing and every call. [See below.](#who-the-server-sees-calling) |
| `modelContext` | `ModelContext` | `document.modelContext` | Where to register: a polyfill, or a stand-in in tests. |

### What the plugin does

1. **Lists.** Once the server is ready, it lists the server's tools through the `tools:list-tools` flow, every page of them, as a caller on the `"webmcp"` surface. `availableWhen`, authorities and list hooks decide what agents get.
2. **Registers.** It registers each listed tool with `document.modelContext.registerTool(tool, { signal, exposedTo })`, and keeps the `AbortController` behind `signal`: aborting it is how WebMCP removes a tool.
3. **Follows the server.** When the server's tools change, by [`server.registerTool()`](https://frontmcp.dev/reference/sdk/register-tool), a React [`useDynamicTool()`](https://frontmcp.dev/reference/react/hooks#usedynamictool), or anything else, it lists them again and only touches what changed: a new tool is registered, a removed one aborted, and a changed one aborted and registered again, since WebMCP has no update. Changes made together are synced once.
4. **Runs calls.** An agent's call runs the `tools:call-tool` flow on the `"webmcp"` surface, with the agent's cancel signal as the tool's `this.signal`. Plugin hooks see it like any call, and [`getCallSurface()`](https://frontmcp.dev/reference/server/environment#surfaces) returns `"webmcp"` in the tool.
5. **Cleans up.** `dispose()` on the server aborts every registration, through [`scope.onDispose()`](https://frontmcp.dev/reference/sdk/register-tool#scopeondisposecallback).

### How tools are translated

| MCP | WebMCP |
| --- | --- |
| `name` | `prefix` and the name. Characters other than `A`–`Z`, `a`–`z`, `0`–`9`, `_`, `.` and `-` become `_`, so `help-desk:search` is `help-desk_search`; cut to 128 characters; a name taken by an earlier tool gets `_2`, `_3`, … |
| `description` | `description`, else `title`, else the name: WebMCP requires one. |
| `title`, `inputSchema` | As they are. |
| `annotations.readOnlyHint: true` | `readOnlyHint: true` |
| `annotations.destructiveHint: true` | `consequentialHint: true` |
| `annotations.openWorldHint: true` | `untrustedContentHint: true` |
| other annotations | Dropped. A hint that isn't `true` isn't passed: a tool that says nothing about being destructive isn't marked consequential. |
| The result | `{ content, structuredContent? }`, without `_meta`. |
| An `isError` result | The call rejects with the result's text. |
| A failed call | The call rejects with what an MCP client would read: the message of a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/fail), `Invalid tool input` and the problems, or `Internal FrontMCP error. Please contact support with error ID: …` for anything else. |

What reaches the agent is up to the browser. Chrome 153 gives `executeTool()` the result as a JSON string, and a rejected call as `UnknownError: Tool was executed but the invocation failed. For example, the script function threw an error`, with the plugin's message in the page's console (`WebMCP tool execution failed: Uncaught Error: There's no open ticket T-9.`). Its `getTools()` reports `readOnlyHint` and `untrustedContentHint`, and no `consequentialHint`.

### Who the server sees calling

Without `authContext`, every call comes from an anonymous caller, `anon:webmcp`:

| In the tool | Without `authContext` | With `authContext: () => ({ user: { sub: "nour", roles: ["agent"] }, token })` |
| --- | --- | --- |
| `this.auth.user.sub` | `"anon:webmcp"` | `"nour"` |
| `this.auth.isAnonymous` | `true` | `false` |
| `this.auth.roles` | `[]` | `["agent"]` |
| `this.context.authInfo.token` | `undefined` | the token |
| `this.context.sessionId` | `webmcp:` and a UUID, the same for every call to this server | the same, unless `authContext` has `sessionId` |
| `this.auth.scopes` | `[]` | `[]` |

So a tool that refuses anonymous callers refuses agents too, unlike [`create()`](https://frontmcp.dev/reference/sdk/create#who-is-calling)'s own `direct` user, which counts as signed in. To let agents act as the person using the page, pass that user as `authContext`.

The plugin lists the tools again only when the server's tools change. When something else changes what the caller may see, like the user signing in or out under a function `authContext`, call `refresh()` on the bridge, from a tool, provider or hook:

```ts
import { WebMcpBridge } from "@frontmcp/plugin-webmcp";

this.get(WebMcpBridge).refresh();
```

### Exports

| Export | What it is |
| --- | --- |
| `WebMcpPlugin` | The plugin, also the default export. Use `WebMcpPlugin.init(options)`. |
| `WebMcpBridge` | The class that does the work, and its provider token: `refresh()` lists again, `whenIdle()` resolves once no sync is running, `registeredToolNames` has the WebMCP names, `start()` and `stop()`. |
| `isWebMcpSupported()` | `true` when `document.modelContext.registerTool` exists. |
| `toWebMcpToolName(name)` | The WebMCP form of a name, without the prefix and without `_2`: `toWebMcpToolName("help-desk:search tickets")` is `"help-desk_search_tickets"`. |
| `webMcpPluginOptionsSchema` | The options' Zod schema. |
| `ModelContext`, `ModelContextTool`, `ModelContextRegisterToolOptions`, … | Types for the part of WebMCP the plugin uses, as the 2026-09-30 draft has them. |

### Browsers

| Where | `document.modelContext` |
| --- | --- |
| Chrome with `chrome://flags/#enable-webmcp-testing` ("WebMCP for testing") turned on | There. `chrome://flags/#devtools-webmcp-support` adds WebMCP to DevTools. Chrome 153 has both flags. |
| Chrome for Testing 153, started with `--enable-features=WebMCP` | There. This page's examples ran so. |
| Chrome and Edge 149 to 162, for visitors | In an origin trial, FrontMCP's documentation says: a page with the trial's token has it. |
| A page that isn't a secure context, like `http://` on a network address | Not there. `localhost` is a secure context. |
| A cross-origin iframe | There, but `registerTool()` is refused unless the `<iframe>` has `allow="tools"`. |
| A page served with `Permissions-Policy: tools=()` | There, but every `registerTool()` is refused: the plugin logs `WebMCP refused tool "desk.search_tickets": Access to the feature "tools" is disallowed by permissions policy.` |
| Other browsers | Not there. FrontMCP's documentation points to `@mcp-b/global`, a polyfill that defines `document.modelContext`; it wasn't tried for this page. Pass any polyfill as `modelContext`, or load it before `create()`. |

The plugin doesn't support the older `navigator.modelContext` and `provideContext()` API.

#### Caveats

- **Tools only.** WebMCP has no resources or prompts.
- **Asking the user.** Without `elicitation: { enabled: true }` on the server, a tool that calls [`this.elicit()`](https://frontmcp.dev/reference/sdk/elicit) fails for an agent: `Elicitation is disabled on this server.` With it, the agent gets FrontMCP's fallback text, which asks it to call `sendElicitationResult`, and that tool is offered to agents like the others: an agent that calls it with the user's answer gets the tool's result.
- **The default caller is anonymous**, `anon:webmcp`. See [who the server sees calling](#who-the-server-sees-calling).
- **Everything the `"webmcp"` surface lists is offered.** Keep a tool from agents with `availableWhen: { surface: ["mcp"] }`, `include`, or authorities.
- **The draft is still changing.** The plugin follows WebMCP's draft of 2026-09-30, which Chrome 153 accepted.
- **Bundling.** esbuild bundles a page with the plugin for the browser with nothing marked external. Changed in 1.9.3: `@frontmcp/utils` imported `node:crypto` at the top of its ES module build, so esbuild stopped with `Could not resolve "node:crypto"` until `node:*` was marked external and an import map pointed `node:crypto` at an empty module.

### In the Playground

The Playground doesn't run this plugin: `@frontmcp/plugin-webmcp` isn't one of the packages its examples can import, and its page has no `document.modelContext`. The examples on this page ran with FrontMCP 1.9.2 and `@frontmcp/react` 1.9.2 in Chrome for Testing 153 with `--enable-features=WebMCP`, bundled with esbuild, and in Node with a stand-in `modelContext`. With 1.9.3, whose plugin is the same code, a page bundled by esbuild with nothing external ran in Chromium with a stand-in `modelContext`: the plugin registered the tool, and an agent's call returned its result. The one Playground below runs the help desk's tools without the plugin, as MCP clients see them, and the `"webmcp"` surface, which is part of the SDK, has more in [Tools only agents in the browser see](https://frontmcp.dev/reference/sdk/register-tool#tools-only-agents-in-the-browser-see).

---

## Usage

### Offering a page's tools to agents

A support agent's console runs the help desk's server in the page. With the plugin, an agent in the browser can search tickets and fill in the reply box for the support agent to send. `fill_reply_form` is only for agents in the page, and `export_tickets` only for MCP clients. The Playground runs these tools without the plugin, as an MCP client sees them:

```ts help-desk.tools.ts active
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";

@Tool({
  name: "search_tickets",
  title: "Search tickets",
  description: "Search the help desk's tickets by title",
  inputSchema: { query: z.string() },
  annotations: { readOnlyHint: true },
})
export class SearchTickets extends ToolContext {
  async execute({ query }: { query: string }) {
    return { tickets: [{ id: "T-1", title: "Cannot log in" }].filter((t) => t.title.toLowerCase().includes(query.toLowerCase())) };
  }
}

@Tool({ name: "close_ticket", description: "Close a ticket", inputSchema: { id: z.string() }, annotations: { destructiveHint: true } })
export class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    if (id !== "T-1") this.fail(new PublicMcpError(`There's no open ticket ${id}.`, "TICKET_NOT_FOUND"));
    return { id, status: "closed" };
  }
}

@Tool({
  name: "fill_reply_form",
  description: "Type a reply into the open ticket's reply box, for the support agent to review and send",
  inputSchema: { text: z.string() },
  availableWhen: { surface: ["webmcp"] },
})
export class FillReplyForm extends ToolContext {
  async execute({ text }: { text: string }) {
    document.querySelector<HTMLTextAreaElement>("#reply")!.value = text;
    return { filled: text.length };
  }
}

@Tool({ name: "export_tickets", description: "Export every ticket as CSV", inputSchema: {}, availableWhen: { surface: ["mcp"] } })
export class ExportTickets extends ToolContext {
  async execute() {
    return { csv: "id,title\nT-1,Cannot log in" };
  }
}
```

```ts mcp-clients.test.ts
import { test, expect } from "@frontmcp/testing";

test("MCP clients get every tool but the page's own", async ({ mcp }) => {
  expect((await mcp.tools.list()).map((t: { name: string }) => t.name)).toEqual(["close_ticket", "export_tickets", "search_tickets"]);
  expect((await mcp.tools.call("fill_reply_form", { text: "Try again now" })).text()).toBe('Tool "fill_reply_form" not found');
});

test("a failure carries the tool's public message", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-9" });
  expect(result).toBeError("TICKET_NOT_FOUND");
  expect(result.text()).toBe("There's no open ticket T-9.");
});
```

With `startHelpDesk()` from [above](#webmcpplugininitoptions), the page registers three tools: `desk.close_ticket` (with `consequentialHint`), `desk.fill_reply_form` and `desk.search_tickets` (with its title and `readOnlyHint`). An agent in the page, or DevTools' WebMCP pane, runs them through WebMCP:

```ts
const tools = await document.modelContext.getTools();
const search = tools.find((tool) => tool.name === "desk.search_tickets");
await document.modelContext.executeTool(search, JSON.stringify({ query: "log" }));
// '{"content":[{"type":"text","text":"{\"tickets\":[{\"id\":\"T-1\",\"title\":\"Cannot log in\"}]}"}],"structuredContent":{"tickets":[{"id":"T-1","title":"Cannot log in"}]}}'
```

The call ran through the server's `tools:call-tool` flow, so a plugin hook on the server saw it, and `search_tickets` got its arguments checked against its schema first. `export_tickets` isn't registered.

### Tools that come and go with the page

Tools the page adds while it runs are offered as soon as they're added, and withdrawn when they're removed. With `@frontmcp/react`, give `FrontMcpProvider` the server: a component's [`useDynamicTool()`](https://frontmcp.dev/reference/react/hooks#usedynamictool) registers a server tool, so the plugin offers it to agents while the component is mounted:

```tsx console.tsx
import { createRoot } from "react-dom/client";
import { FrontMcpProvider, useDynamicTool } from "@frontmcp/react";
import { startHelpDesk } from "./help-desk";

type Ticket = { id: string; title: string };

function OpenTicket({ ticket }: { ticket: Ticket }) {
  useDynamicTool({
    name: "get_open_ticket",
    description: "The ticket the support agent has open on screen",
    inputSchema: { type: "object", properties: {} },
    annotations: { readOnlyHint: true },
    execute: async () => ({ content: [{ type: "text" as const, text: JSON.stringify(ticket) }] }),
  });
  return <h1>{ticket.title}</h1>;
}

export async function mount(element: HTMLElement, ticket: Ticket) {
  const server = await startHelpDesk();
  createRoot(element).render(
    <FrontMcpProvider server={server}>
      <OpenTicket ticket={ticket} />
    </FrontMcpProvider>,
  );
}
```

While `OpenTicket` is mounted, `getTools()` has `desk.get_open_ticket`, and an agent's call returns the ticket; once it unmounts, the tool is gone. Without React, [`server.registerTool()`](https://frontmcp.dev/reference/sdk/register-tool) does the same: the returned function withdraws the tool from agents too.

### Calling as the signed-in user

The page knows who's signed in. Pass that user, so tools see them instead of an anonymous caller, and their authorities apply to what agents may list and call:

```ts
WebMcpPlugin.init({
  prefix: "desk.",
  authContext: () => ({ user: { sub: session.userId, roles: session.roles }, token: session.accessToken }),
});
```

The function runs for every listing and every call, so a call after the user changes is made as the new user. To change which tools agents are offered, call `this.get(WebMcpBridge).refresh()` too, [as above](#who-the-server-sees-calling).

### Offering fewer tools

`availableWhen.surface` is part of each tool. For a rule about the page instead, use `include`, which gets each listed tool:

```ts
WebMcpPlugin.init({
  prefix: "desk.",
  include: (tool) => tool.annotations?.destructiveHint !== true,
});
```

Here `desk.close_ticket` isn't offered; MCP clients can still call `close_ticket`.

### Testing without a browser

Pass a stand-in as `modelContext`, and a test in Node can check what the page offers and call it as an agent would. This one runs with `node --test` (through `tsx`, for the decorators):

```ts agent.test.ts
import "reflect-metadata";
import { test } from "node:test";
import assert from "node:assert/strict";
import { create } from "@frontmcp/sdk";
import { WebMcpPlugin, type ModelContext, type ModelContextTool } from "@frontmcp/plugin-webmcp";
import { CloseTicket, ExportTickets, FillReplyForm, SearchTickets } from "./help-desk.tools";

class StandInModelContext implements ModelContext {
  tools = new Map<string, ModelContextTool>();
  async registerTool(tool: ModelContextTool, options?: { signal?: AbortSignal }) {
    if (this.tools.has(tool.name)) throw new DOMException("Duplicate tool name", "InvalidStateError");
    this.tools.set(tool.name, tool);
    options?.signal?.addEventListener("abort", () => this.tools.delete(tool.name));
  }
  run(name: string, input: Record<string, unknown>) {
    return this.tools.get(name)!.execute(input, { signal: new AbortController().signal });
  }
}

const settle = () => new Promise((resolve) => setTimeout(resolve, 10));

test("agents get the page's tools, and calls go through the server", async () => {
  const modelContext = new StandInModelContext();
  const server = await create({
    info: { name: "help-desk", version: "1.0.0" },
    tools: [SearchTickets, CloseTicket, FillReplyForm, ExportTickets],
    plugins: [WebMcpPlugin.init({ prefix: "desk.", modelContext })],
  });
  try {
    await settle();
    assert.deepEqual([...modelContext.tools.keys()].sort(), ["desk.close_ticket", "desk.fill_reply_form", "desk.search_tickets"]);
    assert.deepEqual(modelContext.tools.get("desk.close_ticket")?.annotations, { consequentialHint: true });

    const result = await modelContext.run("desk.search_tickets", { query: "log" });
    assert.deepEqual(result, {
      content: [{ type: "text", text: '{"tickets":[{"id":"T-1","title":"Cannot log in"}]}' }],
      structuredContent: { tickets: [{ id: "T-1", title: "Cannot log in" }] },
    });
    await assert.rejects(modelContext.run("desk.close_ticket", { id: "T-9" }), { message: "There's no open ticket T-9." });
  } finally {
    await server.dispose();
  }
  assert.equal(modelContext.tools.size, 0);
});
```

The plugin registers once the server is ready, a moment after `create()` resolves, hence `settle()`. (`fill_reply_form` reads `document`, so it isn't called here.)

### Trying it in Chrome

1. Turn on `chrome://flags/#enable-webmcp-testing`, and `chrome://flags/#devtools-webmcp-support` for DevTools, then restart Chrome. In an automated test, start Chrome for Testing with `--enable-features=WebMCP`.
2. Serve the page from `localhost` or over HTTPS.
3. In the page's console, `await document.modelContext.getTools()` lists what the page offers, and `executeTool()` runs a tool [as above](#offering-a-pages-tools-to-agents). FrontMCP's documentation also points to DevTools → Application → WebMCP.

To offer the tools to visitors without the flag, FrontMCP's documentation points to Chrome's WebMCP origin trial, whose token goes in the page.

---

## Troubleshooting

### Agents see none of the page's tools

Check, in order:

1. `isWebMcpSupported()` is `true` in the page. If not, the browser has no `document.modelContext`: turn on the flag, add the origin trial's token, serve the page from a secure context, or pass a polyfill as `modelContext`.
2. The plugin is installed with `init()`: `WebMcpPlugin.init()`, not `WebMcpPlugin`.
3. The page's log has no `WebMCP refused tool` line (see below).
4. The tools are listed on the `"webmcp"` surface: their `availableWhen.surface` includes `"webmcp"` or is unset, authorities let the caller see them, and `include` returns `true`.
5. The page waited: the plugin registers once the server is ready, after `create()` resolves.

### `WebMCP refused tool "…": …`

The browser refused a registration; the plugin logs a warning once per tool and tries again when the server's tools change. `Duplicate tool name` means another script on the page has the name, or the plugin is installed twice: set a `prefix`, or install it once. `Access to the feature "tools" is disallowed by permissions policy` means the page isn't allowed WebMCP: a cross-origin `<iframe>` needs `allow="tools"`, and a `Permissions-Policy: tools=()` header turns WebMCP off.

### `Could not resolve "node:crypto"` when bundling the page

esbuild, bundling for the browser, reached the `node:crypto` import of `@frontmcp/utils` 1.9.2. Update the `@frontmcp/*` packages to 1.9.3, where `@frontmcp/utils` doesn't import it in a browser build. See [Caveats](#caveats).

### `modelContext must have a registerTool function`

`WebMcpPlugin.init()` was given a `modelContext` that isn't one, often `document.modelContext` read where it's `undefined`. Leave the option out: the plugin reads `document.modelContext` itself, when the server is ready.

### `Elicitation is disabled on this server.` when an agent calls a tool

The tool calls `this.elicit()`, and the server doesn't have `elicitation: { enabled: true }`. Turn it on, and the agent can answer through `sendElicitationResult`, or have the tool take what it asks for as arguments. See [Caveats](#caveats).

### A tool that refuses anonymous callers refuses agents

Without `authContext`, agents call as an anonymous caller, `anon:webmcp`, so `this.auth.isAnonymous` is `true`. Pass the page's user as [`authContext`](#calling-as-the-signed-in-user).

### An agent's call fails with `Invalid tool input`

The agent's arguments didn't match the tool's `inputSchema`, checked as for any client. The message lists the problems. In Chrome the agent sees `UnknownError: Tool was executed but the invocation failed…`, and the page's console has the message.
