ModelPiper.com registers eight read-only WebMCP tools on app boot: doc search, page markdown, a recent-articles listing, provider and model lookups, pricing tiers, and two over our OpenAPI spec. They shipped in late April. They registered nothing at all until August 1st.
Not "registered but unreachable." Zero tools, on every page load, for every visitor, for three months. The unit suite was green that entire time. Eleven tests, all passing, none of them ever touching a browser. They ran against a document.modelContext we built in the test file. In a real browser that getter was undefined, so all eight registerTool() calls resolved into nothing and boot carried on. A green suite over a mocked capability tells you nothing about whether that capability exists in production.
That is the part worth your time, and it has very little to do with WebMCP. What follows is the specific version: five ways Chrome moved the API out from under our browser-side bridge, why two different test suites both failed to notice, the suite shape that catches it, and a Chrome flag we had been publishing in five places that Chrome has never documented.
What is WebMCP, and what is it not?
WebMCP is a draft browser API that lets a web page register tools for an AI agent running inside the browser. The page declares each tool with a name, a description, and a JSON Schema input, and the agent invokes them after it has already navigated to the page.
Think of it as the page-side half of Anthropic's Model Context Protocol. ToolPiper already speaks MCP over HTTP, where a client like Claude Code, Claude Desktop, or Cursor reaches a tool catalog running on the user's own machine. WebMCP is narrower: the page is the server, the tools live and die with the tab, and the caller is whatever agent the browser is running.
What it is not, because we checked before writing any of this down: a discovery input. WebMCP actuates after navigation, so there is no crawl-time artifact for a search engine to read. We evaluated it as a ranking play and declined that framing. It cannot move rankings, and anyone selling it as SEO is describing a mechanism that does not exist.
We built a bridge for it anyway, because the direction is not speculative: Google's own chrome-devtools-mcp moved the same way, and a browser tool that cannot read what the page exposes is going to age badly. Availability, from Chrome's own documentation: WebMCP is not enabled by default in Chrome stable. It runs as an origin trial from Chrome 149 through 156, ending November 16, 2026, with chrome://flags/#enable-webmcp-testing for local testing. A page has a tool registry only where the site enrolled and serves a token.
How did eight registered tools end up registering nothing?
Without an origin trial token served by the page, document.modelContext is undefined for every ordinary Chrome visitor, so every registerTool() call is a silent no-op. ModelPiper.com shipped the registration code and no token.
The code was right. The tests were right about the code. Our feature doc said the tools were live, and they were, in the sense that the file existed and the function ran. What nobody owned was the precondition. Enrollment is a form on Chrome's origin trials site plus a meta tag in index.html, and it sat outside every test, every review, and every checklist we had.
Everything downstream inherited that. The tool definitions, the client-side BM25 index the two search tools run over, the route-path normalizer that rejects path traversal and foreign hosts: none of it had executed against real traffic. We enrolled https://modelpiper.com on August 1st and the prerender now bakes the token into every page, so that whole layer is exercised in production for the first time. The next round of bugs there is ahead of us, not behind us.
What changed in Chrome's WebMCP API?
Chrome moved the getter from navigator.modelContext to document.modelContext, replaced provideContext({tools}) with an async per-tool registerTool(tool), made enumeration an async getTools() call, ships inputSchema as a JSON string rather than an object, and gives tool descriptors no execute() method of their own.
Five separate breakages in one bridge. We verified each against live Chrome 150 stable and Chrome 153 Dev, not against the draft.
| What our bridge assumed | What Chrome actually ships |
|---|---|
Getter lives on navigator.modelContext | document.modelContext, with navigator kept as a deprecated alias on 150 and already gone on 153 Dev |
Tools are readable as a .tools array | await modelContext.getTools(), async, alphabetically ordered |
inputSchema is an object | a JSON string, normalized back to an object on our side |
Each descriptor carries its own execute() | no execute(). You invoke through modelContext.executeTool(tool, jsonString) |
Register with provideContext({tools}) | registerTool(tool), one tool per call, async |
The measured symptom is the one that stings. Probing for a readable .tools array against Chrome Labs' hotel-chain demo, a page that exposes four tools, returned {available: true, tools: []}. Available, and empty. The bridge reported "connected, nothing here" about a page whose entire purpose is to have tools. Chrome 153 Dev is worse and more honest at once: navigator.modelContext is already undefined there, so a navigator-only probe says unavailable outright instead of failing quietly.
The live registry prototype is [ontoolchange, executeTool, getTools, registerTool]. No provideContext. No readable tools. Every one of those five fixes is load-bearing, not defensive.
Why didn't the tests catch it?
Both suites asserted against something other than the running API. One stubbed the browser surface, so the stub always had the property. The other string-matched the JavaScript source, and a substring assertion cannot tell you whether the property it names is real.
The producer side is the textbook version. webmcp.service.spec.ts defines document.modelContext on a fake, calls the service, and asserts that registerTool was invoked once per tool. All eleven of those tests are correct tests of our code. Not one of them can observe that Chrome never hands us that object.
The consumer side is the more interesting failure, and it is the shape you get any time code crosses a language boundary. Our browser bridge is Swift. The probe it runs is a JavaScript string sent over the Chrome DevTools Protocol. Swift tests can reach the string. They cannot reach a browser. So they assert things like js.contains("document.modelContext"), which is true, and useful, and completely blind to the defect sitting next to it: the probe also contained the word tools, in a property read that no implementation answers. The assertion and the bug agreed with each other.
A test that checks whether your code mentions an API, rather than what that API does when called, will pass a rename, a move, and a property that was never real. We were running both flavors, both green, on the same feature, for three months.
What does a test that can't lie about this look like?
Extract the probe JavaScript out of the Swift source at test time and execute it in node against stubbed page shapes. The code under test is the shipped string, and the thing being stubbed is the page rather than the API the code reads.
That distinction is the whole trick and it is a small one. We still stub. What changed is what we stub. Before: stub the object our code reads, then assert our code read it. Now: stub the page shape, run the real code against it, assert on the result. A probe reading a property no implementation has returns an empty list against a stub holding four tools, and the check goes red.
tests/suites/webmcp-bridge.sh pulls the JS out of WebMCPBridge.swift and runs it against four page shapes: a Chrome 150 registry, a Chrome 149 alias, the window.__webmcpTools convention hook, and no registry at all. 31 checks. Node only, no browser, no Swift build, under a second.
A suite that has never failed is not a gate yet, so we broke it on purpose. Dropping the annotations mapping fails 6 checks. Forwarding annotations raw, with no normalizing, fails 5. Dropping the object-shape guard fails 1. A bare syntax error in the probe fails outright, which the Swift substring tests cannot see at all, because a syntactically broken string still contains every one of the right words.
The honest limit: executing against a stub only covers page shapes we thought of. It catches every defect in the table above, and it will not catch the sixth thing Chrome does next. What it buys is that the next migration fails a check instead of shipping green.
Is there an --enable-features=WebMCP flag?
No. Chrome documents no --enable-features=WebMCP command line switch. WebMCP runs as an origin trial from Chrome 149, so a page has a registry only where the site enrolled and serves a token, with chrome://flags/#enable-webmcp-testing for local testing.
We had been publishing that string in five places. Two were agent-facing: the browser_webmcp tool description, and the handler's "no WebMCP registry found" message. The second is the one that stings. That is the message a real person actually hits, at the exact moment they want to know what to do next, and it was sending them off to set a flag that does not exist.
Nobody invented it out of nothing. --enable-features=X is how a hundred other Chrome features work, so it reads as obviously correct, and it survived review because every reviewer pattern-matched the same way the author did. We had verified the code against the API and never verified the prose against the vendor's docs. Two different jobs, and only one of them had a gate on it.
What happens when the origin trial token expires?
The token expires 2026-11-17 and the trial itself ends November 16, 2026. When it lapses, document.modelContext goes undefined again and all eight registrations revert to the same silent no-op that shipped unnoticed for three months.
Google renews origin trials by emailing a reminder. That is the same class of signal that let the original no-op ship: something outside the repo, that a human has to happen to notice. So the tripwire lives in the repo instead.
tests/suites/webmcp-origin-trial.sh is 8 checks. It decodes the token, pins the feature name, pins the origin against the page's own canonical URL (so moving origin cannot leave an inert token sitting in the head), and goes red 30 days before expiry with the renewal steps printed in the failure message. Mutation-verified against five variants: near expiry, expired, tag removed, canonical moved, corrupt token. Each fires its own check rather than collapsing into one generic failure.
One design choice in there is worth stealing. The suite asserts build parity only against a dist directory newer than index.html. A stale build is skipped, not failed. A gate that can go red for a reason unrelated to its subject teaches people to ignore it, and this one's entire job is to be believed on the single day it fires.
The token is per-origin and public, since it ships in page source. It is not a secret and does not belong in a secret store.
Why are a tool's annotations a security signal?
A WebMCP tool's name and description are page-authored text that flows straight into the calling agent's context, so the MCP annotations readOnlyHint and untrustedContentHint are the only prompt-injection signal the page offers.
Those two are decisions the caller cannot make for itself. Does invoking this mutate anything? Is the content it returns attacker-controlled? If a page can name a tool, it can write a sentence that lands verbatim in whatever model is driving the browser, and it lands there before the agent has decided whether to trust the page. That is prompt injection with a spec-blessed delivery path. Our bridge now carries the whole annotations object through the list mapping, not only the two members we know about, so anything else a page declares survives into the structured result. The handler renders those two as tags beside the name, so a listed tool reads search [read-only, untrusted-content] followed by its description.
Two rules keep that honest. Only an explicit true renders a tag, because an absent or false hint is not a claim and printing one would report an assertion the page never made. And every listing closes with an unconditional caveat that the hints are page-declared and unverified, since tagging only the tools that self-declare would imply the untagged ones are trusted. A page that wants to look safe can simply omit the hint.
What we learned
- A mock of a capability is not evidence the capability exists. A stub proves your code calls the thing correctly given that the thing is there. Whether it is there is a separate question, and something in your pipeline has to ask it out loud.
- A substring assertion tests the source, not the behavior. If the only reachable artifact is a string of code for another runtime, extract it and run it. Executing against a stubbed page shape costs a fraction of a second and catches the whole class: wrong accessor, wrong property, syntax error.
- A precondition nobody owns is a precondition nobody checks. Origin trial enrollment, a token, a signing cert, an entitlement: these live outside the code and outside the tests, which is exactly why they fail silently. Put the check where CI can see it.
- Gate on time before the deadline, not at it. A tripwire that fires the day something expires has already let you ship the outage. Ours fires 30 days early and prints the renewal steps in the failure message, because the person who reads it will not be the person who set it up.
- Verify the prose against the vendor's docs, not against your own code. Documentation drifts along a different axis than the implementation. A flag name can be wrong in every user-facing string you own while the code around it is perfectly correct.
Where this actually stands
WebMCP is early, and we would rather say so than round up. Outside demo pages, the number of sites registering tools rounds to zero, and none of the agents people actually use, Claude, ChatGPT, Gemini, Copilot, consume them today. We are enrolled in a trial that runs to Chrome 156 and can end with the feature dropped. The honest move if that happens is to delete the registration path rather than serve a dead token, and the tripwire is what will tell us.
On the consumer side, browser_webmcp is default-Deny on every tier, including the top one. It is a free tool held back as a maturity gate rather than a paid gate: advertising a tool that answers "no WebMCP registry found" on very nearly every page is noise, which is the same reasoning that keeps browser_action, http_request, and web_search denied by default. You turn it on per-tool in Settings, MCP Tools, and a user Allow rule overrides the tier default.
None of this is an SEO story, and we are not going to dress it up as one. The eight tools on our own site were inert until August 1st. What we have now is a bridge that matches the API Chrome actually ships, two gates that fail before we do, and a documented reason for the one decision we deliberately left unchanged.
ToolPiper's WebMCP bridge ships as the browser_webmcp MCP tool and the POST /v1/browser/webmcp route, alongside the rest of the browser automation surface at modelpiper.com.
This is a technical paper on the browser automation stack. For the accessibility-tree engine underneath it, see AX-Native Browser Automation. For the MCP server that hosts these tools, see Building Over 420 MCP Tools in Swift.
