Skip to content

fix(webmcp): survive a host whose modelContext is not an EventTarget - #9

Merged
ribdsp merged 1 commit into
mainfrom
fix/toolchange-on-eventless-host
Aug 28, 2026
Merged

ribdsp merged 1 commit into
mainfrom
fix/toolchange-on-eventless-host

Conversation

@ribdsp

@ribdsp ribdsp commented Aug 28, 2026

Copy link
Copy Markdown
Owner

The bug

Traces was completely down in ChatGPT Desktop's in-app browser — not degraded, replaced:

Application error: a client-side exception has occurred while loading traces.ribdsp.com

Uncaught TypeError: e.addEventListener is not a function
    at layout-7f4736507f1b2029.js:1:70534

That is the browser the challenge rules name first for judging: "Judges may test WebMCP tools using ChatGPT's in-app browser or Google Chrome with WebMCP enabled."

Root cause

The spec declares interface ModelContext : EventTarget, so src/types/webmcp.d.ts typed addEventListener as present and two components subscribed to toolchange without a guard:

  • src/components/ui/tool-status-banner.tsx — mounts first, so this is the one that actually threw
  • src/components/ui/webmcp-badge.tsx — never reached, but the same fault

ChatGPT Desktop's document.modelContext has no event methods. Thrown from a client effect, React escalates to the nearest error boundary; in a production build that is Next.js's global one, so a missing optional feature took the whole page.

Why nothing caught it: lib/webmcp/polyfill.ts builds its shim as Object.assign(new EventTarget(), …), so the call is always valid in polyfill mode. The failure is only reachable on a native host — and only on a native host that does not honour that part of the spec.

The fix

One helper, src/lib/webmcp/tool-change.ts, and both call sites go through it. It guards on typeof, wraps the subscribe, and returns a no-op unsubscribe when there was nothing to subscribe to — guarded and wrapped, because the lesson is not "this one method was missing", it is that a draft API on a host we do not control must never be able to take the page down.

Absence is a console.warn, once per page load, and silent in the UI. Both callers already render a real tool list from getTools() or from the registration result; all that is lost is live updates when the surface changes shape mid-session.

The type keeps extends EventTarget. It is what the spec says, the polyfill genuinely is one, and weakening it to Partial<EventTarget> would break shim.dispatchEvent inside our own polyfill. A docstring above the interface points readers at the helper instead.

Tests

Four new cases in tool-change.test.ts:

Case Asserts
Host with no event methods no throw, unsubscribe safe to call, listener never fired, warns
Spec-shaped EventTarget host event delivered, and not delivered after unsubscribe
addEventListener that throws no throw, warns
Two subscribers, same fault warns once, not four times under React 19 double-invoke

A version of onToolChange that propagates the original TypeError passes none of them.

Verification

npx tsc --noEmit   clean
npm test           24 files, 293 passed (was 289)

Still to confirm after deploy: whether registerTool and getTools work on that host. The banner throwing first means we never got to read what it said, so the answer is still unknown — this PR is what makes it readable.

Scope

Five files, all inside the WebMCP surface. tailwind.config.ts, globals.css, src/types/domain.ts and next.config.mjs untouched.

Traces was entirely down in ChatGPT Desktop's in-app browser: "Application
error: a client-side exception has occurred", from

    Uncaught TypeError: e.addEventListener is not a function

The spec declares `ModelContext : EventTarget`, so `types/webmcp.d.ts` typed
the event methods as present and two components subscribed to `toolchange`
directly. ChatGPT Desktop exposes a `document.modelContext` without them, the
call threw out of a client effect, React escalated it, and the production
build served its global error page — in the one browser with native WebMCP,
which is the browser the challenge rules point judges at. Chrome never saw it
because our polyfill is a real EventTarget, and the failure is unreachable in
polyfill mode.

Subscription now goes through `lib/webmcp/tool-change.ts`, which guards on
`typeof`, wraps the call, and returns a no-op unsubscribe when there was
nothing to subscribe to. Absence is a console warning, printed once, and
silent in the UI: both callers already render a real tool list without the
event, so all that is lost is live updates when the surface changes shape
mid-session.

The type keeps `extends EventTarget` — it is what the spec says, and the
polyfill genuinely is one — with a note directing readers to the helper
rather than the raw methods.

Four tests cover the eventless host, the spec-shaped host, an
`addEventListener` that throws, and warn-once. 293 pass; `tsc --noEmit` clean.
@vercel

vercel Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
traces Ready Ready Preview Aug 28, 2026 9:03pm

@ribdsp
ribdsp merged commit 0fd1452 into main Aug 28, 2026
5 checks passed
@ribdsp
ribdsp deleted the fix/toolchange-on-eventless-host branch August 28, 2026 21:04

This branch was successfully deployed

1 active deployment
Preview — cb9d2446 Deployed Aug 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant