Inspiration
WebMCP is a genuine leap forward for agentic web apps, a page can expose a real tool (transfer_funds, export_customers, get_session_token) and any AI agent gets a standard way to call it via document.modelContext.executeTool. But that also means the moment a site adopts WebMCP, an agent gets the same reach a logged-in human has with no confirmation dialog, no audit trail, and no idea whether the tool it's calling is safe to call. Browsers ship extensions that block ads and trackers; nothing exists yet that treats an agent's tool calls the same way. We wanted to find out what that missing layer should actually look like and build both halves of the problem to prove it: a real WebMCP app with genuinely risky tools, and the guard that makes calling them safe.
What it does
We built two things:
- A demo WebMCP business app, built purely to give Surf something real and risky to protect — five tools across the risk spectrum:
get_balance(read),export_customers(data-egress),get_session_token(credential),transfer_funds(destructive write), andadd_payee(account-takeover risk), registered withdocument.modelContext.registerTool()like any real WebMCP site would. - Surf, a Chrome MV3 extension that works on any page exposing WebMCP tools — not just our demo. It injects into every page at
document_start(matches: ["<all_urls>"]) and sits between every agent and every tool that page exposes:- Blocks explicitly blocklisted tools outright, by name, regardless of what the page's own annotations claim.
- Holds any tool call that isn't provably safe for an in-page human approval prompt, Approve/Deny buttons rendered directly on the page before it's allowed to run.
- Masks sensitive data in tool results before it reaches the caller: PII (SSNs, credit cards, emails, IBANs), secrets (API keys, JWTs, bearer tokens), and any field whose name alone looks sensitive.
- Records every call agent-driven or manual in a live, cross-tab audit trail any page can subscribe to in real time.
The demo app exists to let judges see Surf actually doing something — you can watch it hold transfer_funds for approval, block add_payee outright, and mask a token out of get_session_token's response but the protection itself is site-agnostic and needs zero cooperation from the page it's protecting.
How we built it
The core trick is where and how we intercept. The guard (guard/mcp-guard.ts) runs in the page's own MAIN JS world from document_start — before any page script runs and polls cheaply for modelContext to appear (since a polyfill or native implementation may define it moments later), then wraps executeTool in place rather than using Object.defineProperty. That matters: a global monkey-patch risks breaking other scripts touching the same built-in, and it wouldn't even help against a native implementation that never defines the property through page-visible calls anyway. Wrapping the method in place works retroactively regardless of how or when modelContext showed up, because callers invoke mc.executeTool(...) at call time rather than caching a reference.
Holding a call for approval means the guard has to pause mid-call: it awaits a decision, posting an APPROVAL_REQUEST into the page via postMessage, picked up by an isolated-world content script bridge that renders a shadow-DOM prompt directly on the page (isolated from the page's own render cycle) and posts the decision back. A 45s timeout, enforced on both ends, auto-denies so a call can never hang forever.
The live audit feed uses externally_connectable so a trusted page can chrome.runtime.connect() straight into the extension and get pushed every entry as it happens, across every tab not just the one it's running in.
DLP rules and masking logic were adapted from Surf's own production DLP engine, so tool results get classified the same way real user data already is elsewhere in the product.
Challenges we ran into
- MV3 service worker lifetime. Background service workers terminate after ~30s idle, which silently closes the audit-feed port — naively treating that as "extension not installed" produces a false flip where a real connection looks like it vanished. We fixed it with a client-side heartbeat (any message resets the idle timer) plus automatic reconnection, only reporting "not installed" after several consecutive failures.
- Injecting UI without fighting the page. The approval prompt has to render reliably on top of arbitrary pages — including ones running their own React/Vue render loops — without a layout fight. Rendering it from the isolated-world bridge as an anchored shadow-DOM element, rather than trying to inject it from the MAIN-world guard, solved this cleanly.
- Safe-by-default without full annotation coverage. Most real tools won't bother annotating
readOnlyHint/destructiveHint, sostrictmode needed a sane fallback — a name heuristic (get_/list_/search_reads as safe) that we know isn't a real safety analysis, which is exactly why the explicit blocklist exists as the actual zero-trust override.
Accomplishments that we're proud of
Getting protection to work transparently and generally — no page changes required, no cooperation needed from the site, working on any WebMCP-enabled page rather than just our own demo — while still being retrofittable to a modelContext that shows up late.
What we learned
How much of "agent safety" is really a timing and trust problem, not a policy problem, the hard part wasn't deciding what to block, it was guaranteeing we're in position to intercept before an agent (or the page) ever gets a live reference to executeTool, and staying resilient to a background execution model (MV3 service workers) that wasn't designed with this kind of always-on interception in mind.
What's next
Real safety-classification instead of name heuristics, broader cross-origin iframe coverage for the inspector, and since a Chrome extension only protects regular Chrome exploring what the same policy engine looks like at the browser or network layer for agents that bring their own embedded Chromium.
Built With
- chromeextension
- chromeextensionapi
- css3
- html5
- javascript
- manifestv3
- next.js
- node.js
- react
- typescript
- webmcp
Log in or sign up for Devpost to join the conversation.