A Chrome extension that holds every WebMCP write until a human approves it. Reads run freely, around 5ms, no prompt.
Works on any WebMCP site, including sites that never adopted anything and don't know it exists. No SDK, no code change, nothing for the site to install.
The approval card shows the tool name, the real arguments, and the site. Not a yes/no on an opaque tool name
Catches sites that lie. A tool that declares readOnlyHint: true and then fires a POST while running is held at the network layer.
"Always allow" is per site + tool, listed in the popup, and revocable there. A cache you can't see or undo isn't consent.
A declined call tells the agent plainly that a human declined it and not to retry, instead of a generic error it would just try again.
How we built it
A document_start content script in the MAIN world replaces registerTool before any page script runs. The site registers its tools the normal way and is calling our wrapper. It never finds out.
MAIN world is what makes this possible. The default isolated world hands the script a private copy of the context, and you'd patch something the page never reads.
MAIN-world scripts can't touch chrome.*, so decisions cross a private MessagePort → an isolated-world bridge → chrome.runtime → an MV3 service worker that owns the queue, the allow list and the badge.
Classification is one line: def.annotations?.readOnlyHint !== true. No annotation means gated. It never reads the tool's name or description to guess intent — that's attacker-authored text, and putting it in charge of a security decision is the problem we're solving.
window.fetch is patched too, with a call stack tracking which tool is executing, so a "read-only" tool that POSTs gets caught.
Pending proposals live in chrome.storage.session so they survive service-worker eviction. The allow list is in chrome.storage.local, keyed origin::toolName.
Zero dependencies, no build step, ~15 KB.
## Challenges we ran into
We found a vulnerability in our own approval channel. Approvals were relayed over window.postMessage, which page scripts share with the interceptor. A page could watch for its own proposal, read thepproval — approving its own writes with no human involved.Fixed with a private MessagePort handed over at document_start, before any page script exists. Re-ran the exploit: the page can't even see a proposal now.
We were silently destroying tool results. On approval the code did typeof result === 'string' ? result : 'Approved.'. On the network path, commit resolves with a Response — so an approved request hring "Approved." Our own demo never caught it because itdiscards its response. Only showed up testing against a site we didn't write.
Accomplishments that we're proud of
It works on sites we didn't build. On Cloudflare's WebMCP coheld with the cart at 0, and only after approval did itbecome 2 × Guji Shakiso, $44.00.
The network-layer catch is confirmed two independent ways: wire on decline, **exactly one* on approve — verified byResource Timing inside the browser and by the server's own access log outside it.
We found and fixed our own self-approval vulnerability beforather than quietly patching it.
We removed two features that didn't earn their place. Shipping less than we built was the right call both times.
Zero dependencies, no build step, 15 KB. Download, unzip, lo
What we learned
Real sites don't annotate. Chrome Labs' own hotel demo hying readOnlyHint. That's exactly why gate-by-default isright, and exactly why one-click "always allow" has to exist.
Testing against sites you didn't write finds things your own worst bugs surfaced that way.
The WebMCP runtime replaces a thrown error with generic text, so a rejection reaches the agent looking like a bug — and an agent that thinks a tool is
buggy retries it. A declined call has to resolve with a clea
API details that cost us time: getTools() returns a Promise, and inputSchema arrives as a JSON string, not an object.
Reading navigator.modelContext logs a deprecation warning nt.modelContext` first.
The honest summary: this raises the cost of an injection from "the agent was convinced" to "the agent was convinced and a human approved a card
describing the action." That's a big gap. It isn't a proof.
What's next for WebMCP Consent
Advisory-only LLM triage. A model offers a plain-Englishith the raw metadata beside it — but a human still clicks towhitelist. Advisory on purpose: a model that auto-approves based on a tool's own description reopens the exact vulnerability we're defending against.
Support destructiveHint and idempotentHint, the other stextensions of the same model.
Patch XMLHttpRequest and sendBeacon to close the non-fetch gap.
Community-curated allow lists, ad-blocker style. Credible wirt problem today.
For browsers that don't accept extensions, a local proxy injecting the same patch. Platform-agnostic, but it asks the user to trust a local root CA.
Log in or sign up for Devpost to join the conversation.