Feature detection lies: two things I learned shipping on WebMCP

dev.to

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.

1. '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);
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

…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.

2. Declarative tools land asynchronously, and 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);
}
Enter fullscreen mode Exit fullscreen mode

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.

A third one, already settled upstream

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 numbers

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
Enter fullscreen mode Exit fullscreen mode

Plus 306 unit tests, including all 152 legality cells — 7 phases × 19 gated tools, asserted against the rule table rather than against the implementation.

The non-technical lesson, which cost more

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.

What it doesn't do

  • No small-board mode (§49) — the gap above. It's a second data file, not a rewrite, but it isn't written.
  • Never rules on germaneness. Not computable from a table, so the chair rules and the ruling enters the minutes. That limit is the design.
  • No per-member vote records — only tallies. Which is why it can't implement Reconsider (§37), where eligibility is restricted to someone who voted on the prevailing side. Shipping it would mean shipping a rule the engine cannot check.
  • Timings are one machine, one browser. M1 Max, Chrome 151. No cross-device distribution is claimed.

Try it

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.

Source: dev.to

arrow_back Back to News