Inspiration

I kept reading the same sentence in every WebMCP write-up: give agents tools, not DOM. I agreed with it completely. Then I opened a handful of sites I use every day with WebMCP enabled in Chrome, and every single one of them handed the agent an empty document.modelContext.

That's the whole problem, right there. The standard is good. The tools don't exist yet, because somebody has to sit down and write them for every site, one at a time. So agents are back to guessing from screenshots on 99% of the web while we wait.

I got stuck on a question: why does registerTool exist as a runtime API at all? If tools were meant to be a fixed list, you'd declare them once at load. But WebMCP lets you register at any moment, and it ships a toolchange event so listeners find out when the surface changes. Most people use that for SPA routing.

I wanted to see what happens if you take it literally. What if the agent registers the tools?

What it does

SAW-Bridge gives a page three meta-tools instead of a catalogue:

  • saw_inspect_page_scope — look at the page and report what it actually exposes
  • saw_suggest_and_register_tool — design a schema and register a real, lasting WebMCP tool
  • saw_execute_code_mode — write code and run it once, for one-off work

The demo is a dashboard of world happiness and AI readiness data. It has a filter chip for happiness above 7.5. It deliberately has no chip for 7.4, and no tool for it either.

So I asked ChatGPT's in-app browser for 7.4.

It listed the page's tools, called saw_inspect_page_scope, found the site's own filterByMinHappiness function, and then it stopped and asked me before touching anything. I said yes. The table dropped from twelve rows to four — Finland 7.82, Denmark 7.64, Iceland 7.56, Netherlands 7.42. All above 7.4, and the chart re-rendered through the app's own code path, not a simulated click on something that looked clickable.

The part I didn't expect is that the agent chose between the two strategies. For a one-off threshold it wrote code and ran it once, which is the cheaper call. Ask for something reusable and it takes the other path and registers a lasting tool — and then Chrome's own DevTools WebMCP panel lists that tool and will invoke it with your parameters, because by that point it's an ordinary WebMCP tool. It isn't picking between one blessed tool and scraping the screen. It's deciding how permanent its new capability should be.

Every mutating call goes through an approval dialog first, showing the exact tool and the exact arguments. There's a live audit drawer so you can watch what was discovered, assembled, approved and refused, and why.

There are two delivery surfaces sharing one engine: a single-file SDK a site drops in, and a Chrome extension that injects the same thing into sites that ship nothing.

How we built it

The core is plain WebMCP. Tools register through document.modelContext.registerTool(). Consumers discover with getTools() and invoke with executeTool(). Registration takes an AbortSignal, so aborting genuinely unregisters. Assembled tools get untrustedContentHint: true; read-only ones get readOnlyHint: true.

Two pieces took actual thinking.

Finding what a page exposes. The interesting stuff on a page is buried among roughly a thousand standard browser globals, and a hand-written denylist rots the moment Chrome ships a new API. What worked: create a hidden iframe, read Object.getOwnPropertyNames off its contentWindow to get a reference set of standard globals from the browser itself, then diff the real window against it. Whatever's left is the page's own surface, and it stays correct as browsers change.

Then rank candidates before truncating — an object with callable methods outranks a bare object, which outranks a loose function. I learned that one the hard way, see below.

Compiling what the agent writes. This lands on the same insight as Cloudflare's Code Mode: models handle capability better when they can write code against an API than when they pick from a long list of tool definitions. I want to be precise about the relationship — I did not use @cloudflare/codemode. Cloudflare runs model-written code in a server-side Worker isolate. This runs it in the page's own realm, and that difference is the reason the project exists. The currently applied filter, the selected row, the chart instance holding the data — none of that exists outside the page. A sandbox elsewhere can't touch what the user is actually looking at without replicating the whole session.

The token math lands in the same place by a different road. Code Mode collapses N tool definitions into one code tool. Here there's no catalogue to collapse: three meta-tools, and concrete tools show up only when a task calls for one. That's progressive disclosure, which MCP's own client guidance recommends when definitions start eating context — except the tools being disclosed don't have to exist yet.

Challenges we ran into

The demo was completely dead and looked fine. The SDK called init() at the end of its bootstrap. There was no function called init anywhere in the file. The whole thing threw on the last line, so the three meta-tools never registered — but the polyfill and the global had already been assigned, so the page looked healthy and every button failed silently. I only caught it by building a jsdom harness that clicked the actual buttons on the actual HTML.

My REPL couldn't run its own demo. The code path wrapped snippets in return (...), which only accepts a single expression. My own demo passed a statement body. It threw a SyntaxError every time. Now it tries the expression form, falls back to a statement body, and compiles async so snippets can await.

executeTool doesn't take a tool name. The spec says it takes a RegisteredTool object from getTools() and resolves with the stringified result. I'd written it to accept a string and return a plain object. Against my own polyfill it worked perfectly. In a browser with native WebMCP it would have rejected on the first call — the exact environment judges test in. That was a genuinely unnerving thing to find.

It worked from file:// and broke as soon as I deployed. WebMCP needs an origin-keyed agent cluster, which means an Origin-Agent-Cluster: ?1 response header. file:// is exempt, and localhost is a secure context but is not origin-keyed, so this fails only after hosting, with a SecurityError and no hint. Both runtimes now check at startup and name what's missing instead of letting you guess.

The security dialog could be hijacked by the thing it was guarding against. This one bothered me most. When an agent authors tools, tool metadata becomes untrusted input, and I'd built the approval dialog with innerHTML. A payload like x</pre><img src=y onerror=...> renders live markup inside the security gate and can click its own Approve button. I wrote the test first, watched it fail, then fixed it: closed shadow root, DOM references captured at document_start, every agent-supplied string inserted as text. Turns out this is a named attack class — tool surface poisoning — and the spec's own boundary section is still marked TODO.

I also found window.SAWBridge.setSettings sitting there in public, which meant one line of page script could switch the approval gate off before an agent did anything.

I spent an evening debugging the wrong browser. I asked ChatGPT's browser plugin which WebMCP tools it could see and got a confident, detailed, completely wrong answer — it listed my UI button labels as if they were tools, and reported the tool counter as "0", which is the value in the static HTML before JavaScript runs. It had fetched my page, not executed it. That plugin surface isn't the WebMCP-enabled in-app browser. Once I tested in the right place, everything worked on the first try.

And the ecosystem was further along than I thought. Somewhere in the middle of this I found @mcp-b/webmcp-polyfill, the desktop relays, the React hooks — all shipped, all solid. I sat there for a while genuinely wondering whether I'd built something nobody needed. What snapped me out of it was noticing what those projects have in common: they all treat the tool surface as trusted freight to be transported. None of them ask what happens when the freight writes itself.

Accomplishments that we're proud of

The 7.4 moment. Not because it's technically dramatic, but because the page genuinely could not do it before and nobody wrote code for that specific request. It came out of inspection at runtime.

Fixing the injection bug properly instead of quietly. The test with that payload is in the repo and it fails against the old build, which means it measures something real rather than decorating the README.

Being honest in the polyfill. It rejects duplicate names, enforces the spec's 1–128 character name rules, hides execute from getTools(), and returns a stringified result from executeTool — and it throws a clear error if you pass a name string, telling you to pass a tool object instead. A permissive polyfill is worse than none, because it makes broken code look correct until it meets a real browser.

179 assertions across three jsdom suites: the SDK alone, the real index.html driven through all four demo steps, and the extension's main-world runtime.

And the small stuff that meant the thing actually worked. Ranking discovered globals before truncating the list — an unsorted slice(0, 20) was dropping the single most useful namespace on the page, so the agent's report looked empty on a page full of opportunity. A tool counter that printed "-3 JIT Tools" because it subtracted a hardcoded 3 before the kernel finished registering. Probing for unsafe-eval at startup and writing "unavailable on this page" into the affected tool descriptions, so an agent finds out from the description instead of an EvalError mid-task.

What we learned

Writing a polyfill teaches you a spec in a way reading it doesn't. Every shortcut I took — a convenient signature, a helpful default — turned into a bug the moment native WebMCP showed up.

Governance is the actual hard part, not tool generation. Generating a tool is a small amount of code. Making it safe to allow is where the work is: what happens when the tool name is hostile, when the page tries to switch off the gate, when the user revokes an origin mid-session and the agent still holds tools it registered there.

You can't sandbox code that shares your realm, and pretending otherwise is worse than admitting it. The Proxy-based scope guard blocks cookie, localStorage, indexedDB and friends, but anyone determined gets out through document.body.ownerDocument. So it's documented as a guard rail that makes attempts loud, not as containment. I'd rather a judge trust the honest claim than catch me on the inflated one.

Test in the environment you're shipping to, early. Both of my worst days were the same mistake in different clothes: my polyfill hid a spec violation, and the wrong browser surface hid a working feature.

Also: finding out someone already built part of your idea is information, not a verdict. It told me where my differentiation actually was, which was somewhere I hadn't been emphasizing.

What's next for SAW-Bridge — Self-Assembling WebMCP Bridge

A fallback for pages with a strict CSP. No unsafe-eval means no runtime code generation, so JIT assembly can't run. Detection is done, mitigation isn't. The path is deterministic tool synthesis from forms and semantic markup — no codegen needed, works everywhere, narrower but honest.

Build-time tools for framework apps. In a bundled React or Vue app there are no custom globals to discover and the DOM is the wrong abstraction; the semantic action is dispatch(filter(7.4)), not a click. A build plugin can derive the JSON Schema from TypeScript types and register a direct function reference. No eval, so the CSP problem disappears at the same time. Route-scoped registration maps cleanly onto the AbortSignal the spec already gives us.

Interoperate with the existing desktop relays rather than rebuild them. MCP-B and others already carry browser tools out to Claude Desktop and Cursor. What they can't do is invent something to carry when the page exposes nothing. Emitting tools those relays already understand is a much smaller job than writing another relay, and it puts this in the pipeline instead of beside it.

Take the governance layer seriously as its own thing. The approval gate, the origin policy, the redacted audit trail and the revocation-on-policy-change behaviour are useful to anyone building on a dynamic tool surface, not just to me. The spec's boundary section being a TODO is an invitation.

Built With

Share this project:

Updates

Submission history