guidewebmcpai agentsseo

How to Get Your Website Ready for Google WebMCP

A step-by-step guide to exposing WebMCP tools on your site — the imperative and declarative APIs, how to test them, and the real security downsides.

WebMCP lets your page hand an AI agent a list of named, typed, callable tools — so instead of guessing which button to click, the agent reads that your site has a set_options tool taking a threshold between 1 and 254, and calls it.

It is a W3C Community Group draft, not a ratified standard. It runs in Chrome and nowhere else. Real adoption is close to zero. All of that is true, and it is still worth spending an afternoon on, for a reason that has nothing to do with betting on the spec: the work it forces you to do — enumerating what your site actually lets someone do, in typed parameters — is the same work that pays off for llms.txt, for AI search, and for your own API surface. We shipped it here, and the expensive part was not the JavaScript.

What WebMCP actually is

A page calls document.modelContext.registerTool() with a name, a description, a JSON Schema for the inputs, and a function to run. An agent in the browser — a sidebar assistant, an extension, an automation running in the user’s tab — can then discover that tool and call it directly.

The alternative it replaces is agents driving your UI: reading the accessibility tree, inferring what a slider does, clicking, waiting, re-reading the DOM to check whether it worked. That works often enough to be tempting and fails in ways you never see. A tool call has a schema, so the agent knows the valid range before it tries; it has a return value, so the agent knows whether it worked.

The critical difference from a server-side MCP server is where it runs. A remote MCP server is a separate process with its own credentials. A WebMCP tool runs inside the user’s already-open, already-authenticated tab, with the user’s session. That is the whole appeal — no new auth, no new API, no new deployment — and, as the second half of this post covers, it is also the whole risk.

Why bother before anyone else has

Three honest reasons, in descending order of how much I believe them.

Structuring the work is the value. To register tools you have to answer “what are the five things a person comes to this site to do, and what are the exact parameters of each?” Most sites cannot answer that in a form a machine could act on. Once you can, the same answers feed your llms.txt, your on-page copy, and your internal API design.

Task completion is the new ranking. If an assistant can book, buy, or configure on a competitor’s site and only read about it on yours, you lose at the point of intent. That shift is already visible with AI-search referrals; WebMCP is the actuation half of it.

Reliability, if agents do come. A typed schema with real minimums and maximums produces fewer broken flows than click-guessing. This one only matters once agents are actually calling you.

What I would not claim: that shipping this today gets you traffic. It does not. Adoption is tiny and the API is behind a trial flag.

Step 1: Get the flag and an origin trial token

For local development, open chrome://flags/#enable-webmcp-testing, set it to Enabled, and relaunch. That is enough to build against on localhost.

For a real domain you need an origin trial token, available from Chrome 149. Register your origin, get a token, and send it as a response header on every page that registers tools:

Origin-Trial: <your token>

Three things to know before you wire it up:

  • The token is per-origin, exactly. A token issued for https://www.example.com does not cover example.com. If your apex redirects to www, you are fine; if both serve pages, you need both.
  • It expires. Ours expires 2026-11-17. Put the renewal date in a comment next to the token and in a calendar, because when it lapses the feature dies silently — no error, the object is just gone.
  • It is public anyway. The token is sent in the clear on every response. There is no reason to hide it in a secret store; keep it in config with the expiry documented, and use an env var only so it can be rotated without a deploy.

WebMCP is also gated by origin isolation and a tools permissions policy, so if the object is missing on a page that should have it, check those before you suspect the token.

Step 2: Decide which tools to expose

This is the step that takes the afternoon. The JavaScript takes twenty minutes.

Expose user intents, not internal endpoints. “Find the page that does this job” is a tool. “POST /api/v2/assets/search” is not — it leaks your architecture, carries parameters that mean nothing to a model, and invites calls you never anticipated.

Our site-wide surface is five tools, and the list is short on purpose:

  • search_site — find the page that does a given job, from a plain-English description
  • list_image_tools — enumerate every tool, with its slug, category and parameters
  • open_tool — navigate the current tab to a page by slug
  • get_pricing — the free allowance, credit packs and tiers
  • answer_faq — the site’s published answer to a question about itself

Every mini-tool page then registers its own set, namespaced by slug: posterize_image__set_options, __get_state, __get_result, __download_preview, __reset. (We build those tools, so treat this section as interested rather than neutral — but the shape is the point, not the site.)

What we deliberately left out, and what you probably should too:

  • Anything destructive. Deleting, overwriting, cancelling.
  • Anything that spends money. No purchase tool, no credit-consuming call.
  • Anything returning personal data. A read-only tool that reveals user information is the same decision as publishing that information; treat it that way.
  • Anything returning bulk data. We do not return image URLs or data-URL results — a tool that hands back a megabyte of base64 is a denial-of-service on the agent’s context window, and a fine exfiltration channel.

Step 3: Register a tool with the imperative API

The whole surface is document.modelContext. Feature-detect, then register:

function getModelContext() {
  if (typeof document === "undefined") return null;
  // `document.modelContext` is current; `navigator.modelContext` is the older
  // spelling, deprecated in Chrome 150 but still live for earlier trial builds.
  const ctx = document.modelContext ?? navigator.modelContext;
  return ctx && typeof ctx.registerTool === "function" ? ctx : null;
}

const controller = new AbortController();

getModelContext()?.registerTool(
  {
    name: "posterize_image__set_options",
    description: "Set the number of colour bands on the posterize preview.",
    inputSchema: {
      type: "object",
      properties: {
        levels: {
          type: "number",
          minimum: 2,
          maximum: 16,
          description: "Colour bands. Fewer bands means a flatter, more graphic result.",
        },
      },
      required: ["levels"],
    },
    annotations: { readOnlyHint: false },
    execute: async ({ levels }, { signal }) => {
      const applied = Math.min(16, Math.max(2, Number(levels)));
      await applyPosterize(applied, { signal });
      return applied === levels
        ? `Preview updated to ${applied} colour bands.`
        : `${levels} is out of range; clamped to ${applied} colour bands.`;
    },
  },
  { signal: controller.signal },
);

Points worth pulling out:

  • inputSchema is the contract. Put the real minimum, maximum and enum in it. If your app already declares its controls as typed config, generate the schema from that config rather than hand-writing descriptors — ours derives ~90 tools’ schemas from one function, so a slider range and its schema cannot drift apart.
  • execute gets an AbortSignal. Pass it through to your fetch so a cancelled agent turn cancels the work.
  • { signal } on registration unregisters the tool. Abort the controller when the component unmounts. An agent calling a tool whose page has been navigated away from is worse than the tool never existing.
  • The return value is text a model reads. Say what changed. "Preview updated to 5 colour bands." is a better return than {ok: true}.

Step 4: Or annotate a form and ship no JavaScript at all

If you have an existing form, the declarative API is a handful of attributes:

<form toolname="supportRequestTool"
      tooldescription="Submit a request for support."
      action="/submit">
  <label for="firstName">First Name</label>
  <input type="text" name="firstName" id="firstName">

  <select name="team" required
          toolparamdescription="Determines what team this request is routed to.">
    <option value="Customer happiness team">Return my purchase.</option>
    <option value="Distribution team">Check where my package is.</option>
  </select>

  <button type="submit">Submit</button>
</form>

Chrome translates the form into a tool with a schema derived from the fields. toolparamdescription explains a field the label does not, and toolautosubmit lets the agent submit without a human pressing the button — which, for anything with a consequence, you should leave off.

This is the cheapest possible entry point: one contact form, four attributes, no build change.

Step 5: Write descriptions an agent can act on

Chrome publishes budgets, and they are tighter than people expect:

  • Tool name: 30 characters
  • Tool description: 500 characters
  • Parameter description: 150 characters
  • Tool output: about 1.5K characters

Treat these as design constraints, not limits to bump against. Some rules that follow from them:

  • Enums beat free text. {"enum": ["png", "jpg", "webp"]} removes an entire class of failed calls.
  • Describe the effect, not the implementation. “Flattens the image to N colour bands” beats “calls posterize with levels.”
  • One job per tool. A tool with a mode parameter that changes what it fundamentally does is two tools.
  • Never return an unbounded blob. Summarise, paginate, or return a count and a way to narrow.

Step 6: Test the tools in DevTools

Chrome ships a panel for this: DevTools → Application → WebMCP. It shows every registered tool with its description as the agent sees it, an invocation counter per tool, and a chronological log of calls with their inputs, outputs and errors — filterable by status and by whether the tool came from HTML or JavaScript.

The useful part is the manual runner: pick a tool, type JSON parameters, hit Run tool. You are testing your tool independently of whether some model decided to call it, which is the only way to iterate at a reasonable pace.

Once it works, pin it down with a browser test. We keep a Playwright spec that asserts the expected tools are registered on each page and that calling one produces the expected state change — the same instinct behind any repeatable workflow: if it is worth doing by hand once, it is worth asserting.

The downsides nobody puts in the announcement post

Now the part the launch posts skip. WebMCP’s security model is genuinely unresolved, and Chrome says so in its own documentation.

Indirect prompt injection is the entire threat model

An LLM reads instructions and data as the same stream of tokens. That gives two attack vectors:

Malicious tool manifests. A page can hide instructions inside a tool name, parameter or description — text the user never sees, aimed squarely at the agent. Your tool descriptions are, from the agent’s side, untrusted input.

Contaminated outputs. More relevant to you: a completely trustworthy site returns user-generated content — a review, a comment, a support ticket — and that content contains instructions. Your tool is honest; its payload is not. If any tool of yours returns text a third party wrote, you are a delivery vehicle whether you meant to be or not.

There is no fix for this, only mitigation. Attacks against state-of-the-art models are repeatable and published.

Your tools run inside the user’s authenticated session

This is the confused-deputy problem, and it is structural. A WebMCP tool executes with exactly the permissions of the logged-in user, without the click that used to stand between intent and action. Every assumption in your app of the form “a human saw this screen before this request” is now false. If your authorisation logic lives in the UI — a disabled button, a hidden menu item — a tool call walks straight past it.

Third-party scripts can register tools too

Any script on your page can call registerTool. Your analytics tag, your chat widget, your ad network, a compromised CDN — each can silently add a tool to your origin’s surface mid-session, which the agent will treat as coming from you. Researchers call this mid-session tool injection. If you have never audited your third-party script list, WebMCP just raised the cost of not doing so.

Cross-origin exposure is opt-in for a reason

registerTool takes an exposedTo option listing origins, and iframes need an explicit allow="tools" permissions policy. The defaults are conservative. Widening them is the kind of change that looks like a one-line convenience and is actually a trust decision: you are authorising another site to act as the user, on yours.

The spec is still moving under you

navigator.modelContext became document.modelContext. Unregistration semantics changed at Chrome 153. Origin trial tokens expire. This is not a ship-once feature; budget for revisiting it every couple of Chrome releases until it stabilises.

How to be prepared

The checklist we ended up with, roughly in order of value:

  1. Annotate every tool honestly. readOnlyHint: true on anything that changes nothing, so agents can skip confirmation where it is safe. untrustedContentHint: true on anything returning user-generated or third-party content, so the agent knows to treat the payload as data rather than instructions.
  2. Read-only first. Ship reads and navigation; add writes once you have logs.
  3. Authorise server-side, every time. Treat a tool call as an anonymous, hostile HTTP request — because the caller is a model that may be following someone else’s instructions. UI-level gating is not a control.
  4. Validate and clamp inside execute. Do not trust the schema to have been honoured. Clamp numbers to the real range and say so in the return value; reject malformed colours, enums and IDs rather than passing them through. A model that gets told “16 was out of range, clamped to 10” corrects itself; one that gets a silent failure retries forever.
  5. Cap output size. Enforce your own limit below the 1.5K guidance, and never emit raw user content unmarked.
  6. Require a human for consequences. Anything that spends money, sends a message, or deletes data goes through a confirmation the user actually sees.
  7. Audit third-party scripts. Tool registration is now a privileged capability on your origin. Tighten your CSP script-src accordingly.
  8. Rate-limit and log invocations server-side. Same as any public API, because that is what you just built. Anomalous patterns — token exhaustion, unusual tool sequences — are your early warning.
  9. Diary the token expiry. The most likely way this breaks is not an attack, it is a lapsed origin trial token nobody noticed.

The short version

Add four attributes to one form and you have shipped WebMCP. Do it properly and the sequence is: get the flag, get a token, pick five user intents, register them read-only with accurate schemas, test them in the DevTools WebMCP panel, and then spend the real effort on the assumption that every input is hostile and every output may be read as an instruction.

The security downsides are not hypothetical and they are not solved. But they are the same downsides you already accept the moment an agent starts driving your UI with no schema at all — WebMCP at least makes the surface explicit, which is the first thing you need in order to defend it.

You can see the whole surface described in plain text at /llms.txt and /llms-full.txt, and running on any mini-tool page — posterize or the palette generator are good ones to open DevTools on.