I spent eight days building a parliamentary-procedure engine on WebMCP — the emerging standard that lets a web page hand an AI agent a typed tool list through document.modelContext.
The idea is one sentence: an action that is out of order should not exist to be called. Not greyed out, not refused at runtime — absent from getTools() entirely, with the rule that removed it printed beside the gap.
Two things I learned are worth more than the product, and both came from running it in a client I did not control. 'registerTool' in modelContext does not tell you the API works
That checks the property exists. It does not check that calling it works, and it says nothing about the other capabilities hanging off the same interface.
ChatGPT's in-app browser hands back a modelContext that registers tools correctly and answers getTools() correctly — and is not an EventTarget. addEventListener('toolchange', …) throws a TypeError.
Subscribing is a separate capability from registering, and nothing in the shape of the object warns you.
The failure mode was the worst one available. boot() awaited start(), the throw landed after the page had painted, and the page rendered completely and then announced it had failed to start. It looked alive, then called itself broken.
…and then say so in the UI. The banner now reads "WebMCP live · document.modelContext — tools registered, but this client's modelContext is not an EventTarget, so there are no toolchange events." A product that names which mechanism is carrying it beats one that quietly degrades. Declarative tools land asynchronously, and toolchange is how you find out
WebMCP has a declarative form: put toolname on a and the browser adopts it as a tool. It is genuinely elegant — the form is the tool, and removing the attribute removes the tool. My app uses both mechanisms on purpose, so a single state change can remove seven tools by aborting an AbortSignal and an eighth by dropping an attribute.
But adoption happens when the browser notices the DOM change. There is no promise to await. Where toolchange fires this is invisible: the event tells you the surface settled, and you re-render.
Without the event, the render that follows a state change read getTools() one tick early. The panel said 16. The API said 17. It stayed wrong — four seconds later, still wrong.
That is precisely the divergence the product claims is impossible. The whole pitch is that the left column is rendered from getTools(), so the screen and the API cannot disagree. In one client, they did.
With nothing to await, the honest option is to poll until the surface stops moving:
To verify the fix I served the local build under the live origin — so the origin-trial token still applied — with addEventListener stripped off modelContext to reproduce the client's shape. Both shapes now return 5 / 17 / 15 / 9 tools at the four checkpoints, zero divergences, zero page errors.
On Chrome 151, executeTool's second argument must be a JSON string. Passing the object the IDL specifies returns UnknownError: Failed to parse input arguments.
This is not an open question — it was resolved in webmachinelearning/webmcp#243 ("The executeTool() method should take an object, not a string"), closed as completed on 2026-08-17. Chrome has not shipped the resolution yet. Send the string, keep the object path as a fallback, and you are correct today and on the day Chrome converges.
The claim worth measuring here is not speed. It is that the panel cannot lie, because its left column is rendered from getTools() rather than from my own bookkeeping. npm run bench drives the deployed origin in real Chrome and exits non-zero on any gate failure:
Plus 306 unit tests, including all 152 legality cells — 7 phases × 19 gated tools, asserted against the rule table rather than against the implementation.
