cd /news/developer-tools/feature-detection-lies-two-things-i-… · home topics developer-tools article
[ARTICLE · art-115745] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Feature detection lies: two things I learned shipping on WebMCP

A developer building a parliamentary-procedure engine on WebMCP, the emerging standard for web pages to expose typed tools to AI agents, discovered that feature detection for the API is unreliable. The developer found that checking for the presence of 'registerTool' does not guarantee the API works, and that some clients, such as ChatGPT's in-app browser, do not support event subscriptions, causing runtime errors. The developer implemented a fallback polling mechanism to ensure the UI stays in sync with the tool list, highlighting the need for robust capability detection in WebMCP clients.

read6 min views3 publishedAug 30, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @webmcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/feature-detection-li…] indexed:0 read:6min 2026-08-30 ·