# Introduction to WebMCP

Source: https://tpiros.dev/blog/building-with-webmcp

Point an AI agent at a website today and it has to work everything out from the outside. It screenshots the page or reads the accessibility tree, guesses which element is the real "add to basket", clicks, waits, then screenshots again to see what happened. Each round trip is slow and token-hungry, and a redesign quietly breaks the whole routine.

WebMCP is the proposal that fixes this. The page declares what it can do as tools, with names, descriptions and JSON schemas, and an in-browser agent calls those instead of poking at your UI.

This post comes with working code. I've wired WebMCP into [Watch Anchor](https://watchanchor.com), my watch collection app. Open it in Chrome with the WebMCP flag switched on (a toggle in `chrome://flags`, no special build needed) and the page registers 22 tools with the browser (6 if you're signed out): search the catalogue, filter by dial colour or calibre, manage collections and wishlists, record warranty details, keep notes. An agent can drive all of that through the tools while you watch the UI follow along.

# What WebMCP is

WebMCP is a proposed web standard, incubated in the [W3C Web Machine Learning Community Group](https://github.com/webmachinelearning/webmcp) with engineers from Microsoft and Google behind it. The tools it defines have the same shape as MCP tools, which is deliberate.

The difference from MCP proper is where the code runs. An MCP server is something you deploy and operate; agents connect to it over the network, and you get to solve auth all over again. WebMCP tools live in your page's JavaScript. The agent calls them in the tab you already have open, inside the session you're already logged into. For Watch Anchor that meant no new infrastructure: every tool is a thin wrapper over fetch calls to a set of small API routes, backed by the same service layer the rest of the app already uses.

You can try it today. Flip `chrome://flags/#enable-webmcp-testing` (Chrome 146 or newer), relaunch, and WebMCP works on any site that registers tools, Watch Anchor included. The API lives at `document.modelContext`. It's experimental and the spec is still settling, so if you build on it now, plan to revisit.

There are two ways to expose tools. The imperative API registers them from JavaScript and is meant for complex, dynamic interactions; the declarative API annotates plain HTML forms, and the browser derives the tool for you. Watch Anchor uses the imperative one throughout, so let's start there.

# The imperative API

The core of it is `registerTool`:

```javascript
const addTodoTool = {
  name: 'addTodo',
  description: 'Add a new item to the todo list',
  inputSchema: {
    type: 'object',
    properties: { text: { type: 'string' } },
    required: ['text'],
  },
  execute: async ({ text }) => {
    await persistTodo(text);
    return { content: [{ type: 'text', text: `Added todo: ${text}` }] };
  },
  annotations: { readOnlyHint: false, untrustedContentHint: true },
};

const controller = new AbortController();
document.modelContext.registerTool(addTodoTool, { signal: controller.signal });

// when the tool stops making sense for the current page state:
controller.abort();
```

The schema is standard [JSON Schema](https://json-schema.org/understanding-json-schema/reference), `execute` is a plain async function running in your page, and the return value follows the MCP content shape. Unregistration goes through the `AbortSignal`, which turns out to be a nice fit for React effects (more on that below).

The `annotations` matter more than they look. `readOnlyHint: true` tells the agent this tool doesn't change state, so it can call it without pestering the user for confirmation. `untrustedContentHint: true` flags that the output contains data you don't vouch for, like user-generated content, so the agent treats it as information rather than instructions. Both are hints to the agent's guardrails, not enforcement.

# The declarative API

If your interaction is already a form, you might not need JavaScript at all. Two attributes turn a form into a tool:

```html
<form toolname="reserve_table" tooldescription="Reserve a table at the restaurant" action="/reserve">
  <label for="date">Date of the reservation</label>
  <input type="date" id="date" name="date" required />

  <fieldset toolparamdescription="Seating area preference">
    <label><input type="radio" name="seating" value="inside" /> Inside</label>
    <label><input type="radio" name="seating" value="terrace" /> Terrace</label>
  </fieldset>

  <button type="submit">Reserve</button>
</form>
```

The browser computes the input schema from the fields: labels become parameter descriptions, `<select>` options become enums, `required` becomes required. You can override descriptions with `toolparamdescription` (on a `<fieldset>` for grouped inputs like radios).

The default behaviour is a sensible consent model: the agent fills the form in, the fields light up (there are new CSS pseudo-classes, `:tool-form-active` on the form and `:tool-submit-active` on the submit button), and the human still clicks submit. Add `toolautosubmit` if you want the agent to submit directly.

If you handle submission in JavaScript rather than letting the form navigate, there's plumbing for that too. `SubmitEvent` gains an `agentInvoked` boolean and a `respondWith()` method; call `preventDefault()` to stop the standard submission, then hand the browser a promise that resolves with the result:

```javascript
form.addEventListener('submit', (e) => {
  if (!e.agentInvoked) return;
  e.preventDefault();
  e.respondWith(handleReservation(new FormData(form)));
});
```

Whatever the promise resolves with gets serialised and handed back to the model as the tool output. There are also `toolactivated` and `toolcancel` events on `window` if you need to react to the agent starting or the user backing out.

# What I actually built on Watch Anchor

Watch Anchor is a Next.js app with Clerk for auth. All the WebMCP wiring lives in one client component, `WebMCPProvider`, mounted once in the root layout. Trimmed down, the pattern looks like this:

```tsx
'use client';

  const router = useRouter();
  const { isSignedIn } = useAuth();

  useEffect(() => {
    const modelContext = (document as Document & { modelContext?: ModelContext })
      .modelContext;
    if (!modelContext) return; // no flag, no tools, no errors

    const controller = new AbortController();
    const { signal } = controller;

    modelContext.registerTool(
      {
        name: 'searchWatches',
        description:
          'Search the watch catalog by free-text keyword across brand, model, and reference number. When given a reference number, search with the reference ALONE (no brand). If no results, retry shorter: reference only, brand only, or model only.',
        annotations: { readOnlyHint: true, untrustedContentHint: true },
        inputSchema: {
          type: 'object',
          properties: {
            query: { type: 'string', description: 'A single search term' },
            page: { type: 'number', description: 'Page number, starts at 1' },
          },
          required: ['query'],
        },
        execute: async ({ query, page }) => {
          const data = await callApi('/api/webmcp/search', { query, page });
          router.push(`/browse?query=${encodeURIComponent(query)}`);
          return compactResult(data);
        },
      },
      { signal }
    );

    // ...21 more tools

    return () => controller.abort();
  }, [isSignedIn]);

  return null;
}
```

The `AbortController` pattern from the spec maps directly onto a React effect cleanup, which is pleasingly boring. A few decisions in there deserve spelling out.

## Register tools for the state you're in

Chrome's docs say to register tools when they make sense for the page state and unregister them when they don't. In practice my state boundary is authentication, not page. Catalogue tools (search, explore, filter, watch details) are always registered. The 17 collection-management tools only exist when Clerk reports a signed-in user. And when you're signed out, a dedicated `signIn` tool appears instead, whose description tells the agent exactly when to reach for it: when the user asks for something that needs authentication (adding a watch to a collection, say) and the tools for it aren't there. It gives the agent a legitimate next step instead of a dead end.

Because `isSignedIn` is in the effect's dependency array, signing in tears the whole registration down via the abort signal and re-registers the full set, which costs almost nothing and keeps the tool list honest about what the current user can do.

## Keep the human's screen in sync

Notice the `router.push` in `searchWatches`. Nearly every read tool does two things: return structured JSON to the agent, and navigate the visible page to show the same result. This came straight from a limitation Chrome's docs call out: the human and the agent share one UI, and if the agent "searches" while the screen sits on the homepage, the human has no idea what happened. Agents also use the visible state to verify their own tool calls, so returning after the UI updates helps them too.

## Respect the output budget

Chrome's [secure tools guidance](https://developer.chrome.com/docs/ai/webmcp/secure-tools) gives concrete character budgets: about 500 characters per tool description, 150 per parameter description, 30 for names, and 1.5K per tool output. Blow past them and you risk tripping the agent's guardrails or just drowning it in tokens. My search endpoints can return pages of watch data with long photo URLs, so every list-shaped result goes through a compactor before the agent sees it:

```typescript
const MAX_OUTPUT_CHARS = 1500;

function compactResult(data: unknown, maxItems = 8): ToolResult {
  if (isWatchList(data)) {
    const watches = data.watches
      .slice(0, maxItems)
      .map(({ photo: _photo, ...rest }) => rest);
    const out: Record<string, unknown> = { ...data, watches };
    if (data.watches.length > maxItems) {
      out.truncated = true;
      out.shown = maxItems;
    }
    return { content: [{ type: 'text', text: bound(JSON.stringify(out)) }] };
  }
  return textResult(data);
}
```

That's eight items, no photo URLs, and a hard cap of 1,500 characters. The page navigation shows the full result set anyway, so the agent's copy only needs to be enough to reason with.

## Tool descriptions are prompts

The description field is where you steer the model, and mine read more like prompts than documentation. The `searchWatches` description spells out query strategy: search a reference number *alone*, without the brand, and if nothing comes back, retry shorter (reference only, brand only, or model only). The explore tool's description points to a separate `getFilterOptions` tool "to discover valid values before calling exploreWatches", because its filters only accept exact values.

That discovery-tool pattern (one data-only tool that returns the legal values for another tool's parameters, with live counts) matches Chrome's advice to validate loosely in schema and strictly in code, returning descriptive errors the model can self-correct from.

## Destructive tools get friction

Deleting a collection or removing warranty records carries `annotations: { destructiveHint: true }`. Full disclosure on that one: `destructiveHint` comes from MCP's annotation vocabulary, and the WebMCP spec itself defines only `readOnlyHint` and `untrustedContentHint`, so it's a courtesy signal an agent may ignore. The specified protections sit elsewhere. Before executing, the tool feature-detects the experimental `requestUserInteraction()` method and awaits it so the user can confirm. These tools also don't claim `readOnlyHint`, and Chrome's guidance tells agents to request confirmation for state-changing calls and keep the human in the loop. And underneath all of that, nothing changed server-side: every mutation still goes through Clerk-authenticated API routes scoped to the current user. Which brings me to security.

# The security model

An agent using WebMCP tools operates inside your authenticated session, and Chrome's [agent security guidance](https://developer.chrome.com/docs/agents/security) is refreshingly blunt about the consequences. It names two attack vectors. The first is "malicious manifests": websites may have tool definitions with hidden instructions, in tool names, parameters, or descriptions, designed to hijack the agent. The second is "contaminated outputs": tool responses from an otherwise trustworthy site might include malicious instructions as part of third-party data, such as user comments. Both are forms of indirect prompt injection, and they work because LLMs process instructions and data as the same token stream. Chrome's own docs state plainly that "there have been repeatable prompt injection attacks against agentic systems that use state-of-the-art LLMs". No model is immune, so the mitigations are layered.

Some are deterministic: limits on input tokens so a tool response can't overload the context window, restricting the agent to the origins relevant to the user's task, and keeping the human in the loop with confirmation requests. Some are probabilistic, chiefly "spotlighting": marking untrusted content so the model treats it as data rather than instructions. Wrapping it in delimiter tags like `<untrusted>` is the token-cheap version, but it's vulnerable to structural evasion if an attacker guesses the closing delimiter and injects it inside their payload. Base64-encoding the untrusted text closes that hole, at the cost of increasing its size and token consumption by roughly 33%.

On top of those, the guidance sketches a defence stack for agent builders: content classifiers such as Google Cloud's Model Armor to pre-scan tool descriptions and outputs, critic models that check a proposed tool call against what the user actually asked for, red-teaming with tools like Promptfoo, Anthropic's Bloom or Petri, and production monitoring for anomalies like token exhaustion. Most of that sits on the agent side rather than yours.

As a site author your levers are smaller but real. Annotate honestly: `readOnlyHint` only on tools that genuinely just read, `untrustedContentHint` wherever third-party data flows through. Keep outputs inside the budgets. And if your tools shouldn't be callable from other origins, say via an embedded iframe, restrict them:

```javascript
await document.modelContext.registerTool(
  {
    name: 'my_shared_tool',
    description: 'Shared across origins',
    // ...
  },
  { exposedTo: ['https://trusted.com', 'https://example.com'] }
);
```

I left Watch Anchor's tools open to any agent, deliberately: they only expose what the API routes already allow, and those routes enforce auth and per-user scoping regardless of who's calling. That's the rule I'd generalise. WebMCP is a convenience surface for agents; your actual authorisation boundary stays on the server, exactly where it was.

# Testing without an agent in the loop

The [Model Context Tool Inspector extension](https://chromewebstore.google.com/detail/model-context-tool-inspec/gbpdfapgefenggkahomfgkhfehlcenpd) covers the gap between writing a tool and having an agent call it. It lists every tool registered on the active tab with its schema, and lets you execute one by hand with typed-in arguments, which removes the model's non-determinism from the loop while you're testing your `execute` functions. Once the plumbing works, it also has a Gemini 2.5 Flash mode: type a natural-language prompt and see which tool the model picks and with what parameters. That's the honest test of your descriptions.

If you just want to poke at WebMCP without building anything, Google hosts [three demos](https://github.com/GoogleChromeLabs/webmcp-tools/tree/main/demos) covering both APIs, including a React flight-search app.

# Your markup is still the fallback

WebMCP is the fast path, and agents that land on a site without tools fall back to what they can see: screenshots for vision models, the DOM, and the accessibility tree, which web.dev's [agent UX article](https://web.dev/articles/ai-agent-site-ux) describes as "a high-fidelity map that ignores the visual 'noise' of CSS". Everything that helps those agents is 20-year-old advice: real `<button>` elements instead of clickable divs, labels actually wired to inputs with `for`, and layouts that hold still. As the article puts it, "everything we suggest to make a site 'agent-ready' also makes sites better for humans." If your semantic HTML is a mess, WebMCP tools are a bandage on it.

# Where this leaves things

The limitations are real. Tool calls run in page JavaScript, so there's no headless story; an actual tab has to be open. It's one browser, behind a flag, and the API is experimental. And discovery is explicitly unsolved: Chrome's docs admit there's no mechanism for an agent to know a site has tools without visiting it first. An agent can drive Watch Anchor beautifully once it's there, but nothing tells it Watch Anchor exists. That half of the problem deserves its own post, and I'm writing one.

But the implementation side is small. The whole Watch Anchor integration is one React component and a handful of thin API routes. Compare that with what it takes for an agent to drive the same flows through the UI, guessing at buttons from screenshots, and the trade is obvious. "Add the watch I just bought to my collection, five-year warranty from today" maps onto two tool calls (`addWatchToCollection`, then `saveWarrantyInfo`) against a real app with real auth. Behind a flag, sure. But the surface is there, and I didn't have to deploy a single new server to get it.

If you want to try it yourself, flip the flag and give your site its first tool. The spec work is happening in the open [on GitHub](https://github.com/webmachinelearning/webmcp).
