An agent-operable health insurance comparator built for the WebMCP Challenge.
It exposes 13 WebMCP tools through document.modelContext so an AI agent (ChatGPT in-app
browser, Chrome 149+ with the WebMCP flag, or any MCP client through the polyfill) can operate the
real UI: set a risk profile, filter and sort plans, page through results, open plan details,
compare side by side, get an explainable top-3 recommendation, save a shareable comparison, and
prepare — but never send — a contact request.
Live demo: https://queplan.github.io/queplan-webmcp/ (GitHub Pages, auto-deployed from main
by .github/workflows/pages.yml).
The page looks like the real queplan.cl comparator (same brand, layout, filters, cards, compare bar, comparison modal and detail sheet) and adds an "Asistente IA" activity panel: a live, plain-Spanish timeline of every tool the agent calls, with a short spotlight on the part of the page that just changed — so judges see the agent operate the comparator step by step, and see exactly where it stops ("✋ Tu decisión: presiona Contratar").
This is a standalone, open-source demo of the WebMCP layer built by QuePlan
(a Chilean insurtech). The same layer is integrated into QuePlan's private production codebase
behind an internal feature flag; public rollout is pending. The monorepo is private, so this repo
ships the same tool names and input contracts (insuranceTypes, organizations,
precioMax/precioMin, order, currency, edad/sexo/ingreso/cargas, limite, query,
criterio, id, ids) against a fully synthetic catalog. Parameters whose concept doesn't exist in this catalog —
prevision (health-system domain), categoria (navigable parent category),
coverageOrganizations (provider network) — are omitted from the schemas, exactly the way
production omits per-country unsupported filters; grouped option trees are simplified to flat
lists.
All data is synthetic. Every insurer ("Andina Salud", "Cordillera Seguros", "Patagonia Vida", "Austral Care", "Altiplano Seguros"), plan, price and coverage figure is invented. No real insurer, quote, or personal data is involved, and nothing is ever submitted anywhere.
The person explains what they need in plain language; the agent operates the real comparator; the
page shows every step; the person keeps the final decision. The agent is an expert broker with
the human in command — it never contracts, and it never sends data without a human click
(preparar_solicitud only highlights the button; submitting is physically the human's).
npm install
npm run dev # http://localhost:5173
npm test # contract + UI + activity tests (vitest, 63 tests)
npm run build # static build in dist/ (relative asset paths: any static host or subpath)Requires Node 20+. Every push to main runs the tests, builds and deploys dist/ to GitHub
Pages (.github/workflows/pages.yml).
The page registers its tools on load — no toggle, no setup.
Option A — ChatGPT in-app browser: open the live demo URL inside ChatGPT's browser and ask it to operate the page (prompts below). Verified 2026-09-01: 13/13 tools registered and executed there, both on this public demo and on QuePlan's private integration (cross-checked by Claude and Codex agents).
Option B — Chrome 149+ (native WebMCP):
-
Open
chrome://flags/#enable-webmcp-testing, enable it, relaunch Chrome. -
Open the demo. The header badge should read
WebMCP: 13 tools (API nativa). -
Use any WebMCP-capable agent/extension (e.g. DevTools' Model Context panel) to list and invoke the tools — or drive them from the DevTools console (verified on the native runtime; note the native
executeTooltakes theRegisteredToolobject and the arguments as a JSON string):const mc = document.modelContext; const tool = (name) => mc.getTools().then((ts) => ts.find((t) => t.name === name)); await mc.executeTool(await tool('setear_perfil'), JSON.stringify({ edad: 34, sexo: 'Femenino', cargas: [{ edad: 5, sexo: 'Femenino' }] })); await mc.executeTool(await tool('aplicar_filtros_comparador'), JSON.stringify({ precioMax: 100000 })); await mc.executeTool(await tool('recomendar_seguros'), '{}');
Each call moves the page and adds a line to the "Asistente IA" panel.
Option C — any browser + any MCP client (dev relay): without the native API the page falls
back to @mcp-b/webmcp-polyfill automatically, and in dev mode (npm run dev) it also loads the
@mcp-b/webmcp-local-relay embed, which bridges the tab's tools to external MCP clients over a
local WebSocket:
-
npm run devand open http://localhost:5173 (keep the tab open). -
Configure the relay as an MCP server in your client — e.g. for Claude Code, a
.mcp.json:{ "mcpServers": { "webmcp-local-relay": { "command": "npx", "args": ["-y", "@mcp-b/webmcp-local-relay@4.0.0"] } } } -
The client can now call
webmcp_list_toolsand invoke the 13 tools by name (the embed and the relay server must be the same version — both are pinned to4.0.0).
The relay embed is dev-only; production builds ship no relay.
A scripted pass through the full journey — paste these one by one into the agent (ChatGPT in-app browser or a Chrome 149+ WebMCP agent) with the demo open:
- Watch the panel — keep the "Asistente IA" panel (right side, or the floating pill on small screens) in view: every step below appears there in plain Spanish, and the page spotlights what changed.
- Profile — "I'm a 34-year-old woman with two kids: a 5-year-old girl and an 8-year-old boy.
Set that up." (the sexes are explicit on purpose: if you only give ages, the agent should ask —
setear_perfilnever invents a dependent's sex; the profile card shows "—" for it) - Budget + needs — "Keep only complementary health and catastrophic plans under $120,000 a month."
- Explainable top-3 — "Recommend the best three for us and tell me WHY each one." (watch the "Tu comparación 🥊" podium open on screen; the reasons come as structured data)
- Compare — "Now compare the two cheapest of those head to head. Which wins on deductible?"
- More options — "Anything else beyond this page?" (the visible list pages forward)
- Share — "Save this comparison so I can send it to my husband." (state-restoring URL)
- Human-only CTA — "I want to be contacted about the winner." (the agent can only scroll and highlight the Contratar button — the click, and everything after it, is yours)
- Adversarial — "Filter prices from $100,000 down to $50,000... I mean min 100000, max 50000."
(typed
input_invalidoerror, no impossible state left behind)
English:
- "I'm a 34-year-old woman with two kids (5 and 8). Find me the three cheapest complementary health plans and compare them side by side."
- "Filter to oncology coverage only, sort by lowest price, and open the details of the best one."
- "Show prices in UF instead of pesos. What's the price range now?"
- "Save this comparison so I can send the link to my partner."
- "I want to be contacted about the second plan." (the agent can only highlight the button — you click it)
Español:
- "Tengo 41 años, hombre, una carga de 3 años. ¿Qué plan catastrófico me conviene con tope alto y deducible bajo? Muéstrame la ficha."
- "Compara los tres planes oncológicos y dime cuál gana en cada atributo."
- "Filtra solo Andina Salud y Cordillera Seguros bajo $80.000 mensuales."
- "Guárdame esta comparación para retomarla después."
- "Prepárame la solicitud del plan que elegimos." (el agente resalta el botón; el click es tuyo)
| Tool | Type | What it does |
|---|---|---|
estado_comparador |
read-only | Full current context: profile, currency, order, active filters (with monedaFiltros), total, page, price range (UF). |
listar_seguros |
read-only | Plans on the current page. total is always the full filtered total; enPagina the page size; placeholders without an id are excluded. |
ver_mas_resultados |
mutation (UI) | Advances the visible list to the next page. Last-page guard (sin_cambios) + auto-recovery to page 1 if pagination desyncs. |
obtener_opciones_filtro |
read-only | Translates filter names to IDs. query + limite search options without dumping the catalog into context. |
setear_perfil |
mutation | PATCH the quoting profile (edad, sexo, ingreso, cargas); cargas: [] clears; each carga requires edad (no phantom dependents) and a carga's sexo is never invented — absent stays absent (UI shows "—", the share link encodes only the age). ingreso is write-only — confirmed with ingresoActualizado: true, never the amount. |
aplicar_filtros_comparador |
mutation | insuranceTypes / organizations / precioMax+precioMin (CLP). ID lists REPLACE; [] RESTORES that filter's default — all categories / all companies marked (a company filter is all-by-default, so [] never means "0 companies"; the price filter ignores []); unknown IDs and inverted ranges (min > max) are typed errors. |
ordenar_resultados |
mutation | recommended / lowerPrice / higherPrice. |
cambiar_moneda |
mutation | Display currency CLP ⇄ UF (display-only, no re-quote). |
obtener_detalle_seguro |
read-only | Opens the plan's detail on screen and returns the sheet incl. price breakdown (long lists capped + recortado flag). |
comparar_seguros |
mutation (UI) | Side-by-side view of 2–3 plans + compact difference matrix with per-attribute winner. |
recomendar_seguros |
mutation (UI) | Explainable top-3 for the CURRENT profile+filters (mejor_valor / menor_precio / mayor_cobertura) with structured reasons per rank, and opens the podium side by side. menor_precio only ranks within the dominant currency. |
guardar_comparacion |
read-only | Shareable URL that restores the exact state (profile, filters, order, currency, comparison). |
preparar_solicitud |
human-in-the-loop | Navigates to the plan and highlights its "Contratar" button. Returns requiereConfirmacionHumana: true. Never submits. |
- Imperative, top-level
document.modelContext.registerTool— snake_case ASCII names. - Strict schemas:
enumfor every closed value,additionalProperties: false, per-parameter descriptions — plus own-schema validation in the handler, because the native browser engine does not validate (the polyfill does; the standard doesn't promise it). - Every tool returns human text and
structuredContent(+ declaredoutputSchemawhoseestadoenum is always COMPLETE — a partial enum makes output validation reject legitimate responses). - Shared result-state taxonomy in
estado:ok/timeout/error_backend/input_invalido/sin_cambios—isErroronly on the middle three;sin_cambiosis a readable no-op, never a fake success. Nothing is ever invented on the user's behalf: no phantom dependents, no defaulted sex, no "0 companies" from an empty selection. - Honest annotations:
readOnlyHinton reads,untrustedContentHintwherever output carries third-party catalog text. Never instructions inside descriptions or outputs. - Totals are consistent across tools (
listar_seguros.total≡estado_comparador.resultado.total), and every re-quoting mutation resets pagination to page 1 — the UI, the state and the list can never tell three different stories. - Every mutation moves the visible UI and is re-readable via
estado_comparador(read-modify-write is always possible). - Output budget: long lists are capped and flagged (
recortado), options are searchable (query), and rankings never mix currencies. - Consequential actions stay human: the agent proposes, the person confirms with a click.
agent (ChatGPT in-app browser / Chrome WebMCP / MCP client)
│
▼
document.modelContext.registerTool
│
┌───────────────────────────────────┼─────────────────────────────────────┐
│ src/webmcp/ (portable WebMCP layer) │
│ │
│ tools/*.ts ── 13 tool contracts (schemas, taxonomy, annotations) │
│ │ wrapped by │
│ validate-args.ts ── own-schema validation (works on native engine) │
│ native-boundary.ts ── native V8 promise + JSON-plain clone + in-band │
│ │ errors (survives zone.js-patched environments) │
│ activity.ts ── observer hook: one ActivityEvent per tool call (any │
│ │ engine, any outcome) — feeds the on-page activity panel │
│ register.ts ── engine detection (native → polyfill) + per-tool │
│ │ registration with graceful degradation + diagnostics │
│ ▼ │
│ ComparatorDataSource ◄── the ONLY seam the tools know about │
└───────────────────────────────────┬─────────────────────────────────────┘
│ implemented by
┌───────────────────────────────────▼─────────────────────────────────────┐
│ src/app/ (demo host — the private codebase binds the real Angular UI) │
│ store.ts ── in-memory state + pricing/filter/sort + share-URL codec │
│ ui.ts ───── vanilla-TS view styled after queplan.cl: cards, filters, │
│ profile card, compare bar, "Tu comparación" modal, detail │
│ sheet, highlight of the human-only "Contratar" button │
│ activity-panel.ts ── "Asistente IA" timeline + spotlight (observer) │
│ src/data/demo-products.json ── 25 synthetic plans, 5 fake insurers │
└─────────────────────────────────────────────────────────────────────────┘
The ComparatorDataSource interface is the portability seam: in QuePlan's private codebase the
same tool layer binds to the live Angular comparator (real quoting backend, real filters); here it
binds to an in-memory store. Tools, schemas, taxonomy and wrappers are identical by design.
QuePlan's comparator runs Angular + zone.js, which patches window.Promise — so every tool result
crosses the WebMCP boundary as a non-native thenable, and anything that reaches the engine is
structured-cloned. native-boundary.ts removes that whole class of hazards by construction: it
converts at the boundary (native V8 promise, JSON-plain descriptor and result, in-band errors), so
the same tool code works on the polyfill and inside zone-patched apps regardless of how a given
engine treats patched promises. This demo's layer is verified with the WebMCP polyfill, through a
real MCP client via the local relay, on Chrome's native WebMCP runtime, and end-to-end in the
ChatGPT in-app browser (2026-09-01: 13/13 tools registered and executed, on this public demo and
on the private integration, cross-checked by Claude and Codex). This demo has no zone.js, but
ships the wrapper — it is the piece most WebMCP adopters with a framework will need first.
src/webmcp/activity.ts wraps every tool at registration time
(withNativeBoundary(withActivity(withValidatedArgs(tool)))) and publishes one ActivityEvent
per execution — whatever the engine and whatever the outcome (ok, sin_cambios, a typed
input_invalido, or a thrown exception). It observes only: names, schemas and results are
untouched (a test asserts the descriptors are identical before and after wrapping).
src/app/activity-panel.ts subscribes and renders the "Asistente IA" panel: a timestamped
timeline in plain Spanish ("👤 Perfil: 34 años, femenino, 2 cargas ✓ → 25 opciones",
"🔎 Filtros: máx $100.000 · Salud Complementario + Catastrófico → 15 opciones",
"🏆 Top 3 recomendado (mejor valor): …", "🔗 Link guardado…", "✋ Tu decisión: revisa … y presiona
Contratar"), newest first, capped at 8 entries, with a 1.8 s spotlight on the zone that changed
(results badge, cards, price slider, profile card, comparison modal, detail sheet, the human-only
CTA). Errors show as "
tests/tools.contract.test.ts (vitest, no browser needed) locks the 13 contracts: registered
names, strict schemas with the complete estado enum, honest annotations, structured output
everywhere, consistent totals, []-restores-filter semantics ("compañías restauradas (todas)"),
inverted-range rejection, PATCH profile semantics with required carga edad and never-invented
sexo (output and share link), write-only ingreso confirmation, option search
(query/limite), pagination (advance, last-page guard, reset-on-mutation), explainable top-3
ranking, placeholder exclusion, share-URL round-trip, human-in-the-loop flag, and clone-safety of
every result. tests/ui.smoke.test.ts (happy-dom) proves tool calls move the visible UI — paging,
the recommendation podium, the queplan.cl anatomy (brand, banner, results badge, profile card,
sidebar counts, card data row, compare bar, "Tu comparación", detail tabs) and the "—" shown for a
dependent without sex. tests/activity.test.ts locks the activity hook: every executed tool
emits exactly one event (validation rejections and thrown handlers included), a broken observer
never breaks a tool call, descriptors are untouched by the wrapper, and the panel renders,
narrates, caps and spotlights as described. npm test runs all 63.
MIT © 2026 QuePlan SpA