What a web proxy has to change before it can register a WebMCP tool
Background and Motivation
I build TrickyBird, a web proxy, so I'm not neutral here. This is a build note about the part that came before the tool: what we had to switch off first.
WebMCP Overview
WebMCP lets a page give the browser's agent a real function instead of a DOM to guess at. You call document.modelContext.registerTool({ name, description, inputSchema, execute }), and the agent can list the tool and call it. The feature sits behind a Permissions Policy whose default is self.
The Proxy Problem
On a normal site that default does the isolation for you: an ad frame is a different origin, so it cannot register anything into your page. A proxy breaks that default. Every frame on a proxied page comes from our origin (the page and its ads alike), so self covers all of them. A frame that belongs to whatever site the reader opened could register a tool, and the agent would call it inside that reader's session.
First Change: Refusal
So the first WebMCP change we made was a refusal. The gateway sends tools=() in the Permissions-Policy header of every document it serves. With WebMCP switched on by flag, registerTool on a proxied page rejects with NotAllowedError, and so does a call from a fresh about:blank frame the page builds itself, which is exactly the frame the collapsed origin would otherwise let in. Both measured in Chrome 152.
On top of the header, our injected shim deletes document.modelContext from proxied pages, so on production today a site that checks for the API finds nothing, instead of an API that answers every call with a refusal. The header stays the real enforcement; a realm the shim never reaches still inherits it.
The Tool
Then the tool. It lives on our own home page, which serves nobody's HTML but ours. There is one, open_site. It takes an address, runs the same check the page's own form runs, and the tab navigates to the proxied page. Give it a phrase that isn't a site and it refuses. It never searches.
A Day-One Mistake
One thing I got wrong on day one, in case you ship the same shape. The draft says registerTool returns a promise, and our hook was written for that: registerTool(...).catch(() => {}), then return the cleanup. Not every implementation agrees. On some Chrome OS and Edge builds the call returned undefined, .catch threw inside the effect before the cleanup existed, and the next mount of the page raised InvalidStateError: Duplicate tool name.
The fix is one line of shape: Promise.resolve(registerTool(...)).catch(() => {}) inside a try. A rejection, a synchronous throw and a bare undefined now settle the same way, and the cleanup is always returned.
Verification
Two things you can check yourself. Lighthouse 13.4.1 has an agentic-browsing category. Our home page scores 1 there, the same as before the tool, and the two audits the tool adds show what they should: the registration table lists open_site, and schema validity scores 1. And Chrome 152 lists open_site on trickybird.com.
Expectations
What do I expect from it? Close to nothing, for now. The only consumer today is the ChatGPT desktop browser, and none of its user agents have turned up in our own traffic. It doesn't have to identify itself, so that may mean less than it sounds.
Comments
No comments yet. Start the discussion.