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.
Live: https://pointoforder.netlify.app · Source (MIT): https://github.com/edycutjong/mace
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 The obvious feature detection:
export const modelContext =
globalThis.document?.modelContext ?? globalThis.navigator?.modelContext ?? null;
export const hasWebMCP = !!(modelContext && 'registerTool' in modelContext);
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.
The fix is to treat every capability as independently optional:
let toolEventsLive = false;
if (hasWebMCP && !regErr) {
try {
modelContext.addEventListener('toolchange', () => { renderAll(); });
toolEventsLive = true;
} catch (err) {
console.error('[mace] modelContext is not an EventTarget:', err);
}
}
…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.
toolchange
is how you find out
WebMCP has a declarative form: put toolname
on a <form>
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:
function settleWithoutEvents(rendered) {
if (toolEventsLive || !hasWebMCP) return; // clients with the event never get here
let tries = 0;
const tick = async () => {
if (toolEventsLive || ++tries > 12) return;
let n;
try { n = (await modelContext.getTools()).length; } catch { return; }
if (n !== rendered) { renderInOrder(); return; } // that render re-arms the poll
setTimeout(tick, 50);
};
setTimeout(tick, 50);
}
Bounded to about 600 ms, and only on the path where the event is unavailable.
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:
HEADLINE 90 getTools()-vs-screen comparisons, 0 divergences
getTools() round trip p50 0.20 p95 0.30 n 90
quorum cliff, submit → settled p50 1.00 p95 4.10 n 30
explain_path_to (depth 6/399) p50 0.90 p95 1.60 n 30
Plus 306 unit tests, including all 152 legality cells — 7 phases × 19 gated tools, asserted against the rule table rather than against the implementation.
I also went and asked ten HOA board members whether the problem I was solving was real.
Six said no. They deliberately don't run strict Robert's Rules, because being regimented about it causes more confusion than it prevents. One corrected me with the rulebook itself: RONR relaxes procedure for boards under about twelve members (12th ed. §49), which is most HOA boards. My engine models the full rules and has no small-board mode — so it is stricter than the rulebook requires for exactly the audience I had named.
My pitch opened with "every HOA runs its meetings under Robert's Rules." That was false, and I had already shipped it.
One person answered differently: a secretary whose association manages millions, who writes each motion down as it is discussed, requires an amendment to be restated and seconded before the vote, and records every member's yay, nay or abstention by name. That is the user. Not every board — the boards where the money makes procedure worth enforcing, and where a vote taken wrong gets challenged months later.
Narrower audience, real evidence, and a limitation I can state myself instead of one a reader finds for me.
Live: https://pointoforder.netlify.app — Chrome 149+ with WebMCP, or the ChatGPT desktop app's Work tab. (The Chat tab cannot open the in-app browser at all; it will tell you so and then guess at the site from its URL.)
Source, MIT: https://github.com/edycutjong/mace — start at src/webmcp.js
. Zero runtime dependencies, no build step.
If you find a third thing wrong with it, I would rather know.