I built an MCP server so AI assistants stop guessing Dutch health insurance prices
Ask an AI assistant what the Dutch mandatory health insurance deductible is for 2027 and you'll often get € 385. That was the 2026 figure. The 2027 figure is € 400 (Staatscourant 2026, 34372). The model isn't broken. It
Ask an AI assistant what the Dutch mandatory health insurance deductible is for 2027 and you'll often get € 385. That was the 2026 figure. The 2027 figure is € 400 (Staatscourant 2026, 34372).
The model isn't broken. It was trained on last year's data and has no idea it's stale. For most topics that's fine. For a product where premiums, coverage and hospital contracts change every year, it means confident wrong answers.
So I built Zorgverzekering.chat: a comparison site for Dutch health insurance, plus an MCP server that gives ChatGPT, Claude, Cursor, VS Code and Gemini CLI the current numbers with their sources. This post covers how it works and what I learned building it on my own.
TL;DR
- The problem: people compare on monthly premium, but annual cost = premium + the deductible you'll use + the care your policy doesn't cover. AI assistants make it worse by quoting last year's figures.
- Data: 607 insurer documents → Claude extracts structured data → validation and a diff against last year → human review of every change (8,540 items so far, 1,407 rejected).
- Engine: pure, deterministic, versioned TypeScript with golden tests, shared by the MCP server, the SSR site and the browser.
- MCP server: a stateless Cloudflare Worker with 12 read-only tools, one response envelope with sources and assumptions, and the same handlers behind a REST API.
-
Build process: a spec, 30 ADRs and a
CONTRACTS.mdwith exclusive file ownership, so several coding agents could build in parallel without stepping on each other.
Architecture
It's a pnpm monorepo with five shared packages: @zv/engine (calculation), @zv/schema (Zod), @zv/taxonomie (coverage codes), @zv/db (migrations and queries) and @zv/i18n. Everything that serves traffic runs on Cloudflare: Workers, D1, KV, R2, Analytics Engine. Data collection runs on my own machine. It doesn't need to be in the cloud, and keeping it local keeps the secrets and the cloud footprint small.
1. Write the contract before the code
I'm one person with a hard deadline. Insurers publish next year's premiums by 12 November, and people can switch until 31 December. I used AI coding agents heavily, and the thing that made that work wasn't the agents. It was the documents I wrote before they started.
-
A spec (
ideation/technische-implementatie.md) with the decisions: scope, the D1 schema, the calculation formula, the tool list and their input and output, privacy rules, and the business model. - ADRs for every decision, including the ones I changed later. There are 30 now. Accepted ADRs are never edited; a new ADR replaces the old one. That gives agents (and me) a reliable answer to "why is it like this?"
-
docs/CONTRACTS.md, which pins down what the spec leaves open: export names, binding names, and above all who owns which files.
| Agent | Owns (writes only here) |
| ----------------- | ---------------------------------------- |
| A contracts | packages/schema/**, packages/taxonomie/** |
| B engine | packages/engine/** |
| C db | packages/db/** |
| E mcp | apps/mcp/** |
| F public web | apps/web/** except G's folders |
| G accounts+admin | apps/web/src/pages/account/**, …/admin/** |
| I python ingest | ingest/** |
Plus a few rules that sound trivial and saved hours:
-
Don't run
pnpm addorpnpm install. Parallel installs race on the lockfile. If an agent is missing a dependency, it reports it and the integrator installs it. -
Workspace packages export TypeScript source directly, with no build step, so a change in
@zv/schemais visible everywhere immediately. - Money is always integer euro cents, percentages are integers 0–100, and dates are ISO strings. No floats anywhere near a premium.
- If something in the contract really doesn't work, make the smallest possible deviation and report it. Don't redesign.
With that in place, the first full build (web, MCP server, engine, database layer, ingest and admin) came together the same day the spec was finished, and production went live the next day. The data took a lot longer, which brings me to the interesting part.
2. LLM extraction, but nothing ships unreviewed
Insurers publish their policies as PDFs and HTML pages, each with its own structure and vocabulary. One insurer calls it fysiotherapie, another fysio- en oefentherapie, samen 12 behandelingen. Rule-based parsers per insurer break whenever a layout changes. Typing everything over by hand isn't feasible in one season.
The pipeline, in Python:
- Fetch each document listed in a YAML file per insurance group. Hash the bytes with SHA-256; if the hash is already known, stop. Otherwise store it in R2. The fetcher is polite: it respects robots.txt, sends a contact address in the user agent and makes at most one request per two seconds per host.
-
Extract with Claude through Cloudflare AI Gateway, using tool use with a strict JSON schema per document type. PDF text goes in per page with the page number, so every extracted value keeps a
bron_anchor(source page). The coverage taxonomy is passed as an enum. Anything that doesn't fit comes back asONBEKEND(unknown) with the original text, instead of being forced into the nearest code. -
Validate with pydantic plus domain rules:
- premium within 0.75–1.25× last year's,
- discount increases monotonically with the voluntary deductible,
- percentages 0–100, amounts under € 25,000, counts under 1,000,
- every value has a source and an anchor,
- each taxonomy code appears at most once per package.
- Diff against last year. Values that didn't change and were reviewed last year are carried over. Everything new or changed goes to the review queue.
-
Publish only after review. That creates a new
dataset_versie, and until then the site and MCP server keep serving the previous one.
The review screen shows the old value, the new value and the source passage side by side. The numbers so far:
| Source documents | 607 |
| Basic policies | 83 (38 brands, 10 groups) |
| Supplementary and dental packages | 390 |
| Coverage lines | 5,238 |
| Review items | 8,540 |
| Approved | 7,133 |
| Rejected | 1,407 (≈ 16%) |
One in six. Claude is genuinely good at reading insurance documents, much better than any parser I could have written in that time. It's still wrong often enough that publishing without review would have put hundreds of wrong numbers in front of people making a financial decision.
Lesson learned the hard way: at one point a coding agent approved 5,815 "new" items in one bulk action to speed things up. When those were checked against the sources, about 300 turned out to be wrong. Approvals are now one at a time, after checking. A review queue only works if the review is real.
Second lesson: decide where the truth lives. For me it's the local SQLite files plus the per-insurer YAML overrides. Remote D1 is a build artefact: every reload drops and rebuilds it from local. A "quick fix" made directly in production D1 is silently wiped by the next reload. Corrections go into the YAML, with a source.
3. A deterministic cost engine with golden tests
The comparison sorts on annual cost for your situation. Per adult, roughly:
J = 12 × basic premium (at the chosen deductible and payment term)
+ 12 × Σ supplementary premiums (at your age)
− collective discount (only on supplementary, never on basic)
+ min(expected deductible-liable care, mandatory + voluntary deductible)
+ statutory personal contributions
+ Σ max(0, cost of care − what your packages reimburse)
@zv/engine is pure TypeScript with no I/O. It takes a Zod-validated scenario and returns every line of the calculation with its source and its assumptions ("Physiotherapy rate € 42,50 per session, NZa average"). The same function runs in the MCP tool bereken_jaarkosten, in the server-rendered comparison and in the browser.
It's versioned (ENGINE_VERSION, now 1.3.0), and 42 golden test files with hand-checked outcomes cover cases like a single person with no care, a chronic patient who uses up the deductible, a family with children and orthodontics, and a 17-year-old who turns 18 halfway through the year. Any change to an outcome fails CI unless it comes with a new engine version and updated golden files.
Many rules weren't obvious. "Maximum € 200 per 2 years" for glasses: is that € 100 a year or € 200 now? I checked the conditions of Zilveren Kruis, VGZ and Menzis (the period starts in the year of purchase), so the full maximum is available in the year you buy, and the assumption is shown. Each rule like that has an ADR with links to the insurer documents it's based on.
4. The MCP server: stateless, read-only, sourced
zv-mcp is a Cloudflare Worker using @modelcontextprotocol/sdk and Hono over Streamable HTTP. It has no session state and no Durable Objects. Every tool is either a lookup or a pure calculation, so there's nothing to keep between calls. It scales horizontally and doesn't store anything a user tells it, which is also what the privacy design calls for.
Twelve tools, all annotated readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false:
get_wettelijke_parameters list_verzekeraars zoek_basisverzekeringen
get_polis zoek_aanvullende_pakketten
zoek_zorgaanbieder check_zorgaanbieder bereken_jaarkosten
vergelijk_polissen vergelijk_jaren get_intake_schema
get_bron
The names are Dutch domain terms on purpose. The descriptions are in English and explain each term (eigen risico = deductible), and every tool takes a taal (language) parameter.
Every tool returns the same envelope, both as structuredContent and as a JSON text block:
{
"data": {},
"samenvatting": "Two to four sentences in the requested language, quotable by the model.",
"bronnen": [{ "id": "sha256…", "titel": "Vergoedingenwijzer VGZ 2027", "url": "https://…", "anchor": "p.14" }],
"aannames": ["Physiotherapy rate € 42,50 per session (NZa average 2026)"],
"meta": { "engineVersie": "1.3.0", "datasetVersie": "2027.3", "sorteersleutel": "jaarkosten" },
"acties": [{ "type": "bewaar", "url": "https://www.zorgverzekering.chat/s/<scenario>" }]
}
-
samenvattinggives the model something short and correct to quote, so it doesn't paraphrase a 20 KB JSON blob. -
bronnenandaannamesmake it easy for the model to cite and caveat, and the server instructions tell it to. -
meta.sorteersleutel(sort key) is in every list. That's partly a legal requirement for me: I inform and calculate but don't mediate, so the ranking has to be explainable and never commission-driven. -
acties.bewaaris a link to the website with the scenario base64url-encoded in the URL. Sharing works without the server storing anything.
The server instructions start with the rule that motivated the whole project:
'1. Never state premiums, coverage (dekkingen), deductibles (eigen risico) or contract status from memory; always use the tools.',
Even the error messages repeat it. When a lookup fails, the text the model gets back ends with "do not state premiums or coverage from your own knowledge." At the moment of failure, a model is most tempted to fill the gap from memory.
A Workers gotcha: the MCP SDK builds an Ajv validator for elicitation responses, and Ajv generates code at runtime, which Workers don't allow (and which costs CPU in a 50 ms budget). My server never asks for elicitation, so it passes a validator that fails closed:
const GEEN_VALIDATOR: jsonSchemaValidator = {
getValidator: () => () => ({
valid: false,
data: undefined,
errorMessage: 'Schema validation is not supported',
}),
};
The tool list is converted to JSON Schema once per isolate and cached, for the same reason.
The same handlers serve a REST API under /v1 with an OpenAPI 3.1 spec generated from the Zod schemas via @hono/zod-openapi, so there's one code path for MCP, REST and the website.
Distribution: the server is listed in four places:
| Directory | Listing |
|---|---|
| Official MCP Registry | chat.zorgverzekering.mcp/zorgverzekering |
| Glama | listed, ownership verified |
| MCPBundles | listed |
Every claim is proved from the server's own domain. The registry uses domain proof over HTTP, because the apex redirects to www and the registry doesn't follow redirects. Glama and MCPBundles each read a file under /.well-known/, and a /.well-known/mcp.json server card makes the server discoverable for crawlers such as mcpub.dev. Connecting takes one URL:
# Claude: Settings → Connectors → Add custom connector
# ChatGPT: Settings → Apps & Connectors → Developer mode → Create
https://mcp.zorgverzekering.chat/mcp
# VS Code
code --add-mcp '{"name":"zorgverzekering","type":"http","url":"https://mcp.zorgverzekering.chat/mcp"}'
5. 1.58 million contract statuses without mirroring anyone
"Does my hospital have a contract with this policy?" is often the question that decides between a natura and a budget policy. Insurers publish hospital lists for some policies, but for most care providers the answer lives in each insurer's own provider search tool (zorgzoeker).
Mirroring those completely is out for three reasons:
- Scale: tens of thousands of provider codes × policies × two years, at a polite one request per two seconds, would take more than a week per insurer per round.
- Database rights: the EU Court of Justice (Innoweb v Wegener, C-202/12) treats a metasearch engine that effectively replicates a database's search function as reusing a substantial part of it.
- Most of those statuses would never be asked for.
So it's demand-driven:
- Published hospital lists first.
- Pre-warming: a nightly job refreshes the cache for providers people actually ask about (an Analytics Engine signal containing only the provider code, nothing about the person asking), plus a structural set of hospitals, clinics and mental health institutions.
- Stale-while-revalidate: an expired entry under 90 days old is still returned, with its fetch date as an explicit assumption, while a refresh is queued.
- Only a true miss returns "being fetched, ask again in a moment".
User lookups and pre-warming use separate queues, so a pre-warm backlog never delays a real question. The cache now holds 1,584,725 contract statuses, looked up against a register of 26,522 care providers.
6. The website: SSR, GET and boring on purpose
The site is Astro 5 with @astrojs/cloudflare. Every public page is fully server-rendered, with no client-side data fetching. The comparison runs on a plain GET with the scenario in the URL, so it works without JavaScript and is shareable and cacheable. The page is readable by the same AI crawlers whose answers I'm trying to improve. CI checks that content, tables and JSON-LD are present without JS.
The site is bilingual (Dutch, with English under /en/), because a large share of people who need to understand Dutch health insurance quickly are people who just moved here.
Two D1 details worth knowing:
-
One database per insurance year (
polis_2026,polis_2027) plus a sharedreferentiedatabase. That keeps each one well under D1's size limit and lets me publish 2027 without touching 2026. - Read replicas via the D1 Sessions API, so reads come from a nearby replica.
What I'd tell myself at the start
- The spec and the file-ownership table are the product. Parallel agents are only as good as the contract they build against.
- LLM extraction plus human review beats either one alone. Measure the reject rate. If it's 16%, you know why you can't skip the review.
- Never bulk-approve. Not as a human, and certainly not as an agent.
- Pick one source of truth for data, and treat everything downstream as rebuildable.
- Design the tool output for the model, not just the client. A short summary, sources, assumptions and a sort key in every response do more against hallucination than any prompt.
- Tell the model what not to do, especially in error messages.
The 2027 premiums are published by 12 November. The pipeline picks them up, I review the diff, and the 2027 comparison goes live. Switching is possible until 31 December.
Try it at www.zorgverzekering.chat, or connect https://mcp.zorgverzekering.chat/mcp to your AI assistant and ask it something about Dutch health insurance. I'd love to hear what breaks.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.


