Surface tools registered through a navigator.modelContext polyfill - #416
Conversation
Chromium moved the WebMCP registry from navigator.modelContext to document.modelContext and removed the old alias, so a page that installs its own polyfill on navigator.modelContext never reaches the CDP WebMCP domain and its tools are missing from GET /webmcp/tools. Read such polyfills from page context during discovery and merge their tools into the same list and invoke paths. Polyfill tools get stable refs, follow document and frame lifecycle like native tools, yield to a native registration of the same name, and are marked with source.polyfill in the API. Invocation runs the polyfill's own execute path and reports completed, error, or outcome_unknown with the existing shapes; a tool that navigates its document completes with empty output like the native domain.
Bound the serialized tool list a frame can return so a bloated page cannot
exceed the CDP read limit, discard reads of a document that was replaced
while it was being read, keep native-name precedence in one place so a
shadowed polyfill tool keeps its reference, and let polyfill tools invoke
even when the WebMCP domain could not be enabled for their session.
Prefer a registry entry's own execute over callTool, accept listTools
results wrapped in {tools}, and report unreadable page results as tool
errors. Treat a failed call as a navigation when the document is released
shortly after, drop the synthetic user gesture, and release remote object
groups without blocking the caller.
Resolve out-of-process iframe frame ownership from the live iframe session
instead of the last reporting session, and clear ownership when a session
detaches.
Instead of reading and invoking a navigator.modelContext polyfill from the Go client, copy its tools into the frame's native document.modelContext registry. The CDP WebMCP domain then lists, invokes, and removes them like any other page tool, so no separate source marker, metadata, or invocation path is needed. The bridge runs in the page's main world and is reachable only through a remote object handle held by the client. Each listing syncs it: new polyfill tools are registered, removed or changed ones are unregistered, and a name the page already registered natively keeps the native tool.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 33b8e10. Configure here.
masnwilliams
left a comment
There was a problem hiding this comment.
looks good. bridging into document.modelContext is a much better shape than the previous read-and-invoke path. listing, invocation, refs, removal and navigation all go through the existing native flow now, the API is unchanged, and the client no longer carries a polyfill mode. a couple of small asks inline before merge.
verified locally at a416c47: go test -race ./lib/webmcpclient/ ./lib/browsersurface/ ./cmd/api/api/ and node --test runtime/webmcp-polyfill.test.ts pass. i also ran the bridge in node against a site-style polyfill whose listTools() returns only name/description/inputSchema and keeps title on the _registeredTools entry. the bridged tool registers without its title (inline).
one non-blocking note: once anyone lists tools, bridged tools are visible to the page through document.modelContext.getTools(), so a page can see tools it never registered natively. the PR body calls this out and it matches custom page tools, so fine by me. just flagging it as a page-observable signal.
masnwilliams
left a comment
There was a problem hiding this comment.
approving. the inline comments from my earlier review are all non-blocking and fine as follow-ups: the dropped title for listTools()-style polyfills, the native-registered-later name collision question, and the errorText assertion question. bridging into the native registry is the right shape.
Bridged polyfill tools now register under a reserved polyfill. prefix, so a page that later registers the same name natively no longer gets a duplicate-name rejection. Discovery strips the prefix and lists the page's native tool in place of a bridged copy, and the bridge withdraws its copy on the next sync. Also keep the registry entry's title when listTools() omits it, report what a page tool threw from the exception Chromium attaches to WebMCP.toolResponded, and release tool references by registered name so a replaced document never reuses a stale reference.

Summary
Sites that shipped WebMCP tools before Chromium exposed the native registry install their own polyfill on
navigator.modelContext. Chromium 150 deprecated that alias and later removed it in favor ofdocument.modelContext(spec: webmachinelearning/webmcp#177). A polyfill that does not forward to the native registry is invisible to the CDP WebMCP domain, soGET /webmcp/toolsandwebmcp.listTools()list nothing even thoughawait navigator.modelContext.listTools()shows the tools in page context.This change copies those tools into the frame's native
document.modelContextregistry. From there the CDP WebMCP domain lists, invokes, and removes them like any other page tool; the API and result shapes are unchanged.webmcpclientsyncs a bridge (polyfill_bridge.js) in the main world of each tracked frame. Root frames are reached through their session; same-process child frames through the owner element'scontentWindow.Runtime.callFunctionOnand is reachable only through the client's remote object handle, so no globals or prototypes change on the page, and neitherRuntime.enablenorDOM.enableis used. Pages that calldocument.modelContext.getTools()do see the bridged tools, the same as custom page tools today.polyfill.prefix, which discovery strips, so they never occupy a name the page may register natively later. When the page registers a name natively, before or after bridging, the native tool is listed and the bridge withdraws its copy on the next sync. Anavigator.modelContextthat is the native registry itself is skipped.listTools()/getTools()(array or{tools}), registries on_registeredTools,_tools,tools, orwindow.__webmcp.tools, and execution through a registry entry'sexecute,callTool(name, input),callTool({name, arguments}), orexecuteTool(name, json). Metadata is copied as JSON with per-tool, total-list, and output size bounds.browsersurfacenow records which session owns each frame and exposesTracker.SessionFrames.error_text. Chromium attaches the thrown value toWebMCP.toolRespondedasexceptionand leaveserrorTextempty; the client previously dropped it for every page tool.Testing
go test -race ./lib/webmcpclient/ ./lib/browsersurface/ ./cmd/api/api/: fake-CDP tests cover bridging into root, same-process child, and out-of-process frames, bridge reuse, unregistration, recreation for a new document, native precedence and fallback, invocation under the registered name, exception text, and thatRuntime.enable/DOM.enableare never sent.node --test runtime/webmcp-polyfill.test.tsandmake runtime-typecheck: runs the bridge against site-style, MCP-style, and registry-only polyfills, native collisions, malformed entries, output bounds, and document replacement.TestPlaywrightExecuteAPIe2e against a locally built headless image: the existing declarative and custom subtests plus the newWebMCPPolyfillsubtest (late-installing polyfill,srcdocchild frame, native precedence, a native registration of a bridged name after bridging, thrown-error text, unregistration, navigation, stale refs).