WebMCP
@frontmcp/plugin-webmcp is for a website that runs a FrontMCP server in the page, built with create(), often under @frontmcp/react. 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, 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.
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:
npm install @frontmcp/plugin-webmcpimport { 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." })],
});
}- 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, asWebMcpPlugin.init()does (seen in Node with a stand-inmodelContext). Before, it started and registered nothing. - Options are checked by
init(). AmodelContextwithout aregisterToolfunction throws aZodError: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/utilsand@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(). A function is called again for every listing and every call. See below. |
modelContext | ModelContext | document.modelContext | Where to register: a polyfill, or a stand-in in tests. |
What the plugin does
- Lists. Once the server is ready, it lists the server's tools through the
tools:list-toolsflow, every page of them, as a caller on the"webmcp"surface.availableWhen, authorities and list hooks decide what agents get. - Registers. It registers each listed tool with
document.modelContext.registerTool(tool, { signal, exposedTo }), and keeps theAbortControllerbehindsignal: aborting it is how WebMCP removes a tool. - Follows the server. When the server's tools change, by
server.registerTool(), a ReactuseDynamicTool(), 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. - Runs calls. An agent's call runs the
tools:call-toolflow on the"webmcp"surface, with the agent's cancel signal as the tool'sthis.signal. Plugin hooks see it like any call, andgetCallSurface()returns"webmcp"in the tool. - Cleans up.
dispose()on the server aborts every registration, throughscope.onDispose().
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, 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()'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:
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 callsthis.elicit()fails for an agent:Elicitation is disabled on this server.With it, the agent gets FrontMCP's fallback text, which asks it to callsendElicitationResult, 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. - Everything the
"webmcp"surface lists is offered. Keep a tool from agents withavailableWhen: { 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/utilsimportednode:cryptoat the top of its ES module build, so esbuild stopped withCould not resolve "node:crypto"untilnode:*was marked external and an import map pointednode:cryptoat 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.
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:
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" };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
With startHelpDesk() from above, 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:
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() registers a server tool, so the plugin offers it to agents while the component is mounted:
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() 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:
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.
Offering fewer tools
availableWhen.surface is part of each tool. For a rule about the page instead, use include, which gets each listed tool:
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):
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
- Turn on
chrome://flags/#enable-webmcp-testing, andchrome://flags/#devtools-webmcp-supportfor DevTools, then restart Chrome. In an automated test, start Chrome for Testing with--enable-features=WebMCP. - Serve the page from
localhostor over HTTPS. - In the page's console,
await document.modelContext.getTools()lists what the page offers, andexecuteTool()runs a tool as above. 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:
isWebMcpSupported()istruein the page. If not, the browser has nodocument.modelContext: turn on the flag, add the origin trial's token, serve the page from a secure context, or pass a polyfill asmodelContext.- The plugin is installed with
init():WebMcpPlugin.init(), notWebMcpPlugin. - The page's log has no
WebMCP refused toolline (see below). - The tools are listed on the
"webmcp"surface: theiravailableWhen.surfaceincludes"webmcp"or is unset, authorities let the caller see them, andincludereturnstrue. - 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.
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.
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.
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.