{"slug": "feature-detection-lies-two-things-i-learned-shipping-on-webmcp", "title": "Feature detection lies: two things I learned shipping on WebMCP", "summary": "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.", "body_md": "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`\n\n.\n\nThe 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()`\n\nentirely, with the rule that removed it printed beside the gap.\n\nLive: [https://pointoforder.netlify.app](https://pointoforder.netlify.app) · Source (MIT): [https://github.com/edycutjong/mace](https://github.com/edycutjong/mace)\n\nTwo things I learned are worth more than the product, and both came from running it in a client I did not control.\n\n`'registerTool' in modelContext`\n\ndoes not tell you the API works\nThe obvious feature detection:\n\n``` js\nexport const modelContext =\n  globalThis.document?.modelContext ?? globalThis.navigator?.modelContext ?? null;\nexport const hasWebMCP = !!(modelContext && 'registerTool' in modelContext);\n```\n\nThat 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.\n\nChatGPT's in-app browser hands back a `modelContext`\n\nthat registers tools correctly and answers `getTools()`\n\ncorrectly — and **is not an EventTarget**.\n\n`addEventListener('toolchange', …)`\n\nthrows a `TypeError`\n\n.Subscribing is a separate capability from registering, and nothing in the shape of the object warns you.\n\nThe failure mode was the worst one available. `boot()`\n\nawaited `start()`\n\n, 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.\n\nThe fix is to treat every capability as independently optional:\n\n``` js\nlet toolEventsLive = false;\nif (hasWebMCP && !regErr) {\n  try {\n    modelContext.addEventListener('toolchange', () => { renderAll(); });\n    toolEventsLive = true;\n  } catch (err) {\n    console.error('[mace] modelContext is not an EventTarget:', err);\n  }\n}\n```\n\n…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.\n\n`toolchange`\n\nis how you find out\nWebMCP has a declarative form: put `toolname`\n\non a `<form>`\n\nand 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`\n\nand an eighth by dropping an attribute.\n\nBut adoption happens when the browser notices the DOM change. **There is no promise to await.** Where `toolchange`\n\nfires this is invisible: the event tells you the surface settled, and you re-render.\n\nWithout the event, the render that follows a state change read `getTools()`\n\none tick early. The panel said 16. The API said 17. It stayed wrong — four seconds later, still wrong.\n\nThat is precisely the divergence the product claims is impossible. The whole pitch is that the left column is rendered *from* `getTools()`\n\n, so the screen and the API cannot disagree. In one client, they did.\n\nWith nothing to await, the honest option is to poll until the surface stops moving:\n\n```\nfunction settleWithoutEvents(rendered) {\n  if (toolEventsLive || !hasWebMCP) return;   // clients with the event never get here\n  let tries = 0;\n  const tick = async () => {\n    if (toolEventsLive || ++tries > 12) return;\n    let n;\n    try { n = (await modelContext.getTools()).length; } catch { return; }\n    if (n !== rendered) { renderInOrder(); return; }   // that render re-arms the poll\n    setTimeout(tick, 50);\n  };\n  setTimeout(tick, 50);\n}\n```\n\nBounded to about 600 ms, and only on the path where the event is unavailable.\n\nTo verify the fix I served the local build under the live origin — so the origin-trial token still applied — with `addEventListener`\n\nstripped off `modelContext`\n\nto reproduce the client's shape. Both shapes now return **5 / 17 / 15 / 9** tools at the four checkpoints, zero divergences, zero page errors.\n\nOn Chrome 151, `executeTool`\n\n's second argument must be a JSON **string**. Passing the object the IDL specifies returns `UnknownError: Failed to parse input arguments`\n\n.\n\nThis is not an open question — it was resolved in [webmachinelearning/webmcp#243](https://github.com/webmachinelearning/webmcp/issues/243) (\"The `executeTool()`\n\nmethod 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.\n\nThe claim worth measuring here is not speed. It is that the panel *cannot* lie, because its left column is rendered from `getTools()`\n\nrather than from my own bookkeeping. `npm run bench`\n\ndrives the deployed origin in real Chrome and exits non-zero on any gate failure:\n\n```\nHEADLINE  90 getTools()-vs-screen comparisons, 0 divergences\n\ngetTools() round trip           p50 0.20  p95 0.30  n 90\nquorum cliff, submit → settled  p50 1.00  p95 4.10  n 30\nexplain_path_to (depth 6/399)   p50 0.90  p95 1.60  n 30\n```\n\nPlus 306 unit tests, including all 152 legality cells — 7 phases × 19 gated tools, asserted against the rule table rather than against the implementation.\n\nI also went and asked ten HOA board members whether the problem I was solving was real.\n\n**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.\n\nMy pitch opened with \"every HOA runs its meetings under Robert's Rules.\" That was false, and I had already shipped it.\n\nOne 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.\n\nNarrower audience, real evidence, and a limitation I can state myself instead of one a reader finds for me.\n\nLive: [https://pointoforder.netlify.app](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.)\n\nSource, MIT: [https://github.com/edycutjong/mace](https://github.com/edycutjong/mace) — start at `src/webmcp.js`\n\n. Zero runtime dependencies, no build step.\n\nIf you find a third thing wrong with it, I would rather know.", "url": "https://wpnews.pro/news/feature-detection-lies-two-things-i-learned-shipping-on-webmcp", "canonical_source": "https://dev.to/edycutjong/feature-detection-lies-two-things-i-learned-shipping-on-webmcp-328d", "published_at": "2026-08-30 13:00:08+00:00", "updated_at": "2026-08-30 13:22:55.201551+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-infrastructure"], "entities": ["WebMCP", "ChatGPT", "pointoforder.netlify.app", "github.com/edycutjong/mace"], "alternates": {"html": "https://wpnews.pro/news/feature-detection-lies-two-things-i-learned-shipping-on-webmcp", "markdown": "https://wpnews.pro/news/feature-detection-lies-two-things-i-learned-shipping-on-webmcp.md", "text": "https://wpnews.pro/news/feature-detection-lies-two-things-i-learned-shipping-on-webmcp.txt", "jsonld": "https://wpnews.pro/news/feature-detection-lies-two-things-i-learned-shipping-on-webmcp.jsonld"}}