The problem

Almost every SaaS company's top-of-funnel is a free tool. A calculator, a lookup, a checker: give away something useful, gate the good part behind an email address. It works because a person on that page wants the good part.

Now the visitor is an agent.

It reads the facts, reports them in chat, and leaves. It never sees the gate, so the person never sees the offer. And the funnel shows nothing at all — because a WebMCP tool call is an ordinary function call. No page view, no click, no event. The lead magnet quietly stops generating leads for exactly the visitors who are growing fastest, and there is no number anywhere that says so.

We found all of this on our own site. Sixteen free tools were agent-callable and not one of them emitted a single analytics event. Every one of those pages had an email gate and no tool touched any of them. And the one place a tool did reach a lead endpoint, it did so silently while advertising readOnlyHint: true — inserting a lead row and setting a cookie for someone who had consented to nothing.

We had built the plumbing and skipped the business half. That gap is this project.

What we built

leadmcp — an Apache-2.0 library for the conversion half of WebMCP, plus the sixteen free tools on a live production site that use it.

This is deliberately not another registration wrapper. webmcp-react and Chrome's useWebMCPTool already turn a schema and a function into a registered tool, and they do it well. The unsolved part is what happens after the tool returns: declare the gate → open the gate → attribute the call, plus one primitive nobody else has — a tool call that suspends on a person's decision.

Declare the gate

A read tool returns the ungated slice plus an explicit block naming what is withheld, why, and which tool opens it. The agent can now say "there's a full report with the site analysis — want me to have them email it to you?" instead of reporting a dead end. It hands the conversion back to the person.

The declaration is not a substitute for withholding. Our snapshot tools project facts through selectSnapshotSections(variant) first, so an agent on the zoning page cannot read the buildable envelope that page deliberately does not render. Then they declare what was left out.

Open the gate

A capture tool that requires consentToEmail to be literally true, tells the agent in words to ask the person for their own address in this conversation and never reuse one from earlier context, and records where the consent came from — on_page (a person ticked a box) versus agent_relayed (an agent asserted they agreed in a chat we cannot see). Those are not the same consent, and a CRM that cannot tell them apart cannot be audited. Under Canada's CASL that distinction is the whole ballgame.

Critically, each capture tool calls the page's own submit handler, not a fresh fetch. Same request, same rate limit, same CRM sync, same success state — so the person watching the screen sees the send happen, instead of being emailed by an actor they never saw act. It also means one send path to maintain instead of two that drift.

The newsletter opt-in and the self-described role field are deliberately never relayed by an agent. An agent reporting "they said yes to updates" is not an express opt-in, and a guessed role puts invented segmentation data in the CRM.

Attribute the call

Every tool reports to one observer with a five-value outcome, not success/failure: ok, invalid_input, gated, consent_missing, error. gated is the funnel step nobody instruments — the agent reached the wall and the person was never shown the offer. A high invalid_input rate is a description problem, not a code problem.

And one property does the heavy lifting: agent_surface, optional on our shared analytics props, absent meaning a person. That single change made every free-tool funnel we already had splittable by human vs agent, without a parallel event family nothing joins to. It is one literal rather than an is_agent boolean, so a second surface can arrive without rewriting history.

A tool call that waits for a person

Geocoding "4170 Sophia St" returns three plausible parcels. A chat agent picks the first and sounds confident. Instead, our resolve_address tool suspends — the page shows three pins and the execute() promise stays pending until the person taps one, or rejects with an AbortError if they decline or the agent cancels. Single-flight, so a second request supersedes rather than queues: two chips competing on one canvas is not a state a person can answer.

Whether to suspend or return a correctable error is a surface decision, not a global one. A map where a pin means something asks on the canvas; a form hands back an error and lets the agent ask in words. Both patterns ship here, on purpose.

Why this is a strong fit for WebMCP

We already run a server-side MCP endpoint, so "an agent can call our API" is not the interesting part. Three things only WebMCP makes possible:

The agent acts on the page the person is looking at. No copy-paste step, no second source of truth. When an agent moves the pencil-test assumptions, the sliders move. When it calls focus_lot_map or set_planning_layers, the map the person is staring at pans and the zoning layer comes on. The person can be a step inside the agent's loop. A tool call that blocks on a human decision is not something a server-side MCP tool can express — there is no page to ask on. The gate and its consent live where the person is. Lead capture is a browser-side, consent-bearing act. Moving it server-side is exactly what loses the audit trail that makes it lawful.

What people and agents can do together that was hard before

The agent can hand the conversion back. Before, a gate was invisible to an agent — it read what it could and stopped. Now the withheld material is named, so the agent can offer it, and the person decides. That was not expressible at all. A tool call can pause for a human judgement. The ambiguous address is the honest case: the agent genuinely cannot know which parcel is meant. It asks on the canvas instead of guessing, and resumes with the answer. You can finally count agents. The same free-tool conversion event now fires from a click and from a tool call, split by one property. Before this, agent traffic through a lead magnet produced literally zero telemetry. Consent survives the handoff. An agent can carry an email address to the gate without the resulting CRM row being indistinguishable from a person who ticked a box themselves.

How WebMCP is implemented

All on the draft document.modelContext.registerTool contract (19 Aug 2026 WebMCP CG draft), feature-detected — with no modelContext present every entry point is a no-op and the pages work normally. document.modelContext.registerTool({ name: 'email_feasibility_report', description: 'Email the full feasibility report...', inputSchema: { /* derived from the page's own Zod schema / }, execute: async (input) => { / calls the page's own submit handler */ }, });

31 tools across 9 modules. 4 site-wide (list_free_tools, navigate_to_tool, get_feasibility_snapshot, search_marketplace), page-scoped sets across six free-tool surfaces, 5 capture tools — one per email gate — and 7 on the Lot Studio shared canvas including the three map tools. Tools are derived from the app's existing Zod schemas. defineTool turns a schema plus a function into a bounded, validated tool via native z.toJSONSchema(). If the sliders say 1–12 storeys, so does the agent's schema, forever — there is no second hand-written JSON Schema to drift. Invalid arguments come back as { error, issues } rather than a rejected promise, because agents retry well against data and badly against an opaque throw. The agent surface is derived from the marketing catalogue. navigate_to_tool's enum comes from FREE_TOOLS, so a release-gated tool disappears from the agent surface exactly when it disappears from the site. A test fails if a free tool exists that no page registers tools for — which caught a real bug: one page had been agent-navigable for weeks while registering nothing, so an agent could arrive and find nothing callable. Registration is lifecycle-safe and reports partial failure. One AbortController per mount, so a soft navigation or a Strict Mode double-mount cannot leave duplicate registrations. registerTool is invoked as a method on the model context, never destructured off it — a detached call fails a native WebIDL receiver check that a vi.fn() mock passes happily, and there is a test asserting the receiver. And registerTools never rejects, returning failed[] instead: a throw mid-loop used to abandon every later tool, which reads to an agent as "this page cannot do that" rather than as a bug. Annotation discipline. readOnlyHint only where it is true — which meant fixing our own lie: the site-wide read now sends capture: false, so it creates no lead row and sets no cookie. A tool that suspends on a human decision carries neither hint: it has a side effect, because it puts a question in front of someone.

Package: 11 source files, 71 tests, tsc clean, eslint clean, zero knip findings. App-side WebMCP and free-tool suites: 650 passing.

Limitations

WebMCP is an experimental draft API; Chrome needs chrome://flags/#enable-webmcp-testing. agent_surface on server events is read from a client-supplied header. It segments a funnel and is never used to authorize, gate, quota, or bill. Agent-relayed consent is weaker than an on-page tick. We chose to let the agent pass the email rather than suspend for an on-page form, so the affirmative is an agent's report of what the person said. consent_source keeps that auditable and re-confirmable rather than hiding it. navigate_to_tool performs a full navigation, so the page-scoped tool set changes on load rather than swapping live via ontoolchange. Lot facts are only as good as the municipal source behind them; Vancouver is the deepest coverage. Typology overlays are illustrative massing, not a permit-ready design.

Client verification update (2026-09-03): The public production Lot Studio flow was verified in the ChatGPT desktop in-app browser with GPT-5.6 Terra. All 11 WebMCP tools registered. The agent called resolve_address for 4170 Sophia St, Vancouver BC; the page displayed candidates and paused for a human selection; after accepting the matching Vancouver parcel, get_lot_facts, set_planning_layers, highlight_constraint, and preview_typology (laneway) all completed on the shared canvas. This confirms the earlier GPT-5.6 Luna failure was a client/model gate, not a Bucky registration failure. The selected lot's displayed rear-setback and FSR values were defaulted and the tested highlights had no citation count; the laneway footprint is explicitly illustrative, not permit-ready.

Built With

  • maplibre
  • model-context-protocol
  • next.js
  • posthog
  • react
  • supabase
  • tailwind
  • typescript
  • vercel
  • vitest
  • webmcp
  • zod
Share this project:

Updates

Submission history