Inspiration
The WebMCP Challenge's own rules ship search_products, compare_products, and request_quote as the example tool names. That's the obvious shape for almost any submission, and it's the wrong shape to build on top of if the goal is to show WebMCP doing something a REST API couldn't. I have a geophysics background, specifically Vertical Electrical Sounding (VES) and resistivity survey work, and picking that domain over another generic storefront meant the physical constraints in the tool logic would be real instead of decorative.
What it does
A human fills in a site brief on the page: target depth, expected resistivity range, terrain, and budget. That data lives only in the browser's memory until someone explicitly submits a quote. An AI agent connected via WebMCP reads that live brief, scores a given instrument against it using physical limits (can this unit's depth range actually reach the target depth, will its resistivity ceiling saturate on this site), writes results into a visible comparison tray in real time, and drafts quote justification text that a human has to review and submit themselves. The agent can propose a quote. It cannot send one.Before this, matching a site's physical constraints to the right instrument meant manually cross-referencing depth ranges, resistivity ceilings, and budget against 25 spec sheets, or explaining the same constraints over again to a salesperson. Here, a human states the constraints once, in a form that never leaves the browser until they choose to submit it, and the agent does the cross-referencing: reading the live brief, scoring each instrument's actual physical fit, and rejecting the ones that can't do the job, with a stated reason. The person still makes every decision that matters (which instrument, whether to request a quote), but skips the manual lookup work, and the reasoning behind each recommendation is visible on the page, not buried in a chat transcript.
How I built it
Cloudflare Workers serves the storefront and a plain REST catalog listing. Cloudflare D1 holds 25 real VES/resistivity instruments from ABEM, AGI, IRIS Instruments, GF Instruments, Geometrics, Megger, and Zonge, with depth ranges, resistivity specs, and estimated market prices. The client is plain TypeScript bundled with esbuild, no SPA framework, so document.modelContext registration timing stays simple. The app registers five tools: read_site_brief, assess_site_fit, flag_depth_mismatch, update_comparison_tray, and draft_quote_notes.
Challenges I ran into
The original design called for fit-scoring to run inside a Cloudflare Worker, kept off any public route. That doesn't work: a tool's execute() handler runs in the browser, and a browser can only reach Worker code over HTTP, so any such route is curlable whether or not it's documented anywhere. The fix was moving the scoring functions into the browser bundle itself, operating on an already-fetched public catalog cache, so no request ever carries the site brief anywhere.
The second problem was subtler. Two of the five tools, assess_site_fit and flag_depth_mismatch, originally just returned JSON to the calling agent. That meant an agent holding only read_site_brief plus the public catalog listing could work out the same score itself, without needing either tool at all. The fix was having both tools also write a visible fit badge onto the product's own catalog card, so the result lands on the page a human is looking at, not just in the agent's own reasoning.
The WebMCP proposal spec describes agent.requestUserInteraction() as the built-in way to pause a tool for human confirmation. Chrome's own developer documentation describes it as not yet implemented. Rather than build the quote-submission gate around an API that might not fire, the plain-button commit gate outside the tool surface became the primary mechanism, with requestUserInteraction() wired in as an upgrade that no-ops safely if the browser doesn't support it. Live testing through a real agent (Codex, via the ChatGPT desktop app's built-in browser) confirmed it: the API silently did nothing, and the plain button carried the whole gate.
The hardest problems didn't show up until adversarial testing started, using Google Antigravity with both the source code and the live site open at once. The first pass found an XSS: agent-supplied text (the quote reasoning, tray product IDs) was going into the page via unescaped innerHTML. It also found that the commit endpoint, POST /api/quotes, had zero protection at all. A bare script outside any browser could submit a quote directly, bypassing the human-approval button entirely. Both got fixed: HTML escaping everywhere agent-supplied strings render, and an isTrusted check on the submit button's click handler so a script-simulated click (element.click(), dispatchEvent) can't fire it, only a real one.
A second adversarial pass immediately broke the second fix. The isTrusted check only guards the button's own handler; it does nothing against a request that skips the browser entirely. The interim mitigation, checking the request's Origin header, turned out to be checking a public value: this repo is open source, so a script reading it knows exactly what Origin to send, and Node's fetch() has no browser-level restriction against forging that header. Closing it for real took Cloudflare Turnstile, verified server-side against a secret that lives only in a Worker secret, never in the repo or the client bundle. That's the one piece of the whole flow a script reading the source still can't derive. A third retest confirmed the original bypass now returns 403, and separately caught two smaller bugs in the Turnstile wiring itself: a race where the ready-callback was defined inside the ES module and could run after Turnstile's own script had already looked for it, and a missing hostname check on the verification response.
What I learned
Checking a spec claim against the primary source instead of a blog summary caught a bug before it shipped: two different search results disagreed about whether requestUserInteraction() even exists in the current draft. A second lesson came from the security arc: a check that looks like it closes a gap can still be trivially defeated if the thing it validates (an Origin header, a value read from public source code) isn't actually a secret. The only thing that closed it for real was a value a reader of this repo could never find. The broader lesson about the tools themselves: returning a computed value to an agent is not the same as making that computation something only WebMCP could have produced. If the same result is derivable from data already available through legitimate channels, the tool isn't pulling its weight.
What's next
A few things worth doing beyond the hackathon. agent.requestUserInteraction() is worth revisiting once Chrome ships it, since the tool already checks for it and falls back safely, no rework needed on that side. The same physical-constraint pattern used for VES/resistivity gear would carry over to adjacent survey equipment: ground-penetrating radar's depth of investigation is even more terrain-dependent than VES's (clay soils can cut penetration from tens of meters to under one), and seismic refraction has its own hard depth limit set by spread length and source energy rather than soil conductivity. Both map onto the same "does this equipment's spec sheet reach my target depth" tool logic. And right now a submitted quote just lands in D1; connecting that to a distributor contact would turn this from a demo into something a geophysicist could use in the field.
Built With
- cloudflare-d1
- cloudflare-turnstile
- cloudflare-workers
- css3
- esbuild
- html5
- javascript
- model-context-protocol
- typescript
- vitest
- webmcp
- wrangler
Log in or sign up for Devpost to join the conversation.