diff --git a/server/e2e/e2e_playwright_test.go b/server/e2e/e2e_playwright_test.go
index a1fb61bd..cbe1f7f9 100644
--- a/server/e2e/e2e_playwright_test.go
+++ b/server/e2e/e2e_playwright_test.go
@@ -265,6 +265,9 @@ func TestPlaywrightExecuteAPI(t *testing.T) {
t.Run("WebMCPCustomNavigation", func(t *testing.T) {
testCustomWebMCPInvokesAcrossNavigation(t, ctx, client)
})
+ t.Run("WebMCPPolyfill", func(t *testing.T) {
+ testWebMCPPolyfill(t, ctx, client)
+ })
}
func TestPlaywrightExecuteTimeoutReturnsPromptlyAndRecovers(t *testing.T) {
diff --git a/server/e2e/e2e_webmcp_polyfill_test.go b/server/e2e/e2e_webmcp_polyfill_test.go
new file mode 100644
index 00000000..db7c6797
--- /dev/null
+++ b/server/e2e/e2e_webmcp_polyfill_test.go
@@ -0,0 +1,157 @@
+package e2e
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ instanceoapi "github.com/kernel/kernel-images/server/lib/oapi"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// testWebMCPPolyfill covers tools that a page registers through its own
+// navigator.modelContext polyfill instead of the native document.modelContext
+// registry: late installation, same-origin embedded frames, native precedence
+// for a shared name, invocation results, unregistration, and navigation.
+func testWebMCPPolyfill(t *testing.T, ctx context.Context, client *instanceoapi.ClientWithResponses) {
+ t.Helper()
+
+ fixture, err := os.ReadFile("testdata/webmcp/polyfill.html")
+ require.NoError(t, err)
+ written, err := client.WriteFileWithBodyWithResponse(ctx,
+ &instanceoapi.WriteFileParams{Path: "/tmp/polyfill.html"}, "text/html", bytes.NewReader(fixture))
+ require.NoError(t, err)
+ require.Equal(t, http.StatusCreated, written.StatusCode(), "%s", written.Body)
+
+ const pageURL = "file:///tmp/polyfill.html"
+ var readyState string
+ executeWebMCPPlaywright(t, ctx, client, `
+ await page.goto('file:///tmp/polyfill.html', { waitUntil: 'load' });
+ return page.evaluate(() => document.readyState);
+ `, &readyState)
+ require.Equal(t, "complete", readyState)
+
+ toolsByName := func(collect *assert.CollectT) map[string]instanceoapi.WebMCPTool {
+ rsp, err := client.GetWebMCPToolsWithResponse(ctx, &instanceoapi.GetWebMCPToolsParams{})
+ if !assert.NoError(collect, err) || !assert.Equal(collect, http.StatusOK, rsp.StatusCode(), "%s", rsp.Body) || rsp.JSON200 == nil {
+ return nil
+ }
+ tools := make(map[string]instanceoapi.WebMCPTool)
+ for _, tool := range rsp.JSON200.Tools {
+ if tool.Source.PageUrl == pageURL {
+ tools[tool.Tool.Name] = tool
+ }
+ }
+ return tools
+ }
+
+ // The polyfill installs 1.5s after load; discovery picks it up on a later listing.
+ var tools map[string]instanceoapi.WebMCPTool
+ require.EventuallyWithT(t, func(collect *assert.CollectT) {
+ tools = toolsByName(collect)
+ for _, name := range []string{"search_items", "shared_name", "failing_tool", "navigate_away", "frame_tool"} {
+ assert.Contains(collect, tools, name)
+ }
+ }, 15*time.Second, 250*time.Millisecond)
+ t.Logf("GET /webmcp/tools: %d tools on %s", len(tools), pageURL)
+
+ search := tools["search_items"]
+ require.Nil(t, search.Source.Frame)
+ require.Nil(t, search.Source.Custom)
+ require.Equal(t, "Search the catalog.", search.Tool.Description)
+ require.Equal(t, []any{"query"}, search.Tool.InputSchema["required"])
+
+ // The same name registered natively lists once, as the native tool.
+ shared := tools["shared_name"]
+ require.Equal(t, "Native copy.", shared.Tool.Description)
+
+ frameTool := tools["frame_tool"]
+ require.NotNil(t, frameTool.Source.Frame)
+ require.Equal(t, "about:srcdoc", frameTool.Source.Frame.Url)
+
+ timeout := 10
+ invoke := func(ref string, input map[string]any) *instanceoapi.InvokeWebMCPToolResponse {
+ rsp, err := client.InvokeWebMCPToolWithResponse(ctx, instanceoapi.WebMCPInvokeRequest{
+ ToolRef: ref, Input: input, TimeoutSec: &timeout,
+ })
+ require.NoError(t, err)
+ t.Logf("POST /webmcp/invoke %s: %s", ref, rsp.Body)
+ return rsp
+ }
+
+ searched := invoke(search.ToolRef, map[string]any{"query": "lamp"})
+ require.Equal(t, http.StatusOK, searched.StatusCode(), "%s", searched.Body)
+ require.NotNil(t, searched.JSON200)
+ require.Equal(t, instanceoapi.WebMCPInvocationResultStatusCompleted, searched.JSON200.Status)
+ require.NotEmpty(t, searched.JSON200.InvocationId)
+ require.Equal(t, map[string]any{"results": []any{"match for lamp"}, "page": "/tmp/polyfill.html"}, searched.JSON200.Output)
+
+ var logText string
+ executeWebMCPPlaywright(t, ctx, client, `return page.evaluate(() => document.querySelector('#log').textContent);`, &logText)
+ require.Equal(t, "searched lamp", logText)
+
+ framed := invoke(frameTool.ToolRef, map[string]any{"item": 1})
+ require.Equal(t, http.StatusOK, framed.StatusCode(), "%s", framed.Body)
+ require.Equal(t, map[string]any{"frame": "Embedded polyfill catalog", "input": map[string]any{"item": float64(1)}}, framed.JSON200.Output)
+
+ native := invoke(shared.ToolRef, map[string]any{})
+ require.Equal(t, http.StatusOK, native.StatusCode(), "%s", native.Body)
+ require.Equal(t, map[string]any{"source": "native"}, native.JSON200.Output)
+
+ 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)
+ require.NotNil(t, failed.JSON200.ErrorText)
+ require.Equal(t, "Error: nothing to do", *failed.JSON200.ErrorText)
+
+ // Unregistration through the polyfill drops the tool on the next listing,
+ // and the surviving tool keeps its reference.
+ var clicked bool
+ executeWebMCPPlaywright(t, ctx, client, `await page.click('#unregister'); return true;`, &clicked)
+ require.True(t, clicked)
+ require.EventuallyWithT(t, func(collect *assert.CollectT) {
+ tools = toolsByName(collect)
+ assert.NotContains(collect, tools, "failing_tool")
+ assert.Equal(collect, search.ToolRef, tools["search_items"].ToolRef)
+ }, 10*time.Second, 250*time.Millisecond)
+
+ // The page can still register a bridged name natively; the native tool is
+ // then listed and invoked in place of the bridged copy.
+ var nativeResult string
+ executeWebMCPPlaywright(t, ctx, client, `
+ await page.click('#register-native');
+ await page.locator('#native-result').filter({hasText: /./}).waitFor();
+ return page.locator('#native-result').textContent();
+ `, &nativeResult)
+ require.Equal(t, "registered", nativeResult)
+ require.EventuallyWithT(t, func(collect *assert.CollectT) {
+ tools = toolsByName(collect)
+ assert.Equal(collect, "Native search.", tools["search_items"].Tool.Description)
+ }, 10*time.Second, 250*time.Millisecond)
+ nativeSearch := invoke(tools["search_items"].ToolRef, map[string]any{})
+ require.Equal(t, http.StatusOK, nativeSearch.StatusCode(), "%s", nativeSearch.Body)
+ require.Equal(t, map[string]any{"source": "native"}, nativeSearch.JSON200.Output)
+
+ // A tool that navigates its document completes like a native one and the
+ // old registrations disappear with the document.
+ tabID := search.Source.TabId
+ navigated := invoke(tools["navigate_away"].ToolRef, map[string]any{})
+ require.Equal(t, http.StatusOK, navigated.StatusCode(), "%s", navigated.Body)
+ require.Equal(t, instanceoapi.WebMCPInvocationResultStatusCompleted, navigated.JSON200.Status)
+ require.Equal(t, []any{}, navigated.JSON200.Output)
+ require.EventuallyWithT(t, func(collect *assert.CollectT) {
+ rsp, err := client.GetWebMCPToolsWithResponse(ctx, &instanceoapi.GetWebMCPToolsParams{})
+ if !assert.NoError(collect, err) || !assert.Equal(collect, http.StatusOK, rsp.StatusCode(), "%s", rsp.Body) || rsp.JSON200 == nil {
+ return
+ }
+ for _, tool := range rsp.JSON200.Tools {
+ assert.NotEqual(collect, tabID, tool.Source.TabId, "%s survived navigation", tool.Tool.Name)
+ }
+ }, 10*time.Second, 250*time.Millisecond)
+ stale := invoke(tools["search_items"].ToolRef, map[string]any{"query": "x"})
+ require.Equal(t, http.StatusNotFound, stale.StatusCode(), "%s", stale.Body)
+}
diff --git a/server/e2e/testdata/webmcp/README.md b/server/e2e/testdata/webmcp/README.md
index 1350b240..abf75398 100644
--- a/server/e2e/testdata/webmcp/README.md
+++ b/server/e2e/testdata/webmcp/README.md
@@ -14,3 +14,15 @@ E2E_CHROMIUM_HEADLESS_IMAGE=kernel-headless-test \
```
Chromium 152.0.7977.42 with the image's default WebMCP flags exposes `reserve_table` with string name/date fields, a numeric party size with bounds, and a seating enum. It adds a date format hint to the field description. Both top-level and embedded invocations return `completed` and the submitted values; the tests also read the DOM through `/playwright/execute` to verify native agent submission occurred exactly once. Discovery and invocation responses are logged by the test. Missing declarative tools fail the test rather than silently skipping supported behavior.
+
+# Polyfill WebMCP fixture
+
+`polyfill.html` mirrors sites that ship their own tools through a `navigator.modelContext`
+polyfill (a registry object plus `registerTool`, `unregisterTool`, `listTools`, and
+`callTool`). The polyfill installs 1.5s after load, the page also registers `shared_name`
+natively so native precedence is covered, and a `srcdoc` child frame carries a Map-based
+registry of its own. Run with:
+
+```bash
+GOFLAGS='-run=TestPlaywrightExecuteAPI/WebMCPPolyfill -count=1' make test-e2e
+```
diff --git a/server/e2e/testdata/webmcp/polyfill.html b/server/e2e/testdata/webmcp/polyfill.html
new file mode 100644
index 00000000..71cfe7a0
--- /dev/null
+++ b/server/e2e/testdata/webmcp/polyfill.html
@@ -0,0 +1,135 @@
+
+
+
+
+ Polyfill catalog
+
+
+ Catalog
+
+
+
+
+
+
+
+
+
+
diff --git a/server/lib/browsersurface/frames.go b/server/lib/browsersurface/frames.go
index e8b4a770..233fe931 100644
--- a/server/lib/browsersurface/frames.go
+++ b/server/lib/browsersurface/frames.go
@@ -165,7 +165,7 @@ func (t *Tracker) initializeSession(sessionID string) {
result.FrameTree.Frame.ParentID = existing.parentID
}
}
- t.addFrameTreeLocked(sess.tabID, result.FrameTree)
+ t.addFrameTreeLocked(sess.tabID, sessionID, result.FrameTree)
if sess.target.Type == "page" {
if trackedTab := t.tabs[sess.tabID]; trackedTab != nil {
trackedTab.rootFrameID = result.FrameTree.Frame.ID
@@ -189,14 +189,14 @@ func (t *Tracker) failSessionInitialization(sessionID string, err error) {
}
}
-func (t *Tracker) addFrameTreeLocked(tabID int, tree frameTree) {
- t.upsertFrameLocked(tabID, tree.Frame)
+func (t *Tracker) addFrameTreeLocked(tabID int, sessionID string, tree frameTree) {
+ t.upsertFrameLocked(tabID, sessionID, tree.Frame)
for _, child := range tree.ChildFrames {
- t.addFrameTreeLocked(tabID, child)
+ t.addFrameTreeLocked(tabID, sessionID, child)
}
}
-func (t *Tracker) upsertFrameLocked(tabID int, info frameInfo) {
+func (t *Tracker) upsertFrameLocked(tabID int, sessionID string, info frameInfo) {
tracked := t.frames[info.ID]
if tracked == nil {
publicID := 0
@@ -212,6 +212,7 @@ func (t *Tracker) upsertFrameLocked(tabID int, info frameInfo) {
}
tracked.parentID = info.ParentID
tracked.tabID = tabID
+ tracked.sessionID = sessionID
tracked.url = info.URL
}
@@ -255,7 +256,7 @@ func (t *Tracker) bindSessionsLocked() {
func (t *Tracker) attachFrame(sessionID, frameID, parentFrameID string) {
t.stateMu.Lock()
if sess := t.sessions[sessionID]; sess != nil && sess.tabID != 0 {
- t.upsertFrameLocked(sess.tabID, frameInfo{ID: frameID, ParentID: parentFrameID})
+ t.upsertFrameLocked(sess.tabID, sessionID, frameInfo{ID: frameID, ParentID: parentFrameID})
t.bindSessionsLocked()
}
t.stateMu.Unlock()
@@ -272,7 +273,7 @@ func (t *Tracker) navigateFrame(sessionID string, info frameInfo) {
info.ParentID = existing.parentID
}
}
- t.upsertFrameLocked(sess.tabID, info)
+ t.upsertFrameLocked(sess.tabID, sessionID, info)
if trackedTab := t.tabs[sess.tabID]; trackedTab != nil && trackedTab.rootFrameID == info.ID {
trackedTab.url = info.URL
}
@@ -348,6 +349,11 @@ func (t *Tracker) removeSessionLocked(sessionID string) []string {
removed = append(removed, id)
}
}
+ for _, tracked := range t.frames {
+ if toRemove[tracked.sessionID] {
+ tracked.sessionID = ""
+ }
+ }
sort.Strings(removed)
return removed
}
diff --git a/server/lib/browsersurface/iframe_initialization_test.go b/server/lib/browsersurface/iframe_initialization_test.go
index 6f2c2e90..bf2717cd 100644
--- a/server/lib/browsersurface/iframe_initialization_test.go
+++ b/server/lib/browsersurface/iframe_initialization_test.go
@@ -15,7 +15,7 @@ func TestExplicitIframeInitializationDoesNotWaitForParent(t *testing.T) {
tracker.stateMu.Lock()
// The page's frame is known, but its session initialization is still pending.
tracker.sessions["session-a"] = &session{id: "session-a", target: page, tabID: tabID, initializing: true}
- tracker.upsertFrameLocked(tabID, frameInfo{ID: "page-a"})
+ tracker.upsertFrameLocked(tabID, "session-a", frameInfo{ID: "page-a"})
tracker.stateMu.Unlock()
tracker.addSession("oopif-session", "", targetInfo{
TargetID: "oopif", Type: "iframe", ParentFrameID: "page-a",
diff --git a/server/lib/browsersurface/tracker.go b/server/lib/browsersurface/tracker.go
index 2e7e6502..f55d2a89 100644
--- a/server/lib/browsersurface/tracker.go
+++ b/server/lib/browsersurface/tracker.go
@@ -296,6 +296,55 @@ func (t *Tracker) Snapshot() Snapshot {
return snapshot
}
+// SessionFrames lists every frame of an initialized page or iframe session,
+// roots first, so callers can evaluate in each document through the session
+// that owns it.
+func (t *Tracker) SessionFrames() []SessionFrame {
+ t.stateMu.RLock()
+ defer t.stateMu.RUnlock()
+ // An out-of-process iframe session owns its own frame regardless of which
+ // session reported the frame last.
+ iframeSessions := make(map[string]*session)
+ for _, sess := range t.sessions {
+ if sess.target.Type == "iframe" {
+ iframeSessions[sess.target.TargetID] = sess
+ }
+ }
+ owner := func(tracked *frame) *session {
+ if sess := iframeSessions[tracked.rawID]; sess != nil {
+ return sess
+ }
+ return t.sessions[tracked.sessionID]
+ }
+ frames := make([]SessionFrame, 0, len(t.frames))
+ for _, tracked := range t.frames {
+ sess := owner(tracked)
+ if sess == nil || !sess.initialized || sess.tabID == 0 || sess.tabID != tracked.tabID {
+ continue
+ }
+ root := true
+ if parent := t.frames[tracked.parentID]; parent != nil {
+ root = owner(parent) != sess
+ }
+ frames = append(frames, SessionFrame{
+ SessionID: sess.id,
+ FrameID: tracked.rawID,
+ Root: root,
+ URL: tracked.url,
+ })
+ }
+ sort.Slice(frames, func(i, j int) bool {
+ if frames[i].SessionID != frames[j].SessionID {
+ return frames[i].SessionID < frames[j].SessionID
+ }
+ if frames[i].Root != frames[j].Root {
+ return frames[i].Root
+ }
+ return frames[i].FrameID < frames[j].FrameID
+ })
+ return frames
+}
+
func (t *Tracker) SessionExists(sessionID string) bool {
t.stateMu.RLock()
defer t.stateMu.RUnlock()
diff --git a/server/lib/browsersurface/tracker_test.go b/server/lib/browsersurface/tracker_test.go
index a1af6806..ca2cf582 100644
--- a/server/lib/browsersurface/tracker_test.go
+++ b/server/lib/browsersurface/tracker_test.go
@@ -341,6 +341,31 @@ func TestTrackerMapsBrowserSurfaceAndPublishesLifecycleEvents(t *testing.T) {
require.Equal(t, 2, moved.WindowID)
}
+func TestSessionFramesReportOwnersAndRoots(t *testing.T) {
+ protocol := newFakeProtocol()
+ tracker := New(protocol)
+ require.NoError(t, tracker.Start(context.Background()))
+ protocol.emitTarget("Target.targetCreated", map[string]any{
+ "targetInfo": map[string]any{
+ "targetId": "oopif", "type": "iframe", "url": "https://cross-origin.example/",
+ "parentFrameId": "root-a",
+ },
+ })
+
+ var frames []SessionFrame
+ require.Eventually(t, func() bool {
+ frames = tracker.SessionFrames()
+ return len(frames) == 5
+ }, time.Second, 10*time.Millisecond)
+ require.Equal(t, []SessionFrame{
+ {SessionID: "oopif-session", FrameID: "oopif", Root: true, URL: "https://cross-origin.example/"},
+ {SessionID: "session-a", FrameID: "root-a", Root: true, URL: "https://store.example/"},
+ {SessionID: "session-a", FrameID: "inner", URL: "https://bank.example/"},
+ {SessionID: "session-a", FrameID: "outer", URL: "https://payments.example/"},
+ {SessionID: "session-b", FrameID: "root-b", Root: true, URL: "https://travel.example/"},
+ }, frames)
+}
+
func TestTrackerPreservesFramesDuringProcessSwap(t *testing.T) {
protocol := newFakeProtocol()
tracker := New(protocol)
diff --git a/server/lib/browsersurface/types.go b/server/lib/browsersurface/types.go
index 1684632e..ccf36c45 100644
--- a/server/lib/browsersurface/types.go
+++ b/server/lib/browsersurface/types.go
@@ -76,6 +76,17 @@ type FrameLocation struct {
URL string
}
+// SessionFrame is a frame whose document is reachable through the session
+// that reported it. Root frames are the main frame of a page target or the
+// frame of an out-of-process iframe target; other frames are same-process
+// children of the session's root.
+type SessionFrame struct {
+ SessionID string
+ FrameID string
+ Root bool
+ URL string
+}
+
type Location struct {
WindowID int
TabID int
@@ -132,9 +143,10 @@ type tab struct {
}
type frame struct {
- id int
- rawID string
- parentID string
- tabID int
- url string
+ id int
+ rawID string
+ parentID string
+ tabID int
+ sessionID string
+ url string
}
diff --git a/server/lib/webmcpclient/client.go b/server/lib/webmcpclient/client.go
index 01425e88..10dc02ae 100644
--- a/server/lib/webmcpclient/client.go
+++ b/server/lib/webmcpclient/client.go
@@ -24,6 +24,7 @@ const (
maxToolsPerSession = 256
maxCompletedInvocations = 256
maxAbandonedInvocations = 256
+ maxExceptionTextBytes = 64 << 10
)
type connection struct {
@@ -41,6 +42,8 @@ type connection struct {
invocations map[invocationKey]invocationResponse
waitingInvocations map[invocationKey]string
abandonedInvocations map[invocationKey]time.Time
+ polyfillBridges map[string]*polyfillBridge
+ polyfillSyncMu sync.Mutex
stateChangedCh chan struct{}
logger *slog.Logger
@@ -63,6 +66,7 @@ func newConnection(protocol *cdpclient.Client) *connection {
invocations: make(map[invocationKey]invocationResponse),
waitingInvocations: make(map[invocationKey]string),
abandonedInvocations: make(map[invocationKey]time.Time),
+ polyfillBridges: make(map[string]*polyfillBridge),
stateChangedCh: make(chan struct{}, 1),
logger: slog.Default(),
eventsCancel: cancel,
@@ -184,6 +188,9 @@ func (c *connection) handleProtocolEvent(message cdpclient.Message) {
case "WebMCP.toolResponded":
var response invocationResponse
if json.Unmarshal(message.Params, &response) == nil {
+ if response.ErrorText == "" && response.Exception != nil {
+ response.ErrorText = exceptionText(response.Exception)
+ }
key := invocationKey{sessionID: message.SessionID, invocationID: response.InvocationID}
c.stateMu.Lock()
c.pruneAbandonedInvocationsLocked()
@@ -204,6 +211,26 @@ func (c *connection) handleProtocolEvent(message cdpclient.Message) {
}
}
+// exceptionText reports what a page tool threw, bounded to
+// maxExceptionTextBytes. Chromium leaves errorText empty and describes Error
+// objects with their stack, so only the message line is kept.
+func exceptionText(exception *exceptionDetails) string {
+ var text string
+ if exception.Description != "" {
+ text, _, _ = strings.Cut(exception.Description, "\n at ")
+ } else if value, ok := exception.Value.(string); ok {
+ text = value
+ } else if exception.Value != nil {
+ if encoded, err := json.Marshal(exception.Value); err == nil {
+ text = string(encoded)
+ }
+ }
+ if len(text) > maxExceptionTextBytes {
+ text = strings.ToValidUTF8(text[:maxExceptionTextBytes], "")
+ }
+ return text
+}
+
// customToolIdentity decodes the hidden name used for a custom registration.
// The browser registers custom.. to avoid collisions with page
// tools, while discovery exposes the original name and the generated ID separately.
@@ -258,7 +285,11 @@ func (c *connection) addTools(sessionID string, tools []toolEvent) {
c.toolRefs[key] = ref
tracked++
}
- customID, name := customToolIdentity(tool.Name)
+ var customID string
+ name, bridged := strings.CutPrefix(tool.Name, polyfillToolNamePrefix)
+ if !bridged {
+ customID, name = customToolIdentity(tool.Name)
+ }
c.tools[ref] = ®isteredTool{
ref: ref,
sessionID: sessionID,
@@ -270,6 +301,7 @@ func (c *connection) addTools(sessionID string, tools []toolEvent) {
customID: customID,
frameID: tool.FrameID,
declarative: tool.BackendNodeID != nil,
+ bridged: bridged,
}
}
c.signalStateChanged()
@@ -278,8 +310,19 @@ func (c *connection) addTools(sessionID string, tools []toolEvent) {
func (c *connection) toolsSnapshot() []Tool {
c.stateMu.RLock()
defer c.stateMu.RUnlock()
+ // A page that registers a name natively while its polyfill still lists it
+ // keeps the native tool.
+ native := make(map[string]bool)
+ for _, tool := range c.tools {
+ if !tool.bridged && tool.customID == "" {
+ native[toolKey(tool.sessionID, tool.frameID, tool.name)] = true
+ }
+ }
result := make([]Tool, 0, len(c.tools))
for _, tool := range c.tools {
+ if tool.bridged && native[toolKey(tool.sessionID, tool.frameID, tool.name)] {
+ continue
+ }
location, ok := c.surface.Resolve(tool.sessionID, tool.frameID)
if !ok {
continue
@@ -498,7 +541,7 @@ func (c *connection) removeSession(sessionID string) {
}
for ref, tool := range c.tools {
if tool.sessionID == sessionID {
- delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name))
+ delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.registeredName))
delete(c.tools, ref)
}
}
@@ -516,7 +559,7 @@ func (c *connection) abandonFrameInvocationsAcrossSessionsLocked(frameID string)
func (c *connection) removeFrameToolsLocked(sessionID, frameID string) {
for ref, tool := range c.tools {
if tool.sessionID == sessionID && tool.frameID == frameID {
- delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name))
+ delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.registeredName))
delete(c.tools, ref)
}
}
@@ -525,7 +568,7 @@ func (c *connection) removeFrameToolsLocked(sessionID, frameID string) {
func (c *connection) removeFrameToolsAcrossSessionsLocked(frameID string) {
for ref, tool := range c.tools {
if tool.frameID == frameID {
- delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.name))
+ delete(c.toolRefs, toolKey(tool.sessionID, tool.frameID, tool.registeredName))
delete(c.tools, ref)
}
}
diff --git a/server/lib/webmcpclient/client_test.go b/server/lib/webmcpclient/client_test.go
index 70cca9a6..ecc0df15 100644
--- a/server/lib/webmcpclient/client_test.go
+++ b/server/lib/webmcpclient/client_test.go
@@ -67,12 +67,22 @@ type fakeCDP struct {
popupOpen bool
iframeOpen bool
nestedFrameOpen bool
+ childFrameOpen bool
+ polyfillTools map[string][]string
+ bridgedTools map[string]map[string]bool
+ staleBridges map[string]bool
+ bridgesCreated int
+ lastInvokedName string
+ methods map[string]int
write func(any)
}
func newFakeCDP(t *testing.T, omitResponse bool) *fakeCDP {
t.Helper()
- fake := &fakeCDP{enabledSessions: make(map[string]int), omitResponse: omitResponse, toolCount: 1}
+ fake := &fakeCDP{
+ enabledSessions: make(map[string]int), methods: make(map[string]int), omitResponse: omitResponse, toolCount: 1,
+ bridgedTools: make(map[string]map[string]bool), staleBridges: make(map[string]bool),
+ }
fake.server = httptest.NewServer(http.HandlerFunc(fake.serve))
fake.url = "ws" + strings.TrimPrefix(fake.server.URL, "http")
t.Cleanup(fake.server.Close)
@@ -135,6 +145,9 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) {
respond := func(result any) {
write(map[string]any{"id": request.ID, "result": result})
}
+ f.mu.Lock()
+ f.methods[request.Method]++
+ f.mu.Unlock()
switch request.Method {
case "Target.setDiscoverTargets":
respond(map[string]any{})
@@ -211,7 +224,18 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) {
case "Page.enable":
respond(map[string]any{})
case "Page.getFrameTree":
- respond(map[string]any{"frameTree": frameTreeForSession(request.SessionID)})
+ tree := frameTreeForSession(request.SessionID)
+ f.mu.Lock()
+ childFrameOpen := f.childFrameOpen
+ f.mu.Unlock()
+ if childFrameOpen && request.SessionID == "page-session" {
+ tree["childFrames"] = []map[string]any{{"frame": map[string]any{
+ "id": "page-child", "parentId": "page-frame", "loaderId": "child-loader", "url": "https://merchant.example/child",
+ }}}
+ }
+ respond(map[string]any{"frameTree": tree})
+ case "Runtime.evaluate", "Runtime.callFunctionOn", "Runtime.releaseObjectGroup", "DOM.getFrameOwner", "DOM.resolveNode":
+ f.servePolyfill(request, respond, write)
case "WebMCP.enable":
f.mu.Lock()
f.enabledSessions[request.SessionID]++
@@ -248,7 +272,12 @@ func (f *fakeCDP) serve(w http.ResponseWriter, r *http.Request) {
"params": map[string]any{"tools": tools},
})
case "WebMCP.invokeTool":
+ var invokeParams struct {
+ ToolName string `json:"toolName"`
+ }
+ _ = json.Unmarshal(request.Params, &invokeParams)
f.mu.Lock()
+ f.lastInvokedName = invokeParams.ToolName
f.invocationCount++
invocationID := fmt.Sprintf("invocation-%d", f.invocationCount)
closeOnInvoke := f.closeOnInvoke
diff --git a/server/lib/webmcpclient/manager.go b/server/lib/webmcpclient/manager.go
index 93d2495f..4941a907 100644
--- a/server/lib/webmcpclient/manager.go
+++ b/server/lib/webmcpclient/manager.go
@@ -51,6 +51,9 @@ func (m *Manager) Tools(ctx context.Context) ([]Tool, error) {
if !conn.surface.HasTabs() {
return nil, ErrNoPageTarget
}
+ if conn.syncPolyfillTools(ctx) {
+ conn.waitForSettled(ctx)
+ }
return conn.toolsSnapshot(), nil
}
diff --git a/server/lib/webmcpclient/polyfill.go b/server/lib/webmcpclient/polyfill.go
new file mode 100644
index 00000000..e06db528
--- /dev/null
+++ b/server/lib/webmcpclient/polyfill.go
@@ -0,0 +1,261 @@
+package webmcpclient
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/kernel/kernel-images/server/lib/browsersurface"
+ "github.com/kernel/kernel-images/server/lib/cdpclient"
+ "github.com/nrednav/cuid2"
+)
+
+// Sites that ship their own WebMCP tools before the browser exposes the
+// native registry install a polyfill on navigator.modelContext. Chromium
+// dropped that alias in favor of document.modelContext, so nothing such a
+// polyfill registers reaches the CDP WebMCP domain. polyfill_bridge.js copies
+// those tools into the frame's native registry, after which they are listed,
+// invoked, and removed like any other page tool.
+//
+//go:embed polyfill_bridge.js
+var polyfillBridgeSource string
+
+// polyfillToolNamePrefix must match BRIDGED_PREFIX in polyfill_bridge.js.
+// Bridged tools register under this prefix so the page can still register
+// the original name natively; discovery strips it.
+const polyfillToolNamePrefix = "polyfill."
+
+const (
+ polyfillSyncTimeout = 2 * time.Second
+ polyfillSyncConcurrency = 8
+ polyfillReleaseTimeout = time.Second
+)
+
+// polyfillBridge is a remote object handle for one frame's bridge. The handle
+// lives in the frame's execution context and becomes invalid when it goes away.
+type polyfillBridge struct {
+ objectID string
+ group string
+}
+
+func polyfillBridgeKey(sessionID, frameID string) string {
+ return sessionID + "\x00" + frameID
+}
+
+// polyfillFrameURL excludes browser-internal documents; every web document,
+// including about:blank and srcdoc frames that inherit their parent's origin,
+// can carry a polyfill.
+func polyfillFrameURL(url string) bool {
+ return !strings.HasPrefix(url, "chrome") && !strings.HasPrefix(url, "devtools://")
+}
+
+// syncPolyfillTools brings every tracked frame's bridged registrations up to
+// date with its polyfill and reports whether any native registry changed.
+func (c *connection) syncPolyfillTools(ctx context.Context) bool {
+ // One bridge per document: concurrent listings would otherwise create two
+ // and leave the first one's registrations unmanaged.
+ c.polyfillSyncMu.Lock()
+ defer c.polyfillSyncMu.Unlock()
+ frames := c.surface.SessionFrames()
+ live := make(map[string]bool, len(frames))
+ for _, frame := range frames {
+ live[polyfillBridgeKey(frame.SessionID, frame.FrameID)] = true
+ }
+ c.stateMu.Lock()
+ for key := range c.polyfillBridges {
+ if !live[key] {
+ delete(c.polyfillBridges, key)
+ }
+ }
+ c.stateMu.Unlock()
+
+ ctx, cancel := context.WithTimeout(ctx, polyfillSyncTimeout)
+ defer cancel()
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ changed bool
+ )
+ semaphore := make(chan struct{}, polyfillSyncConcurrency)
+ for _, frame := range frames {
+ if !polyfillFrameURL(frame.URL) {
+ continue
+ }
+ wg.Add(1)
+ go func(frame browsersurface.SessionFrame) {
+ defer wg.Done()
+ semaphore <- struct{}{}
+ defer func() { <-semaphore }()
+ if c.syncPolyfillFrame(ctx, frame) {
+ mu.Lock()
+ changed = true
+ mu.Unlock()
+ }
+ }(frame)
+ }
+ wg.Wait()
+ return changed
+}
+
+func (c *connection) syncPolyfillFrame(ctx context.Context, frame browsersurface.SessionFrame) bool {
+ // A handle from a replaced document fails once; the retry bridges the
+ // frame's current document in the same listing.
+ for attempt := 0; attempt < 2; attempt++ {
+ changed, stale := c.syncPolyfillBridge(ctx, frame)
+ if !stale {
+ return changed
+ }
+ }
+ return false
+}
+
+// syncPolyfillBridge syncs the frame's bridge, creating it when needed, and
+// reports whether the bridge's handle was stale.
+func (c *connection) syncPolyfillBridge(ctx context.Context, frame browsersurface.SessionFrame) (changed, stale bool) {
+ key := polyfillBridgeKey(frame.SessionID, frame.FrameID)
+ c.stateMu.RLock()
+ bridge := c.polyfillBridges[key]
+ c.stateMu.RUnlock()
+ if bridge == nil {
+ var err error
+ if bridge, err = c.createPolyfillBridge(ctx, frame); err != nil {
+ return false, false
+ }
+ c.stateMu.Lock()
+ c.polyfillBridges[key] = bridge
+ c.stateMu.Unlock()
+ }
+
+ raw, err := c.surface.Send(ctx, "Runtime.callFunctionOn", map[string]any{
+ "objectId": bridge.objectID,
+ "functionDeclaration": "function() { return this.sync(); }",
+ "awaitPromise": true,
+ "returnByValue": true,
+ }, frame.SessionID)
+ if err == nil {
+ var result evaluationResult
+ if err = json.Unmarshal(raw, &result); err == nil && result.ExceptionDetails != nil {
+ err = errors.New("WebMCP: polyfill bridge threw")
+ }
+ if err == nil {
+ return string(result.Result.Value) == "true", false
+ }
+ }
+ // A protocol error means the handle's document is gone. Timeouts keep the
+ // handle, which may still own registrations.
+ var protocolErr *cdpclient.Error
+ if !errors.As(err, &protocolErr) {
+ return false, false
+ }
+ c.stateMu.Lock()
+ if c.polyfillBridges[key] == bridge {
+ delete(c.polyfillBridges, key)
+ }
+ c.stateMu.Unlock()
+ go c.releaseObjectGroup(frame.SessionID, bridge.group)
+ return false, true
+}
+
+type evaluationResult struct {
+ Result struct {
+ ObjectID string `json:"objectId"`
+ Value json.RawMessage `json:"value"`
+ } `json:"result"`
+ ExceptionDetails *json.RawMessage `json:"exceptionDetails"`
+}
+
+func (c *connection) createPolyfillBridge(ctx context.Context, frame browsersurface.SessionFrame) (*polyfillBridge, error) {
+ group := "kernel-webmcp-polyfill-" + cuid2.Generate()
+ windowID, err := c.frameWindow(ctx, frame, group)
+ if err == nil {
+ var raw json.RawMessage
+ raw, err = c.surface.Send(ctx, "Runtime.callFunctionOn", map[string]any{
+ "objectId": windowID,
+ "functionDeclaration": polyfillBridgeSource,
+ "objectGroup": group,
+ }, frame.SessionID)
+ if err == nil {
+ var result evaluationResult
+ if err = json.Unmarshal(raw, &result); err == nil {
+ if result.ExceptionDetails != nil || result.Result.ObjectID == "" {
+ err = errors.New("WebMCP: polyfill bridge is unavailable")
+ } else {
+ return &polyfillBridge{objectID: result.Result.ObjectID, group: group}, nil
+ }
+ }
+ }
+ }
+ go c.releaseObjectGroup(frame.SessionID, group)
+ return nil, err
+}
+
+func (c *connection) releaseObjectGroup(sessionID, group string) {
+ ctx, cancel := context.WithTimeout(context.Background(), polyfillReleaseTimeout)
+ defer cancel()
+ _, _ = c.surface.Send(ctx, "Runtime.releaseObjectGroup", map[string]any{"objectGroup": group}, sessionID)
+}
+
+// frameWindow returns a remote object handle for the frame's Window. Root
+// frames evaluate directly in their session; same-process child frames are
+// reached through their owner element's contentWindow, which only succeeds
+// for same-origin documents.
+func (c *connection) frameWindow(ctx context.Context, frame browsersurface.SessionFrame, group string) (string, error) {
+ if frame.Root {
+ raw, err := c.surface.Send(ctx, "Runtime.evaluate", map[string]any{
+ "expression": "window",
+ "objectGroup": group,
+ }, frame.SessionID)
+ if err != nil {
+ return "", err
+ }
+ var result evaluationResult
+ if err := json.Unmarshal(raw, &result); err != nil || result.Result.ObjectID == "" {
+ return "", fmt.Errorf("WebMCP: frame window is unavailable")
+ }
+ return result.Result.ObjectID, nil
+ }
+
+ raw, err := c.surface.Send(ctx, "DOM.getFrameOwner", map[string]any{"frameId": frame.FrameID}, frame.SessionID)
+ if err != nil {
+ return "", err
+ }
+ var owner struct {
+ BackendNodeID int `json:"backendNodeId"`
+ }
+ if err := json.Unmarshal(raw, &owner); err != nil || owner.BackendNodeID == 0 {
+ return "", fmt.Errorf("WebMCP: frame owner is unavailable")
+ }
+ raw, err = c.surface.Send(ctx, "DOM.resolveNode", map[string]any{
+ "backendNodeId": owner.BackendNodeID,
+ "objectGroup": group,
+ }, frame.SessionID)
+ if err != nil {
+ return "", err
+ }
+ var node struct {
+ Object struct {
+ ObjectID string `json:"objectId"`
+ } `json:"object"`
+ }
+ if err := json.Unmarshal(raw, &node); err != nil || node.Object.ObjectID == "" {
+ return "", fmt.Errorf("WebMCP: frame owner is unavailable")
+ }
+ raw, err = c.surface.Send(ctx, "Runtime.callFunctionOn", map[string]any{
+ "objectId": node.Object.ObjectID,
+ "functionDeclaration": "function() { return this.contentWindow; }",
+ "objectGroup": group,
+ }, frame.SessionID)
+ if err != nil {
+ return "", err
+ }
+ var window evaluationResult
+ if err := json.Unmarshal(raw, &window); err != nil || window.Result.ObjectID == "" {
+ return "", fmt.Errorf("WebMCP: frame window is unavailable")
+ }
+ return window.Result.ObjectID, nil
+}
diff --git a/server/lib/webmcpclient/polyfill_bridge.js b/server/lib/webmcpclient/polyfill_bridge.js
new file mode 100644
index 00000000..9e2af9e5
--- /dev/null
+++ b/server/lib/webmcpclient/polyfill_bridge.js
@@ -0,0 +1,327 @@
+// Copies tools that a page registers through a JavaScript
+// navigator.modelContext polyfill into the frame's native document.modelContext
+// registry, where the CDP WebMCP domain lists and invokes them like any other
+// page tool. Called with a frame's Window as `this`; the returned bridge is
+// reachable only through the caller's remote object handle, so nothing is
+// stored on the page.
+//
+// Bridged tools register under BRIDGED_PREFIX, never under the page's own
+// names, so the page can still register any name natively later. Once it
+// does, the bridge withdraws its copy.
+function () {
+ const BRIDGED_PREFIX = 'polyfill.';
+ const MAX_TOOLS = 256;
+ const MAX_TEXT = 64 * 1024;
+ const MAX_TOOL_BYTES = 256 * 1024;
+ const MAX_LIST_BYTES = 1024 * 1024;
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
+ const window = this;
+
+ function isObject(value) {
+ return typeof value === 'object' && value !== null;
+ }
+
+ function isFunction(value) {
+ return typeof value === 'function';
+ }
+
+ function isNative(context) {
+ try {
+ return Object.prototype.toString.call(context) === '[object ModelContext]';
+ } catch {
+ return false;
+ }
+ }
+
+ function nativeContext() {
+ try {
+ const context = window.document.modelContext;
+ return isNative(context) ? context : null;
+ } catch {
+ return null;
+ }
+ }
+
+ // The page-created object on navigator.modelContext, or null when it is
+ // absent or is the native registry itself.
+ function polyfill() {
+ let context;
+ try {
+ context = window.navigator.modelContext;
+ } catch {
+ return null;
+ }
+ if (!isObject(context) && !isFunction(context)) return null;
+ if (isNative(context) || context === nativeContext()) return null;
+ return context;
+ }
+
+ function currentDocument() {
+ try {
+ return window.document;
+ } catch {
+ return null;
+ }
+ }
+
+ // Tool registries that polyfills keep next to their public methods.
+ function registryEntries(context) {
+ const candidates = [];
+ for (const key of ['_registeredTools', '_tools', 'tools']) {
+ try {
+ candidates.push(context[key]);
+ } catch {
+ // A throwing accessor is not a registry.
+ }
+ }
+ try {
+ candidates.push(window.__webmcp && window.__webmcp.tools);
+ } catch {
+ // Not a registry either.
+ }
+ for (const registry of candidates) {
+ try {
+ if (isObject(registry) && isFunction(registry.values) && isFunction(registry.get)) {
+ return Array.from(registry.values());
+ }
+ if (Array.isArray(registry)) return registry;
+ if (isObject(registry)) return Object.values(registry);
+ } catch {
+ continue;
+ }
+ }
+ return null;
+ }
+
+ async function listedTools(context) {
+ for (const method of ['listTools', 'getTools']) {
+ let fn;
+ try {
+ fn = context[method];
+ } catch {
+ continue;
+ }
+ if (!isFunction(fn)) continue;
+ try {
+ const result = await fn.call(context);
+ if (Array.isArray(result)) return result;
+ if (isObject(result) && Array.isArray(result.tools)) return result.tools;
+ } catch {
+ // Fall through to the next source.
+ }
+ }
+ return registryEntries(context);
+ }
+
+ function text(value) {
+ if (typeof value !== 'string') return undefined;
+ return value.length > MAX_TEXT ? value.slice(0, MAX_TEXT) : value;
+ }
+
+ function schema(value) {
+ if (typeof value === 'string') {
+ try {
+ value = JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+ }
+ return isObject(value) && !Array.isArray(value) ? value : undefined;
+ }
+
+ // A JSON copy of the tool's metadata, so nothing page-owned reaches the
+ // native registry except the data itself.
+ function metadata(tool, registered) {
+ try {
+ if (!isObject(tool)) return null;
+ const name = tool.name;
+ if (typeof name !== 'string' || name === '' || name.length > 256 - BRIDGED_PREFIX.length) return null;
+ const entry = {
+ name,
+ // listTools() often drops the title that the registry entry keeps.
+ title: text(tool.title) ?? text(registered?.title),
+ description: text(tool.description) ?? '',
+ // Some polyfills accept the pre-standard `parameters` alias for inputSchema.
+ inputSchema: schema(tool.inputSchema) ?? schema(tool.parameters) ?? {type: 'object'},
+ outputSchema: schema(tool.outputSchema),
+ annotations: isObject(tool.annotations) ? tool.annotations : undefined,
+ };
+ const serialized = JSON.stringify(entry);
+ if (typeof serialized !== 'string' || serialized.length > MAX_TOOL_BYTES) return null;
+ return {entry: JSON.parse(serialized), serialized};
+ } catch {
+ return null;
+ }
+ }
+
+ function registeredByName(context) {
+ const byName = new Map();
+ for (const entry of registryEntries(context) ?? []) {
+ try {
+ if (isObject(entry) && typeof entry.name === 'string') byName.set(entry.name, entry);
+ } catch {
+ continue;
+ }
+ }
+ return byName;
+ }
+
+ async function desiredTools(context) {
+ const tools = await listedTools(context);
+ const desired = new Map();
+ if (!Array.isArray(tools)) return desired;
+ const registered = registeredByName(context);
+ let bytes = 0;
+ for (const tool of tools) {
+ if (desired.size >= MAX_TOOLS) break;
+ let name;
+ try {
+ name = tool.name;
+ } catch {
+ continue;
+ }
+ const item = metadata(tool, registered.get(name));
+ if (!item || desired.has(item.entry.name)) continue;
+ if (bytes + item.serialized.length > MAX_LIST_BYTES) break;
+ bytes += item.serialized.length;
+ desired.set(item.entry.name, item);
+ }
+ return desired;
+ }
+
+ function registryEntry(context, name) {
+ const entries = registryEntries(context);
+ if (!entries) return null;
+ for (const entry of entries) {
+ try {
+ if (isObject(entry) && entry.name === name && isFunction(entry.execute)) return entry;
+ } catch {
+ continue;
+ }
+ }
+ return null;
+ }
+
+ async function run(context, name, input) {
+ // The registry entry's own execute is unambiguous; callTool's shape is not.
+ const entry = registryEntry(context, name);
+ if (entry) return entry.execute(input);
+ let callTool;
+ try {
+ callTool = context.callTool;
+ } catch {
+ callTool = undefined;
+ }
+ if (isFunction(callTool)) {
+ // Site polyfills take (name, input); MCP-style polyfills take ({name, arguments}).
+ // Arity cannot tell defaulted or rest parameters apart, which is why the
+ // registry entry is tried first.
+ if (callTool.length >= 2) return callTool.call(context, name, input);
+ return callTool.call(context, {name, arguments: input});
+ }
+ let executeTool;
+ try {
+ executeTool = context.executeTool;
+ } catch {
+ executeTool = undefined;
+ }
+ if (isFunction(executeTool)) {
+ const output = await executeTool.call(context, name, JSON.stringify(input));
+ if (typeof output !== 'string') return output;
+ try {
+ return JSON.parse(output);
+ } catch {
+ return output;
+ }
+ }
+ throw new Error('the modelContext polyfill does not expose a way to execute tools');
+ }
+
+ function plain(output) {
+ if (output === undefined) return undefined;
+ let serialized;
+ try {
+ serialized = JSON.stringify(output);
+ } catch {
+ throw new Error('tool output is not JSON-serializable');
+ }
+ if (serialized === undefined) return undefined;
+ if (typeof serialized !== 'string') throw new Error('tool output is not JSON-serializable');
+ if (serialized.length > MAX_OUTPUT_BYTES) throw new Error('tool output exceeds 1 MiB');
+ return JSON.parse(serialized);
+ }
+
+ function execute(name) {
+ return async (input) => {
+ const context = polyfill();
+ if (!context) throw new Error('the page no longer exposes a modelContext polyfill');
+ return plain(await run(context, name, input));
+ };
+ }
+
+ // Names the page registered in the native registry itself.
+ async function nativeNames(native) {
+ const names = new Set();
+ try {
+ for (const tool of await native.getTools()) {
+ if (typeof tool.name === 'string' && !tool.name.startsWith(BRIDGED_PREFIX)) names.add(tool.name);
+ }
+ } catch {
+ // Without a readable registry, discovery still hides shadowed tools.
+ }
+ return names;
+ }
+
+ // name -> {controller, serialized} for tools registered in `bridgedDocument`.
+ const bridged = new Map();
+ let bridgedDocument = null;
+
+ function unregister(name) {
+ bridged.get(name).controller.abort();
+ bridged.delete(name);
+ }
+
+ return {
+ // Registers new polyfill tools, unregisters removed or changed ones, and
+ // reports whether the native registry changed.
+ async sync() {
+ const document = currentDocument();
+ if (document !== bridgedDocument) {
+ // A same-origin child frame navigated; its registrations went with it.
+ bridged.clear();
+ bridgedDocument = document;
+ }
+ const native = nativeContext();
+ const context = polyfill();
+ const desired = native && context ? await desiredTools(context) : new Map();
+ if (desired.size > 0) {
+ for (const name of await nativeNames(native)) desired.delete(name);
+ }
+ let changed = false;
+ for (const [name, registration] of bridged) {
+ if (desired.get(name)?.serialized !== registration.serialized) {
+ unregister(name);
+ changed = true;
+ }
+ }
+ for (const [name, item] of desired) {
+ if (bridged.has(name)) continue;
+ const controller = new AbortController();
+ try {
+ await native.registerTool(
+ {...item.entry, name: BRIDGED_PREFIX + name, execute: execute(name)},
+ {signal: controller.signal},
+ );
+ } catch {
+ // The registry rejected the tool (for example its schema); the next
+ // sync tries again.
+ controller.abort();
+ continue;
+ }
+ bridged.set(name, {controller, serialized: item.serialized});
+ changed = true;
+ }
+ return changed;
+ },
+ };
+}
diff --git a/server/lib/webmcpclient/polyfill_test.go b/server/lib/webmcpclient/polyfill_test.go
new file mode 100644
index 00000000..9dc4fb44
--- /dev/null
+++ b/server/lib/webmcpclient/polyfill_test.go
@@ -0,0 +1,259 @@
+package webmcpclient
+
+import (
+ "context"
+ "encoding/json"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Window handles are "window:" for roots and
+// "window::page-child" for the same-process child frame; bridge
+// handles are "bridge|".
+var polyfillWindowFrames = map[string]string{
+ "window:page-session": "page-frame",
+ "window:page-session:page-child": "page-child",
+ "window:iframe-session": "iframe-frame",
+}
+
+// servePolyfill answers the Runtime and DOM commands used to bridge a page's
+// navigator.modelContext polyfill. A bridge sync emits WebMCP.toolsAdded and
+// toolsRemoved for the difference, the way Chromium reports registrations in
+// the native registry.
+func (f *fakeCDP) servePolyfill(request wireRequest, respond func(any), write func(any)) {
+ fail := func(message string) {
+ write(map[string]any{"id": request.ID, "error": map[string]any{"code": -32000, "message": message}})
+ }
+ var params struct {
+ ObjectID string `json:"objectId"`
+ FunctionDeclaration string `json:"functionDeclaration"`
+ FrameID string `json:"frameId"`
+ }
+ _ = json.Unmarshal(request.Params, ¶ms)
+
+ switch request.Method {
+ case "Runtime.evaluate":
+ respond(map[string]any{"result": map[string]any{"type": "object", "className": "Window", "objectId": "window:" + request.SessionID}})
+ case "DOM.getFrameOwner":
+ if params.FrameID != "page-child" {
+ fail("Frame with the given id was not found.")
+ return
+ }
+ respond(map[string]any{"backendNodeId": 7})
+ case "DOM.resolveNode":
+ respond(map[string]any{"object": map[string]any{"type": "object", "subtype": "node", "objectId": "iframe:" + request.SessionID}})
+ case "Runtime.releaseObjectGroup":
+ respond(map[string]any{})
+ case "Runtime.callFunctionOn":
+ switch {
+ case strings.Contains(params.FunctionDeclaration, "contentWindow"):
+ respond(map[string]any{"result": map[string]any{"type": "object", "className": "Window", "objectId": "window:" + request.SessionID + ":page-child"}})
+ case params.FunctionDeclaration == polyfillBridgeSource:
+ f.mu.Lock()
+ f.bridgesCreated++
+ f.mu.Unlock()
+ respond(map[string]any{"result": map[string]any{"type": "object", "objectId": "bridge|" + params.ObjectID}})
+ case strings.Contains(params.FunctionDeclaration, "this.sync()"):
+ windowID := strings.TrimPrefix(params.ObjectID, "bridge|")
+ f.mu.Lock()
+ if f.staleBridges[windowID] {
+ delete(f.staleBridges, windowID)
+ f.mu.Unlock()
+ fail("Could not find object with given id")
+ return
+ }
+ desired := make(map[string]bool)
+ for _, name := range f.polyfillTools[windowID] {
+ desired[name] = true
+ }
+ bridged := f.bridgedTools[windowID]
+ var added, removed []map[string]any
+ for name := range bridged {
+ if !desired[name] {
+ removed = append(removed, map[string]any{"name": polyfillToolNamePrefix + name, "frameId": polyfillWindowFrames[windowID]})
+ }
+ }
+ for name := range desired {
+ if !bridged[name] {
+ added = append(added, map[string]any{
+ "name": polyfillToolNamePrefix + name, "description": name + " description", "frameId": polyfillWindowFrames[windowID],
+ "inputSchema": map[string]any{"type": "object"},
+ })
+ }
+ }
+ f.bridgedTools[windowID] = desired
+ f.mu.Unlock()
+ if len(removed) > 0 {
+ write(map[string]any{"method": "WebMCP.toolsRemoved", "sessionId": request.SessionID, "params": map[string]any{"tools": removed}})
+ }
+ if len(added) > 0 {
+ write(map[string]any{"method": "WebMCP.toolsAdded", "sessionId": request.SessionID, "params": map[string]any{"tools": added}})
+ }
+ respond(map[string]any{"result": map[string]any{"type": "boolean", "value": len(added)+len(removed) > 0}})
+ default:
+ fail("unexpected function")
+ }
+ }
+}
+
+func newPolyfillFakeCDP(t *testing.T) *fakeCDP {
+ t.Helper()
+ fake := newFakeCDP(t, false)
+ fake.childFrameOpen = true
+ fake.polyfillTools = map[string][]string{
+ // merchant_tool is also registered natively in the same frame.
+ "window:page-session": {"poly_search", "merchant_tool"},
+ "window:page-session:page-child": {"child_poly"},
+ "window:iframe-session": {"payment_poly"},
+ }
+ return fake
+}
+
+func toolsByName(tools []Tool) map[string]Tool {
+ byName := make(map[string]Tool, len(tools))
+ for _, tool := range tools {
+ byName[tool.Name] = tool
+ }
+ return byName
+}
+
+func toolNames(tools []Tool) []string {
+ names := make([]string, 0, len(tools))
+ for _, tool := range tools {
+ names = append(names, tool.Name)
+ }
+ sort.Strings(names)
+ return names
+}
+
+func TestPolyfillToolsAreBridgedIntoTheNativeRegistry(t *testing.T) {
+ fake := newPolyfillFakeCDP(t)
+ manager := NewManager(staticUpstream{url: fake.url})
+ t.Cleanup(func() { _ = manager.Close() })
+
+ tools, err := manager.Tools(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, []string{
+ "bank_tool", "child_poly", "merchant_tool", "payment_poly", "payment_tool", "poly_search", "search_flights",
+ }, toolNames(tools))
+ byName := toolsByName(tools)
+ require.Nil(t, byName["poly_search"].Source.Frame)
+ // The page's native registration is listed, not the bridged copy.
+ require.Equal(t, "merchant_tool description", byName["merchant_tool"].Description)
+ require.Equal(t, "https://merchant.example/child", byName["child_poly"].Source.Frame.URL)
+ // An out-of-process iframe is bridged through its own session.
+ require.Equal(t, "https://payments.example/element", byName["payment_poly"].Source.Frame.URL)
+
+ // Bridging never enables the Runtime or DOM domains, which pages can detect.
+ fake.mu.Lock()
+ require.Zero(t, fake.methods["Runtime.enable"])
+ require.Zero(t, fake.methods["DOM.enable"])
+ // Every web frame gets a bridge, including the two without a polyfill.
+ require.Equal(t, 5, fake.bridgesCreated)
+ fake.mu.Unlock()
+
+ // Bridges and references are reused while the documents live.
+ again, err := manager.Tools(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, byName["poly_search"].Ref, toolsByName(again)["poly_search"].Ref)
+ fake.mu.Lock()
+ require.Equal(t, 5, fake.bridgesCreated)
+ fake.mu.Unlock()
+
+ // Bridged tools invoke through the native WebMCP domain under their
+ // registered name.
+ result, err := manager.Invoke(context.Background(), byName["poly_search"].Ref, map[string]any{"query": "lamp"})
+ require.NoError(t, err)
+ require.Equal(t, "Completed", result.Status)
+ fake.mu.Lock()
+ require.Equal(t, "polyfill.poly_search", fake.lastInvokedName)
+ fake.mu.Unlock()
+}
+
+func TestPolyfillUnregistrationRemovesBridgedTools(t *testing.T) {
+ fake := newPolyfillFakeCDP(t)
+ manager := NewManager(staticUpstream{url: fake.url})
+ t.Cleanup(func() { _ = manager.Close() })
+ tools, err := manager.Tools(context.Background())
+ require.NoError(t, err)
+ childRef := toolsByName(tools)["child_poly"].Ref
+
+ fake.mu.Lock()
+ fake.polyfillTools["window:page-session"] = nil
+ fake.mu.Unlock()
+ after, err := manager.Tools(context.Background())
+ require.NoError(t, err)
+ require.NotContains(t, toolsByName(after), "poly_search")
+ require.Equal(t, childRef, toolsByName(after)["child_poly"].Ref)
+}
+
+func TestPolyfillBridgeIsRecreatedForANewDocument(t *testing.T) {
+ fake := newPolyfillFakeCDP(t)
+ manager := NewManager(staticUpstream{url: fake.url})
+ t.Cleanup(func() { _ = manager.Close() })
+ tools, err := manager.Tools(context.Background())
+ require.NoError(t, err)
+ oldRef := toolsByName(tools)["poly_search"].Ref
+
+ // Navigation drops the document's registrations and invalidates the
+ // bridge handle; the next listing bridges the new document.
+ fake.mu.Lock()
+ fake.staleBridges["window:page-session"] = true
+ delete(fake.bridgedTools, "window:page-session")
+ fake.mu.Unlock()
+ fake.emit(map[string]any{
+ "method": "Page.frameNavigated", "sessionId": "page-session",
+ "params": map[string]any{"frame": map[string]any{"id": "page-frame", "loaderId": "next-loader", "url": "https://merchant.example/next"}},
+ })
+ require.Eventually(t, func() bool {
+ for _, tab := range manager.connection.surface.Snapshot().Tabs {
+ if tab.PageURL == "https://merchant.example/next" {
+ return true
+ }
+ }
+ return false
+ }, 3*time.Second, 10*time.Millisecond)
+
+ // The stale handle is replaced within the same listing.
+ tools, err = manager.Tools(context.Background())
+ require.NoError(t, err)
+ require.Contains(t, toolsByName(tools), "poly_search")
+ require.NotEqual(t, oldRef, toolsByName(tools)["poly_search"].Ref)
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ require.Equal(t, 6, fake.bridgesCreated)
+}
+
+func TestBridgedToolIsListedWhenTheNativeToolGoesAway(t *testing.T) {
+ fake := newPolyfillFakeCDP(t)
+ manager := NewManager(staticUpstream{url: fake.url})
+ t.Cleanup(func() { _ = manager.Close() })
+ tools, err := manager.Tools(context.Background())
+ require.NoError(t, err)
+ nativeRef := toolsByName(tools)["merchant_tool"].Ref
+
+ fake.emit(map[string]any{
+ "method": "WebMCP.toolsRemoved", "sessionId": "page-session",
+ "params": map[string]any{"tools": []map[string]any{{"name": "merchant_tool", "frameId": "page-frame"}}},
+ })
+ require.Eventually(t, func() bool {
+ tools, err := manager.Tools(context.Background())
+ require.NoError(t, err)
+ tool, ok := toolsByName(tools)["merchant_tool"]
+ return ok && tool.Ref != nativeRef && tool.Description == "merchant_tool description"
+ }, 3*time.Second, 20*time.Millisecond)
+}
+
+func TestExceptionTextKeepsTheThrownMessage(t *testing.T) {
+ require.Equal(t, "Error: page says no", exceptionText(&exceptionDetails{
+ Type: "object", Description: "Error: page says no\n at execute (:1:147)",
+ }))
+ require.Equal(t, "plain string", exceptionText(&exceptionDetails{Type: "string", Value: "plain string"}))
+ require.Equal(t, `{"code":7}`, exceptionText(&exceptionDetails{Type: "object", Value: map[string]any{"code": 7}}))
+ require.Empty(t, exceptionText(&exceptionDetails{Type: "undefined"}))
+ require.Len(t, exceptionText(&exceptionDetails{Type: "string", Value: strings.Repeat("x", 100<<10)}), maxExceptionTextBytes)
+}
diff --git a/server/lib/webmcpclient/types.go b/server/lib/webmcpclient/types.go
index 9f2bcebe..ff6d5743 100644
--- a/server/lib/webmcpclient/types.go
+++ b/server/lib/webmcpclient/types.go
@@ -59,6 +59,7 @@ type registeredTool struct {
customID string
frameID string
declarative bool
+ bridged bool
}
type toolEvent struct {
@@ -71,10 +72,19 @@ type toolEvent struct {
}
type invocationResponse struct {
- InvocationID string `json:"invocationId"`
- Status string `json:"status"`
- Output any `json:"output,omitempty"`
- ErrorText string `json:"errorText,omitempty"`
+ InvocationID string `json:"invocationId"`
+ Status string `json:"status"`
+ Output any `json:"output,omitempty"`
+ ErrorText string `json:"errorText,omitempty"`
+ Exception *exceptionDetails `json:"exception,omitempty"`
+}
+
+// exceptionDetails is the Runtime.RemoteObject Chromium reports when a page
+// tool's execute throws or rejects.
+type exceptionDetails struct {
+ Type string `json:"type"`
+ Value any `json:"value"`
+ Description string `json:"description"`
}
type invocationKey struct {
diff --git a/server/runtime/webmcp-polyfill.test.ts b/server/runtime/webmcp-polyfill.test.ts
new file mode 100644
index 00000000..1788c2a5
--- /dev/null
+++ b/server/runtime/webmcp-polyfill.test.ts
@@ -0,0 +1,236 @@
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import test from 'node:test';
+import vm from 'node:vm';
+
+// The page-side bridge that lib/webmcpclient calls on a frame's Window.
+const source = readFileSync(new URL('../lib/webmcpclient/polyfill_bridge.js', import.meta.url), 'utf8');
+
+interface Bridge {
+ sync(): Promise;
+}
+
+function bridge(window: unknown): Bridge {
+ const create = vm.runInNewContext(`(${source})`, {AbortController}) as (this: unknown) => Bridge;
+ return create.call(window);
+}
+
+type Tool = {name: string; execute?: (input: unknown) => unknown} & Record;
+
+// Stands in for Chromium's document.modelContext: duplicate names reject and
+// aborting the registration signal removes the tool.
+class ModelContext {
+ readonly tools = new Map();
+
+ get [Symbol.toStringTag]() {
+ return 'ModelContext';
+ }
+
+ async registerTool(tool: Tool, options: {signal?: AbortSignal} = {}) {
+ if (this.tools.has(tool.name)) throw new Error(`Duplicate tool name: ${tool.name}`);
+ this.tools.set(tool.name, tool);
+ options.signal?.addEventListener('abort', () => this.tools.delete(tool.name), {once: true});
+ }
+
+ async getTools() {
+ return [...this.tools.values()].map(({execute: _execute, ...metadata}) => metadata);
+ }
+
+ // Bridged tools register under the reserved prefix; helpers take the page's name.
+ bridged() {
+ return [...this.tools.keys()].filter((name) => name.startsWith(PREFIX)).map((name) => name.slice(PREFIX.length));
+ }
+
+ metadata(name: string) {
+ const {execute: _execute, ...metadata} = this.tools.get(PREFIX + name)!;
+ return JSON.parse(JSON.stringify(metadata));
+ }
+
+ async invoke(name: string, input: unknown) {
+ return JSON.parse(JSON.stringify(await this.tools.get(PREFIX + name)!.execute!(input) ?? null));
+ }
+}
+
+const PREFIX = 'polyfill.';
+
+// Mirrors the polyfill shape that sites ship before Chromium exposed
+// document.modelContext: a registry object plus listTools/callTool helpers.
+function sitePolyfill() {
+ const registry: Record = {};
+ return {
+ _registeredTools: registry,
+ registerTool(tool: Tool) {
+ registry[tool.name] = {
+ name: tool.name,
+ title: tool.title,
+ description: tool.description,
+ inputSchema: tool.inputSchema ?? tool.parameters,
+ execute: tool.execute,
+ };
+ },
+ unregisterTool(name: string) {
+ delete registry[name];
+ },
+ async callTool(name: string, input: unknown) {
+ const tool = registry[name];
+ if (!tool) throw new Error(`Tool not found: ${name}`);
+ return tool.execute?.(input);
+ },
+ listTools: () => Object.values(registry).map((tool) => ({name: tool.name, description: tool.description, inputSchema: tool.inputSchema})),
+ };
+}
+
+function fakeWindow(modelContext: unknown, native = new ModelContext()) {
+ return {navigator: {modelContext}, document: {modelContext: native}, native};
+}
+
+test('bridges a site polyfill into the native registry and follows its changes', async () => {
+ const polyfill = sitePolyfill();
+ polyfill.registerTool({
+ name: 'search_items',
+ title: 'Search',
+ description: 'Search the catalog.',
+ inputSchema: {type: 'object', properties: {query: {type: 'string'}}, required: ['query']},
+ execute: async (input) => ({results: [`match for ${(input as {query: string}).query}`]}),
+ });
+ polyfill.registerTool({
+ name: 'legacy_params',
+ description: 'Uses the pre-standard parameters alias.',
+ parameters: {type: 'object', properties: {}},
+ execute: async () => undefined,
+ });
+ polyfill.registerTool({name: 'broken', description: 'Throws.', inputSchema: {type: 'object'}, execute: async () => {
+ throw new Error('page rejected the call');
+ }});
+ const window = fakeWindow(polyfill);
+ const {native} = window;
+ const b = bridge(window);
+
+ assert.equal(await b.sync(), true);
+ assert.deepEqual(native.bridged(), ['search_items', 'legacy_params', 'broken']);
+ assert.deepEqual(native.metadata('search_items'), {
+ name: 'polyfill.search_items',
+ title: 'Search',
+ description: 'Search the catalog.',
+ inputSchema: {type: 'object', properties: {query: {type: 'string'}}, required: ['query']},
+ });
+ assert.deepEqual(native.metadata('legacy_params').inputSchema, {type: 'object', properties: {}});
+ assert.deepEqual(await native.invoke('search_items', {query: 'lamp'}), {results: ['match for lamp']});
+ assert.equal(await native.invoke('legacy_params', {}), null);
+ await assert.rejects(native.invoke('broken', {}), /page rejected the call/);
+
+ assert.equal(await b.sync(), false);
+
+ polyfill.unregisterTool('search_items');
+ polyfill.registerTool({name: 'broken', description: 'Fixed.', inputSchema: {type: 'object'}, execute: async () => 'ok'});
+ assert.equal(await b.sync(), true);
+ assert.deepEqual(native.bridged().sort(), ['broken', 'legacy_params']);
+ assert.equal(native.metadata('broken').description, 'Fixed.');
+ assert.equal(await native.invoke('broken', {}), 'ok');
+});
+
+test('supports MCP-style polyfills that take callTool({name, arguments})', async () => {
+ const tools = new Map();
+ tools.set('add_to_cart', {
+ name: 'add_to_cart',
+ title: 'Add to cart',
+ description: 'Add an item.',
+ inputSchema: {type: 'object', properties: {sku: {type: 'string'}}},
+ outputSchema: {type: 'object'},
+ annotations: {readOnlyHint: false},
+ execute: async () => ({content: [{type: 'text', text: 'added'}]}),
+ });
+ const polyfill = {
+ listTools: () => ({tools: [...tools.values()].map(({execute: _execute, ...metadata}) => metadata)}),
+ async callTool(params: {name: string; arguments: unknown}) {
+ return tools.get(params.name)?.execute?.(params.arguments);
+ },
+ };
+ const window = fakeWindow(polyfill);
+ await bridge(window).sync();
+
+ assert.deepEqual(window.native.metadata('add_to_cart'), {
+ name: 'polyfill.add_to_cart',
+ title: 'Add to cart',
+ description: 'Add an item.',
+ inputSchema: {type: 'object', properties: {sku: {type: 'string'}}},
+ outputSchema: {type: 'object'},
+ annotations: {readOnlyHint: false},
+ });
+ assert.deepEqual(await window.native.invoke('add_to_cart', {sku: '1'}), {content: [{type: 'text', text: 'added'}]});
+});
+
+test('falls back to the registry when the polyfill has no list or call helpers', async () => {
+ const registry = new Map();
+ registry.set('get_context', {name: 'get_context', description: 'Context.', inputSchema: {type: 'object'}, execute: async () => 'ctx'});
+ const polyfill = {registerTool() {}, provideContext() {}};
+ const window = {...fakeWindow(polyfill), __webmcp: {tools: registry}};
+ await bridge(window).sync();
+
+ assert.deepEqual(window.native.bridged(), ['get_context']);
+ assert.equal(await window.native.invoke('get_context', {}), 'ctx');
+});
+
+test('never takes a name the page registers natively, before or after bridging', async () => {
+ const native = new ModelContext();
+ await native.registerTool({name: 'shared', description: 'Native copy.', execute: async () => 'native'});
+ const polyfill = sitePolyfill();
+ polyfill.registerTool({name: 'shared', description: 'Polyfill copy.', inputSchema: {type: 'object'}, execute: async () => 'polyfill'});
+ polyfill.registerTool({name: 'later', description: 'Polyfill copy.', inputSchema: {type: 'object'}, execute: async () => 'polyfill'});
+ const window = fakeWindow(polyfill, native);
+ const b = bridge(window);
+ assert.equal(await b.sync(), true);
+ assert.deepEqual(native.bridged(), ['later']);
+ assert.equal(await native.tools.get('shared')!.execute!({}), 'native');
+
+ // The page registers a bridged name natively afterwards: it succeeds, and
+ // the next sync withdraws the bridged copy.
+ await native.registerTool({name: 'later', description: 'Native copy.', execute: async () => 'native'});
+ assert.equal(await b.sync(), true);
+ assert.deepEqual(native.bridged(), []);
+ assert.deepEqual([...native.tools.keys()].sort(), ['later', 'shared']);
+
+ // navigator.modelContext that is the native registry, or no polyfill at all.
+ const self = new ModelContext();
+ assert.equal(await bridge(fakeWindow(self, self)).sync(), false);
+ assert.equal(await bridge(fakeWindow(undefined)).sync(), false);
+ assert.equal(await bridge({navigator: {get modelContext() {
+ throw new Error('blocked');
+ }}, document: {modelContext: new ModelContext()}}).sync(), false);
+ // A document whose modelContext is not the native registry.
+ assert.equal(await bridge({navigator: {modelContext: sitePolyfill()}, document: {modelContext: {}}}).sync(), false);
+});
+
+test('drops malformed entries, bounds output, and resets for a new document', async () => {
+ const polyfill = {
+ listTools: () => [
+ null,
+ {name: 42},
+ {name: '', description: 'empty'},
+ {name: 'dup', description: 'first', inputSchema: '{"type":"object"}'},
+ {name: 'dup', description: 'second'},
+ {name: 'bad_schema', inputSchema: 'not json', description: 7},
+ {name: 'huge', description: 'x', inputSchema: {type: 'object', enum: ['x'.repeat(300 * 1024)]}},
+ ],
+ async callTool(name: string, _input: unknown) {
+ if (name === 'dup') return {payload: 'x'.repeat(1024 * 1024)};
+ const value: Record = {};
+ value.self = value;
+ return value;
+ },
+ };
+ const window = fakeWindow(polyfill);
+ const b = bridge(window);
+ await b.sync();
+
+ assert.deepEqual(window.native.bridged(), ['dup', 'bad_schema']);
+ assert.deepEqual(window.native.metadata('dup'), {name: 'polyfill.dup', description: 'first', inputSchema: {type: 'object'}});
+ assert.deepEqual(window.native.metadata('bad_schema'), {name: 'polyfill.bad_schema', description: '', inputSchema: {type: 'object'}});
+ await assert.rejects(window.native.invoke('dup', {}), /exceeds 1 MiB/);
+ await assert.rejects(window.native.invoke('bad_schema', {}), /not JSON-serializable/);
+
+ // A same-origin child frame navigated: its registry is new and empty.
+ window.document = {modelContext: new ModelContext()};
+ assert.equal(await b.sync(), true);
+ assert.deepEqual((window.document.modelContext as ModelContext).bridged(), ['dup', 'bad_schema']);
+});