Suresh Michael
All posts
Agentic Web15 min read

Publish, Don't Be Parsed: WebMCP, Session Trust, and the Limits of a Tool Description

A browser window on a secure origin shows a booking app: a sidebar of home, calendar, profile and settings icons, and a list of appointment rows each with its own blue action button. Glowing threads run out of those rows into a floating blue panel marked with a linked-node graph icon, which fans out to three tiles: a puzzle piece, a shield, and a user silhouette. A small white robot with lit blue eyes waits at the right, and a shield bearing a padlock sits in the foreground on rings of light.
The page declares its tools; the agent calls them, still inside the user session

Architectural notes on WebMCP: what the browser is offering, and what it quietly asks you to accept.

Right now, the most likely way an AI agent uses your booking flow is this. It takes a screenshot, guesses which of the blue rectangles books the appointment, and clicks it.

When that works, it is not because your site is well built. It is because a model made a lucky inference about your CSS. When it fails, it fails in the most expensive way available: not with an error, but with a confident wrong action, taken inside a logged-in session, on behalf of a user who was not watching.

Chrome's docs have a word for this. They call it actuation: "the act of an agent simulating manual mouse clicks and text input, as though it were the human user engaging with your website." It is the only option agents currently have, and it is a reverse-engineering exercise conducted at runtime against a target that ships CSS changes on Thursdays.

WebMCP proposes the obvious alternative: stop making them guess. Let the page declare what it can do.

That premise is correct, and I think this standard or something shaped very much like it is inevitable. What follows is a read of the specification, the Chrome documentation, and Cloudflare's developer preview, aimed at the two or three decisions you will actually have to make. The interesting parts are not in the API.

1. The shift is from inference to declaration, and it is genuinely overdue

Mechanically, WebMCP is small. A page registers tools; an agent in the browser discovers and calls them.

await document.modelContext.registerTool({
  name: "book_appointment",
  description: "Book an appointment in an available slot for the signed-in user.",
  inputSchema: {
    type: "object",
    properties: {
      slotId: { type: "string", description: "Slot id from list_available_slots" },
      note: { type: "string", description: "Optional note for the practitioner" },
    },
    required: ["slotId"],
  },
  annotations: { readOnlyHint: false },
  async execute({ slotId, note }, { signal }) {
    const booking = await bookSlot(slotId, note, { signal });
    return {
      content: [
        { type: "text", text: `Booked ${booking.start} with ${booking.clinician}.` },
      ],
    };
  },
});

That is the whole surface, more or less. getTools() for discovery, executeTool() for invocation, a toolchange event when the set changes, an AbortSignal to unregister. If you have written an MCP server, none of this is new; the schema and the result shape are deliberately the same.

The value is not in the API being clever. It is in what stops being your problem. A screenshot-driven agent infers your intent from your layout, which means your visual design becomes an undocumented API contract with an unknown number of consumers. Rename a button, break an integration you never agreed to. Declare a tool instead, and the contract becomes explicit, versionable, and yours.

There is a second-order benefit that matters more than it sounds. An agent that has to navigate spends its context window on your DOM. An agent that can call list_available_slots spends it on the user's problem. Cheaper, faster, and considerably less likely to click Delete.

A declared interface is the only kind you can deliberately change.

2. The whole design rests on where the tool runs

Here is the part that deserves more attention than the API surface, and it is easy to read past because it is presented as a convenience.

The tool executes in the visitor's browser, in your page, inside their existing session. Cloudflare's bridge makes this explicit in a single line:

const res = await fetch(mcpUrl, {
  method: "POST",
  credentials: "same-origin",
  // ...
});

credentials: "same-origin". The agent is not authenticating. The user already did, and the tool call rides that.

This is why WebMCP is so cheap to adopt, and it is worth naming the things you do not have to build. No API keys to issue or rotate. No second authorization model that has to stay in sync with your first one. No separate rate-limit tier. No OAuth dance for a bot. The agent inherits exactly the permissions of the person sitting in front of the tab, which is, in the ordinary case, precisely the correct answer.

It is also, structurally, a confused deputy. Your execute() function runs with full user authority on the instruction of a language model whose input includes the page it is reading, the documents it retrieved, and whatever the user pasted in.

I want to be fair to the design here, because "confused deputy" gets thrown around loosely. This is not a flaw in WebMCP. It is the same trust position a browser extension or a first-party JavaScript SDK occupies, and the alternative (agents typing into your form fields with the same session and worse aim) is not safer. The spec constrains it sensibly: tools live only in origin-isolated documents, they are gated behind a tools permissions policy defaulting to self, and they are invisible cross-origin unless you list an origin in exposedTo and the caller opts in via fromOrigins.

But the constraint is on who can call, not on what the call means.

WebMCP does not create a new authorization surface. It hands a probabilistic client a seat inside the one you already have.

3. Your execute() function is the gate. Your description is not.

I made this argument at length in my notes on agent governance, and WebMCP is the cleanest illustration of it I have seen: instructions to a model are a request, not a control.

The API gives you two annotations that look like safety features:

annotations: {
  readOnlyHint: true,
  untrustedContentHint: true,
}

Read the names carefully. They are hints. readOnlyHint helps a well-behaved agent decide when to ask the user for confirmation. untrustedContentHint labels your tool's output as content the model should not treat as instructions, which is a real and useful mitigation for the case where your tool returns, say, product reviews written by strangers. Chrome's own security guidance is candid that this is a mitigation and not a wall: "There have been repeatable prompt injection attacks against agentic systems that use state-of-the-art LLMs."

So treat the boundary as the boundary. The arguments arriving in execute() came from a model that may have been steered. That means the same discipline you would apply to any public endpoint, applied in the same place:

  • Validate server-side. JSON Schema constrains the shape, not the truth. slotId being a string does not make it a slot this user may book.
  • Re-check authorization on every call. The session proves who; it does not prove what they are allowed to do with this particular resource.
  • Do not expose a tool you would not expose as an unauthenticated-adjacent POST. If the only thing preventing catastrophe is that the description says "only use this when the user explicitly asks", you have written a prompt, not a permission.
  • Keep destructive operations out of the tool set entirely until you have a confirmation story. Absence is the cheapest control available and it never has a bypass.

One caution I would raise in a client meeting: it is tempting to reuse an internal function directly as an execute body, because the signature fits and the work is done. That function was written on the assumption that its caller was your own code. It no longer is.

Write execute() as though the arguments came from a stranger, because functionally they did.

4. A tool surface is a context budget, not an API

This is the design constraint teams will discover late, and it is the one I would put on the whiteboard first.

Chrome documents hard character budgets:

FieldBudgetWhat it forces
Tool name30 charactersA verb and a noun. No namespacing scheme.
Tool description500 charactersOne job, described once.
Parameter description150 charactersEnums instead of prose.
Tool output1.5K charactersReturn a result, not a payload.

These are not arbitrary limits imposed on your creativity. They are the standard telling you what a tool is for. Every registered tool consumes room in a context window that also has to hold the user's actual request. Thirty CRUD tools mirroring your data model will reliably perform worse than four tools shaped like the things people come to your site to do.

The 1.5K output ceiling is the sharpest hint. It rules out "return the search results" and pushes you towards "return the three that match, with ids the next tool accepts." Your tools should compose into a task, with each one handing the next a usable handle.

The second half of this is temporal. Tools do not have to exist for the lifetime of the page:

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

// when the user navigates away from checkout
controller.abort();

Register the checkout tool on the checkout page. Register cancel_booking only when there is a booking to cancel. The toolchange event exists precisely so agents can keep up with a set that moves. A tool that is not registered cannot be called by a confused agent, which makes contextual registration a security control that also happens to improve your results.

Model tools on the jobs your users arrive with, not on the endpoints you happen to have.

The imperative API gets the attention. The declarative one is, I think, the more interesting piece of design, because it puts human-in-the-loop in the platform rather than in your prompt.

You annotate a form. The browser synthesises the tool.

<form
  toolname="createSupportRequest"
  tooldescription="Submits a request for customer support."
  method="post"
  action="/support"
>
  <label for="subject">Subject</label>
  <input id="subject" name="subject"
         toolparamdescription="Short summary of the problem" />
  <button type="submit">Send</button>
</form>

Three attributes, toolname, tooldescription and toolparamdescription, and the input schema is derived from the fields you already have. Remove either of the first two and the tool unregisters. There is no partial state.

Now the part that matters. When an agent invokes that tool, the browser brings the form into view and fills it in, and the form stays visible. The user sees what is about to be submitted, in the UI they already understand, and clicks the button. Automatic submission is opt-in via toolautosubmit, and the page can style the whole interaction with :tool-form-active and :tool-submit-active so it is visibly an agent doing this rather than a ghost. Your handler can tell the difference:

form.addEventListener("submit", (event) => {
  if (event.agentInvoked) {
    event.preventDefault();
    event.respondWith(submitAndSummarise(new FormData(form)));
  }
});

Compare that to the imperative path, where execute() runs silently and the user learns about it afterwards, if at all. Same capability, completely different default.

That gives you a real design axis, and it maps onto reversibility, which is the axis that has always mattered for automation:

Action classExampleWhere I would put it
Read-only, cheap to repeatsearch_products, list_slotsImperative, readOnlyHint: true
Writes, reversibleadd_to_cart, save_draftImperative, with server-side authorization
Writes, irreversible or outward-facingplace order, send message, cancel bookingDeclarative form, no toolautosubmit

Grade your tools by reversibility, and let the irreversible ones keep the form.

6. Three different things are called WebMCP, and they hold the session differently

Worth clearing up before a vendor conversation goes sideways, because all three are real, shipping, and named the same.

Where tools runWho holds the sessionWhat you change
W3C / Chrome document.modelContextIn your page, called by an agent in the browserThe visitor's tabYou write and register the tools
webmcp.dev (Jason McGhee)In your page, bridged to an external MCP client the user pairs with a pasted tokenThe visitor, plus whichever client they pairedAdd a script, register tools, ship a widget
Cloudflare's previewIn your page, from a bridge injected at the edgeThe visitor's tabNothing. A dashboard toggle

The first is the standard: a Draft Community Group Report from the W3C Web Machine Learning Community Group, edited by Brandon Walderman at Microsoft with Khushal Sagar and Dominic Farolino at Google. It is explicitly "not a W3C Standard nor is it on the W3C Standards Track."

The second predates the standard and solves a different problem: it bridges a live page to an MCP client such as Claude Desktop over a token the user pastes into a widget, and it carries prompts, resources and sampling as well as tools. Useful, and a genuinely different trust model, because the counterparty is an application outside the browser rather than an agent inside it.

The third is infrastructure, and it is the one that will drive most of the near-term numbers. Cloudflare uses HTMLRewriter to inject one same-origin script into your HTML at the edge:

<script type="module"
        src="/.webmcp/bridge.js"
        data-packs="c2pa,mcp-server-client"
        data-mcp-url="/mcp"></script>

The bridge detects browser support, composes the tool packs you selected, and registers them. Two packs ship in the preview: one that reads C2PA content credentials off images, and one that discovers your existing server-side MCP endpoint and proxies its tools into the page. Everything runs in the visitor's browser, with no round trip beyond your own origin.

I would call out one detail as a model of how to do this well. The C2PA pack returns signatureVerified: false, deliberately, because it decodes provenance metadata without cryptographically verifying it. Rather than let an agent mistake decoded data for validated data, the field says so in the payload. That is the right instinct for every tool you write: the result should carry its own confidence, because the model will not infer it.

The trade with the edge-injection approach is the usual one. Zero integration cost, and a tool surface you did not author appearing on your origin. Fine for a preview, worth a conversation before it is load-bearing.

Before adopting "WebMCP", establish which one, and who ends up holding the session.

7. The supply side shipped first, and that dictates how you adopt

Now the honest status, because the gap between "the browser supports it" and "anything calls it" is where budgets get wasted.

Chrome is running a public origin trial from version 149 through 156, with chrome://flags/#enable-webmcp-testing for local work. Edge has experimental support behind a flag. Firefox and Safari are in the conversation and have committed to nothing. The API moved from navigator.modelContext to document.modelContext in July 2026, which is a small rename that broke every early adopter's code and tells you exactly how settled this is.

On the demand side, one survey of the landscape in July put it about as bluntly as it can be put: a standard with everything except users. No mainstream agent consumes WebMCP tools in production yet; Claude, ChatGPT's agent, Perplexity and Gemini still read pages the old way. Google has said Gemini in Chrome will be the first real consumer. Lighthouse added agentic-browsing audits in May 2026, which currently report "Not Applicable" on nearly every site they touch. The reported adoption outside demonstration sites rounds to zero, and the wry observation doing the rounds is that the WebMCP checker tools now outnumber the WebMCP implementations.

None of that is an argument against building it. It is an argument about how much to build and where to put it. Two-sided markets bootstrap slowly and then quickly, and the audits flipping from informational to warnings is a plausible forcing function.

What it does mean is that the shape of your adoption should assume churn. Do not scatter document.modelContext.registerTool through your components. Put your tools in one module, have each one call domain functions that exist independently and are tested independently, and let the registration layer be the only thing that knows a standard is involved. The same argument as wrapping any preview dependency: the import statement is the coupling.

Done that way, the rename from navigator to document is a one-line change in one file. Done the other way, it is a sprint.

Adopt it at the edge of your codebase, where a breaking change is a rename rather than a migration.

What I'd tell someone starting this

Start with three tools, not thirty. Pick the three things people actually come to your site to do. A small, task-shaped tool set outperforms a complete, model-shaped one, and it is the difference between an agent that helps and an agent that flails inside your context window.

The gate is in the function. Descriptions, hints and annotations shape a cooperative agent's behaviour. Validation and authorization in execute() are what hold when the agent is not cooperative, and prompt injection is a live, demonstrated attack, not a hypothetical.

Grade by reversibility. Read-only tools can run silently. Anything you cannot undo should keep a visible form and a human finger on the button, and the declarative API gives you that for free.

Register contextually. A tool that only exists on the page where it makes sense is both a better prompt and a smaller attack surface.

Keep one seam. One module registers tools, everything else stays standard application code. The spec is a Community Group draft that renamed its entry point last month.

Do not confuse readiness with reach. Shipping tools today is a cheap option on a likely future, not a channel. Size the investment accordingly, and be sceptical of anyone selling it as traffic.

The deeper point outlives this particular API. The web has published machine-readable descriptions of itself for thirty years: robots.txt, sitemaps, RSS, schema.org, OpenGraph. Every one of them started as a courtesy to crawlers and ended up as infrastructure nobody could opt out of.

WebMCP is the first of that lineage where the machine does not merely read. It acts, with your user's cookies, inside your session, on the strength of a sentence you wrote in a description field. That is the whole opportunity, and it is precisely the whole risk, and they are not separable. Publish the interface. Then defend it like the endpoint it now is.


If you are putting agent-facing interfaces in front of real users (booking, checkout, anything with a write path), I'd genuinely like to compare notes on where you drew the confirmation line.

Share this post
Copied