Skip to content

Surface tools registered through a navigator.modelContext polyfill - #416

Open
rgarcia wants to merge 4 commits into
mainfrom
hypeship/webmcp-polyfill-tools
Open

rgarcia wants to merge 4 commits into
mainfrom
hypeship/webmcp-polyfill-tools

Conversation

@rgarcia

@rgarcia rgarcia commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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 of document.modelContext (spec: webmachinelearning/webmcp#177). A polyfill that does not forward to the native registry is invisible to the CDP WebMCP domain, so GET /webmcp/tools and webmcp.listTools() list nothing even though await navigator.modelContext.listTools() shows the tools in page context.

This change copies those tools into the frame's native document.modelContext registry. From there the CDP WebMCP domain lists, invokes, and removes them like any other page tool; the API and result shapes are unchanged.

  • On every tool listing, webmcpclient syncs 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's contentWindow.
  • The bridge is created with Runtime.callFunctionOn and is reachable only through the client's remote object handle, so no globals or prototypes change on the page, and neither Runtime.enable nor DOM.enable is used. Pages that call document.modelContext.getTools() do see the bridged tools, the same as custom page tools today.
  • Each sync registers new polyfill tools, unregisters removed or changed ones, and resets when a same-origin child frame navigates. A name the page already registered natively keeps the native tool. A navigator.modelContext that is the native registry itself is skipped.
  • Recognized polyfill conventions: listTools() / getTools() (array or {tools}), registries on _registeredTools, _tools, tools, or window.__webmcp.tools, and execution through a registry entry's execute, callTool(name, input), callTool({name, arguments}), or executeTool(name, json). Metadata is copied as JSON with per-tool, total-list, and output size bounds.
  • browsersurface now records which session owns each frame and exposes Tracker.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 that Runtime.enable / DOM.enable are never sent.
  • node --test runtime/webmcp-polyfill.test.ts and make runtime-typecheck: runs the bridge against site-style, MCP-style, and registry-only polyfills, native collisions, malformed entries, output bounds, and document replacement.
  • TestPlaywrightExecuteAPI e2e against a locally built headless image: the existing declarative and custom subtests plus the new WebMCPPolyfill subtest (late-installing polyfill, srcdoc child frame, native precedence, invocation, unregistration, navigation, stale refs).

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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread server/lib/browsersurface/frames.go
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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread server/lib/webmcpclient/polyfill.go Outdated
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread server/lib/webmcpclient/polyfill.go Outdated

@masnwilliams masnwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 masnwilliams left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

2 participants