Browse the blog

WebMCP on frontmcp.dev: A FrontMCP Server in the Docs Page

  • webmcp
  • frontmcp
  • mcp
  • browser-agents
  • chrome

Dated article; use the maintained reference for current instructions. Example version: FrontMCP 1.10.11. Technical checks recorded: 2026-10-26. How checks work.

An agent working in someone's browser reads a docs site the way a person skims one: it takes in the page, finds a link, follows it and reads again. It can't use the site's search, can't run the examples, and only knows which release the docs describe if that's on the screen.

WebMCP gives a page a better option. WebMCP is a browser API, document.modelContext, through which a web page offers tools to the agents in the user's browser. Each tool has a name, a description and an input schema, like an MCP tool, and runs in the page. frontmcp.dev now offers 13: six for reading the docs and seven for running code in the page's Playgrounds. They come from a FrontMCP server that runs in the page, offered by FrontMCP's WebMCP plugin.

This post covers why a website would offer tools at all and what frontmcp.dev's tools do. It also covers how they stay out of the way of readers without an agent, and what we learned shipping them. The measurements are from Chrome for Testing 153 against the live site on October 26, 2026.

Why a website would offer tools to agents

Without WebMCP, an agent in the browser works from what it sees. It takes a screenshot or reads the page, finds the control it needs, acts, and looks again. Every step depends on the page's layout, and every step is another turn for the model. A WebMCP tool replaces that loop with a function call: the agent sends input that matches a schema, and gets data back.

That's useful to a website in more ways than speed:

  • The agent asks for what it needs. Searching the docs is one call to search_docs, not a click, some typing and another screenshot. A redesign doesn't break it, because the tool doesn't depend on where the search box is.
  • The page already knows the user. A tool runs in the page, so it can use what the page has: the signed-in user, the open record, what's on the screen. There's no separate server to deploy and no separate sign-in. With FrontMCP, authContext makes tools act as the page's user.
  • The person sees what happens. Tools act in the page the person is looking at. So a site can stop a tool short of the final step: fill in a reply for the person to send, or add to a cart and let them pay.
  • The site sets the limits. Agents get the tools the site offers and nothing else. Their input is checked against each tool's schema, and hints say which tools only read.

The help desk is the example on the plugin's reference page, and the store is hypothetical. The rest of this post is about the docs site, which is running now.

What an agent can do on the site

ToolWhat it does
search_docsSearches the docs with the index the header's search box uses, and returns titles, paths with section anchors, and snippets
get_pageReturns a page as Markdown, from its .md copy
list_pagesReturns the sidebar's sections and pages, and the blog's posts
get_versionReturns the FrontMCP release the docs cover, the MCP revision its examples speak, and the latest release notes
get_current_pageReturns the open page's path, title and section headings, with their anchors
navigateOpens one of the site's pages in the tab, as a link does, and scrolls to a section
run_in_playgroundCompiles and runs FrontMCP code, and returns the compile errors, or the server's tools, resources and prompts, the response to a request and the test results
list_playgrounds, get_playgroundRead the page's Playgrounds, with the reader's edits
edit_playground, call_playground, run_playground_testsAct on one of the page's Playgrounds as the reader would: edit its code, send a request from its Call tab, run its tests
open_playgroundOpens code in the full Playground page and returns its link

So an agent helping someone with FrontMCP can check which release the docs describe, find the page that answers the question, and run the code it's about to suggest on the real FrontMCP runtime first.

This is what that looks like from the browser's side, in the console of a docs page:

const tools = await document.modelContext.getTools();
const search = tools.find((tool) => tool.name === "search_docs");
await document.modelContext.executeTool(search, JSON.stringify({ query: "webmcp", limit: 3 }));

Chrome hands the result back as a JSON string:

{"query":"webmcp","results":[{"title":"WebMCP","path":"/reference/plugins/webmcp","section":"reference","snippet":"Expose a browser-based FrontMCP server's tools through document.modelContext for compatible browser agents."}, …]}

We also sent run_in_playground one file, a tool that adds two numbers, with a request for it:

{
  "files": [{ "name": "tools.ts", "code": "import { Tool, ToolContext, z } from \"@frontmcp/sdk\"; …" }],
  "call": { "method": "tools/call", "params": { "name": "add", "arguments": { "a": 2, "b": 3 } } }
}

The answer came back in 1.9 seconds, with the server's catalog and the response, "structuredContent":{"sum":5}. Most of that time was the first run loading FrontMCP's runtime and esbuild, which compiles the code, into a Web Worker.

The tools that act on a Playground do it where the reader can see it. edit_playground opens the Playground's section if it's collapsed and scrolls it into view. It puts the new code in the editor as a reader's edit, so Reset brings back the example. A request an agent sends shows in the Call tab, and a test run shows in the Tests tab.

The server in the page

The tools are ordinary FrontMCP tools. This is get_current_page, as the site has it:

export const getCurrentPage = tool({
  name: "get_current_page",
  title: "The page the user is on",
  description:
    "Returns the frontmcp.dev page open in this tab: its path, title, and the section headings on it, each with the anchor that links to it. Use it to know what the user is reading before you answer, or to point them to a section.",
  inputSchema: {},
  annotations: { readOnlyHint: true },
})(async () => ({
  path: sitePath(location.pathname).path,
  title: document.querySelector("main h1")?.textContent?.trim() || document.title,
  headings: Array.from(document.querySelectorAll<HTMLElement>("main :is(h2, h3)[id]"), (heading) => ({
    level: Number(heading.tagName[1]),
    text: heading.textContent?.trim() ?? "",
    anchor: `#${heading.id}`,
  })),
}));

The server is FrontMCP's create(), with the plugin. The site's version also wraps modelContext to count calls, which comes up below:

import { create } from "@frontmcp/sdk";
import { WebMcpPlugin, type ModelContext } from "@frontmcp/plugin-webmcp";

export function startSiteServer(modelContext: ModelContext) {
  return create({
    info: { name: "frontmcp.dev", title: "FrontMCP docs", version: SDK_VERSION },
    tools: [...docsTools, ...playgroundTools],
    plugins: [WebMcpPlugin.init({ modelContext })],
    tasks: { enabled: false },
  });
}

create() builds the server in the page, with no transport. The plugin lists the server's tools and registers each one with document.modelContext.registerTool(). An agent's call runs through the server's own call flow, as an MCP client's would. The arguments are checked against the tool's schema, and hooks and limits apply.

Tool annotations become WebMCP hints:

MCP annotationWebMCP hint
readOnlyHintreadOnlyHint
destructiveHintconsequentialHint
openWorldHintuntrustedContentHint

The four tools whose results include what code produced (run_in_playground, edit_playground, call_playground and run_playground_tests) carry untrustedContentHint.

Nothing extra for readers without an agent

Most readers have no agent in the page, and most browsers have no WebMCP. An earlier version loaded FrontMCP as soon as the page loaded, whenever document.modelContext was there. That turned out to include Lighthouse: the Chrome for Testing build it launches turns WebMCP on through its built-in field-trial configuration. Every Lighthouse run evaluated 2.7 MB of script after the page loaded. That was one 250 ms task at mobile CPU speed, which Lighthouse counted as about 360 ms of Total Blocking Time.

Now the page works in three steps:

StepWhenWhat the page loads
CheckOn every page, in the main bundleNothing more. A few lines check for document.modelContext.registerTool and stop when it's missing
RegisterAfter the load event, once the browser is idleOne file, 5.4 KB compressed: the 13 tools' names, descriptions, schemas and hints, recorded at build time
StartOn an agent's first callFrontMCP's browser build, the plugin and the tools: 96 files, about 540 KB compressed

The first step is the whole loader:

export function loadWebMcp() {
  const modelContext = (document as Document & { modelContext?: ModelContext }).modelContext;
  if (typeof modelContext?.registerTool !== "function") return;
  const register = async () => (await import("./register")).registerTools(modelContext);
  const whenIdle = () => ("requestIdleCallback" in window ? requestIdleCallback(register, { timeout: 500 }) : setTimeout(register, 200));
  if (document.readyState === "complete") whenIdle();
  else window.addEventListener("load", whenIdle, { once: true });
}

The second step uses two functions the plugin has for this. At build time, listWebMcpTools() starts the server with a stand-in modelContext, records what the plugin registers, and disposes of the server. The site writes the result to tools.json, and its checks fail when that file is out of date. In the page, registerWebMcpTools() registers those descriptions, and loads the server when an agent first calls a tool:

import { registerWebMcpTools, type ModelContext } from "@frontmcp/plugin-webmcp/register";
import tools from "./tools.json";

export function registerTools(modelContext: ModelContext) {
  return registerWebMcpTools(modelContext, tools, async (handoff) => (await loadSiteServerModule())(handoff));
}

@frontmcp/plugin-webmcp/register doesn't import the SDK, so a page with WebMCP loads that one small file until an agent calls a tool. First calls that arrive together share one load. Once the server is up, the registered tools stay registered, and each call goes through the server.

On the live site, an agent's first call to get_version took 311 ms, loading included, with no long task. With the CPU slowed four times, as Lighthouse does for mobile, it took 352 ms, with two long tasks of 67 and 88 ms. Later calls took 1 to 4 ms. The first search_docs also downloads the 77 KB search index. These are single runs on an Apple-silicon laptop over a broadband connection.

A bundle of its own

Imported the usual way, the server's code didn't stay apart from the site's. Rolldown, the bundler under Vite, moved modules the tools share with the site, like the header's search and Zod, into new shared chunks. Every page loaded one or two more files.

The fix was to import the server's entry as a worker:

// A worker build only to get a bundle of its own: FrontMCP shares no chunk with the site. It runs on the page.
import siteServer from "./start?worker&url";

Vite builds a ?worker import as a separate bundle that shares no chunk with the page's code, and &url gives its URL instead of a Worker constructor. The page loads that URL with import(), so the code runs on the page, not in a worker. A worker build keeps no exports, so the module announces its start function with an event on window:

window.dispatchEvent(new CustomEvent<StartSiteServer>(SERVER_LOADED, { detail: startSiteServer }));

Code an agent runs

run_in_playground and edit_playground run code an agent wrote. It runs where a reader's edits run: in a Web Worker on frontmcp.dev's origin, with no access to the page's DOM. run_in_playground gets a worker of its own for each run, which is terminated afterwards, and a 60-second limit. Like a reader's code, it can fetch() anything CORS allows and use the origin's IndexedDB and Cache Storage. The site keeps nothing of the reader's there.

What the code does, compile errors and failing tests included, comes back as a result. A call fails only when the agent asked for something the site can't do, like reading a page on another site. The error then says what to do instead. That matters more than it sounds, as the next section shows.

What we learned

Chrome doesn't pass a tool's error message to the agent

When a tool throws, Chrome 153 gives the agent UnknownError: Tool was executed but the invocation failed. For example, the script function threw an error, and writes the message to the page's console:

WebMCP tool execution failed: Uncaught Error: https://example.com/ isn't on frontmcp.dev. Give a path on this site, like /learn/your-first-tool.

An agent that reads only the tool's result never sees "Give a path on this site". If a tool has something the agent needs to know, it belongs in the result.

Not every hint arrives

edit_playground replaces the reader's code, so it's registered with consequentialHint as well. Chrome 153's getTools() returns readOnlyHint and untrustedContentHint, but not consequentialHint: it lists edit_playground with untrustedContentHint only. Don't count on that hint to make an agent ask first.

Test tools don't see the page your readers see

Chrome for Testing has WebMCP on through its field-trial configuration, and Lighthouse runs it that way. Playwright turns field trials off, but on frontmcp.dev the origin-trial token turns WebMCP back on. With no flags, Chrome for Testing 153 under Playwright had document.modelContext on frontmcp.dev, and not on agentfront.dev, which serves no token. To measure what a reader without WebMCP gets, the site's Lighthouse script passes --disable-features=WebMCP.

Keep the plugin's mark

The site counts agent calls by tool, success and duration, without arguments or results (site usage and privacy). It does that by wrapping modelContext before it reaches the plugin. registerWebMcpTools() marks the modelContext it hands the server with a symbol, and the plugin looks for that mark to find its registered tools. A wrapper that doesn't copy it fails the first call with The server has no WebMcpPlugin on the modelContext it was given. Spreading the original modelContext into the wrapper keeps it.

Tools in a page have no manifest entry

frontmcp.dev lists what it offers agents in an Agentic Resource Discovery manifest, /.well-known/ard.json: the docs MCP server and the llms.txt files. Neither the spec nor Lighthouse has a media type for a page's WebMCP tools, so they aren't in it; agents find them in the page. Lighthouse 13.5's Agentic Browsing category scored /learn/your-first-tool 0.98. It listed the 13 tools and found their schemas valid. It flagged the Markdown type of the llms.txt entries, which isn't on its list, and noted one form without WebMCP annotations: the Playground's Call tab.

Adding WebMCP to your site

If your page builds a FrontMCP server with create(), install the plugin with the SDK at the same version:

npm install @frontmcp/sdk @frontmcp/plugin-webmcp

Add WebMcpPlugin.init() to the server's plugins. Without WebMCP the plugin does nothing, so the same server works in every browser. Three things to know from the start:

  • Page-only tools. availableWhen: { surface: ["webmcp"] } offers a tool only to browser agents, like one that fills in a form for the user to review. ["mcp"] keeps a tool from them.
  • The caller. Without authContext, tools see an anonymous caller, anon:webmcp. Pass the signed-in user to have tools act as them.
  • Names. prefix keeps your tools' names apart from other scripts' on the page.

For visitors, register your site for Chrome's WebMCP origin trial and serve its token; frontmcp.dev sends it as an Origin-Trial header. To try a page locally, turn on chrome://flags/#enable-webmcp-testing. The plugin's reference has the rest, including a test that runs in Node with a stand-in modelContext.

Try it on frontmcp.dev

frontmcp.dev serves Chrome's WebMCP origin-trial token, so in a Chrome version the trial covers, 149 to 162, every page has document.modelContext. Open any docs page, then run the snippet above in DevTools' console. An agent in your browser that uses WebMCP finds the tools the same way.

What's next

WebMCP is a draft. The plugin follows the draft of 2026-09-30, and the origin trial ends on March 30, 2027. Coding agents outside the browser can read the same docs from the docs MCP server. As calls are counted, we'll see which tools agents use, and that will decide what changes next. If an agent gets something wrong on the site, open an issue with what it asked for.

Report a docs issue. Site usage and privacy.