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.
| if (typeof name !== 'string' || name === '' || name.length > 256) return null; | ||
| const entry = { | ||
| name, | ||
| title: text(tool.title), |
There was a problem hiding this comment.
listTools() often returns only name/description/inputSchema while the registry entry keeps title (the sitePolyfill fixture in webmcp-polyfill.test.ts has this shape). listedTools prefers listTools(), so those tools are bridged without a title. falling back to the registry entry of the same name would keep it, e.g. pass the registry entries into metadata and use text(tool.title) ?? text(entry?.title). an assertion on title in the site-polyfill test would cover it.
| try { | ||
| // Rejects when the page already registered the name natively, which | ||
| // keeps the native tool. | ||
| await native.registerTool({...item.entry, execute: execute(name)}, {signal: controller.signal}); |
There was a problem hiding this comment.
question: this reserves the name in the native registry, so if the page registers the same name natively after a listing has bridged it, the page's own document.modelContext.registerTool rejects as a duplicate. native-first is covered (shared_name), native-later isn't. is that ordering acceptable? the case i'd worry about is a page that registers natively later in the session, after a listing has already bridged its polyfill tools. if it's intentional, a line in this comment would help.
smaller: the catch treats any rejection as a native collision (bad schema, etc.) and retries it on every listing. that's fine, the comment just reads as if collisions are the only case.
| } | ||
| if (isFunction(callTool)) { | ||
| // Site polyfills take (name, input); MCP-style polyfills take ({name, arguments}). | ||
| if (callTool.length >= 2) return callTool.call(context, name, input); |
There was a problem hiding this comment.
nit, non-blocking: callTool.length is 1 for callTool(name, input = {}) and 0 for (...args) or most wrappers, so those get the {name, arguments} form. only matters for polyfills without a registry entry, since that path runs first.
|
|
||
| failed := invoke(tools["failing_tool"].ToolRef, map[string]any{}) | ||
| require.Equal(t, http.StatusOK, failed.StatusCode(), "%s", failed.Body) | ||
| require.Equal(t, instanceoapi.WebMCPInvocationResultStatusError, failed.JSON200.Status) |
There was a problem hiding this comment.
the errorText == "nothing to do" assertion was dropped in the rewrite. does the page's thrown message still reach the caller through WebMCP.toolResponded, or does the native domain replace it? if callers now get a generic error, a note in the PR body would help, since the previous revision returned the page's message.
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.

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.navigator.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.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 invocation, 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, invocation, unregistration, navigation, stale refs).