NL-GOV-MCP
# NL-GOV-MCP
Dutch public-sector data is scattered across many sources that do not natively work together. CBS does not know what Tweede Kamer publishes. BAG does not know what DUO knows. Rechtspraak is disconnected from Rijksbegroting.
`NL-GOV-MCP` connects what the Dutch government has not connected itself: **one interface, many sources, one question, one answer — with provenance**.
It is an open-source [Model Context Protocol](https://modelcontextprotocol.io/) server that lets AI assistants search, combine, and return data from Dutch public-sector sources. Built by [WAINUT](https://wainut.ai), a one-stop AI shop in the Netherlands (AI Recruitment, AI Consulting & Implementation, AI & Data Training).
## What can you do with this?
Ask in plain Dutch or English. The server routes to the right sources, retrieves data, and returns structured results with source traceability.
Examples:
- *"Hoeveel sociale huurwoningen zijn er gebouwd in Rotterdam sinds 2020?"* → combines relevant housing/statistics sources
- *"Wat heeft de Tweede Kamer besloten over stikstof afgelopen maand?"* → parliamentary search with temporal parsing
- *"Welke basisscholen zijn er in Tilburg?"* → real per-school records from DUO (address, denomination, BRIN)
- *"Welke middelbare school scoort het best?"* → DUO exam results per school location (pass rate, average marks)
- *"Wat besteedt provincie Overijssel aan?"* → TenderNed procurement notices and awards
- *"Hoe stemde Tilburg bij de Tweede Kamerverkiezingen?"* → Kiesraad results per party
- *"Toon alle rechtspraak over huurrecht dit jaar"* → Rechtspraak search with date-aware mapping
- *"Wat is de luchtkwaliteit in Utrecht?"* → live Luchtmeetnet measurements from that city’s own stations
- *"Geef me de rijksbegroting voor onderwijs"* → Rijksbegroting search + chapter navigation
- *"Hoe is Richtlijn (EU) 2016/680 omgezet in Nederland?"* → EUR-Lex/CELLAR: the Dutch transposition measures with their Staatsblad reference
## How is this different from data.overheid.nl?
`data.overheid.nl` is primarily a catalog that tells you where data lives.
`NL-GOV-MCP` actively retrieves and normalizes data across many sources, can combine cross-source results, and returns a consistent MCP response contract ready for assistants and automations.
## Sources (46 connectors, 74 tools)
| Source | What it covers |
|---|---|
| CBS | Statistics Netherlands (demographics, economy, housing, labour; v4/v3 + fallback) |
| Tweede Kamer | Parliamentary documents, search, voting records, member info; single-document retrieval can optionally resolve resource URLs and include capped text previews for text-like formats |
| Officiële Bekendmakingen | Official publications (SRU/XML search + lookup) |
| Rijksoverheid | National government news/document search via the Rijksoverheid.nl RSS platform (server-side keyword) + school holidays |
| Rijksbegroting | National budget data + chapter helper |
| DUO | Per-school records (po/vo/mbo/ho addresses), per-location exam results, education dataset catalogue + RIO adapter |
| data.overheid.nl | National open data catalog (CKAN) |
| Overheid API register | API directory (requires `OVERHEID_API_KEY`) |
| KNMI | Weather datasets/files, warnings, earthquakes (requires `KNMI_API_KEY`) |
| PDOK / BAG | Geospatial search, BAG address registry, and authoritative per-address detail (oppervlakte, bouwjaar, gebruiksdoelen) via Kadaster Individuele Bevragingen REST API |
| Rechtspraak | Court rulings via official `uitspraken.rechtspraak.nl` search backend |
| RDW | Vehicle open data |
| Luchtmeetnet | Live air quality measurements per city/station (NO2, PM10, PM2.5, O3) |
| Rijkswaterstaat | Water data catalog + real-time measurements |
| NDW | Traffic discovery/metadata |
| ORI | Open Raadsinformatie discovery |
| NGR | National Geo Register (CSW metadata) |
| Ruimtelijkeplannen.nl (Wro/Bro) | Vigerende, ontwerp en vervallen ruimtelijke plannen via PDOK WMS, met status- en gemeentefilter |
| RIVM | Public-health discovery |
| Kadaster BAG (Linked Data) | SPARQL access to building/address linked data |
| RCE (Linked Data) | SPARQL access to cultural heritage linked data |
| Eurostat | EU statistics search + preview |
| data.europa.eu | EU open data catalog |
| DSO Omgevingsdocumenten | Discovery van omgevingsplannen, omgevingsvisies, programma's en omgevingsverordeningen onder de Omgevingswet (read-only metadata, vereist `DSO_API_KEY`) |
| data.politie.nl | Registered crime & nuisance figures per municipality/district/neighbourhood (CBS dataderden OData) |
| CBS Iv3 | Municipal & provincial finances — budgets, annual accounts, task fields (dataderden OData) |
| PDOK Bestuurlijke Gebieden | Official municipality/province boundaries and codes (OGC API Features) |
| PDOK Kadastrale Kaart (BRK) | Cadastral parcels, boundaries and designations (OGC API Features) |
| wetten.overheid.nl (BWB) | Consolidated texts of all national laws, decrees and regulations (KOOP SRU) |
| CVDR | Local & regional regulations of municipalities, provinces and water authorities (KOOP SRU) |
| NED | National Energy Dashboard — generation/consumption per source + forecasts (requires `NED_API_KEY`) |
| EP-Online | Building energy labels per address / BAG id (RVO, requires `EP_ONLINE_API_KEY`) |
| BRO | Basisregistratie Ondergrond — groundwater, CPT soundings, borings (keyless REST) |
| NS Reisinformatie | Train travel advice, departures/arrivals and disruptions (requires `NS_API_KEY`) |
| OVapi / NDOV | Realtime public-transport departures per stop + GTFS |
| BRON verkeersongevallen | Registered road-traffic accidents with location & severity (Rijkswaterstaat WFS) |
| DNB Statistics | Interest rates, mortgages, pensions, insurers, balance of payments (requires `DNB_API_KEY`) |
| NZa Zorgbeeld | Current waiting times for medical-specialist care per institution |
| Register Overheidsorganisaties | All Dutch government organisations + TOOI identifiers (KOOP) |
| TenderNed | Public procurement — tender notices, awards, market consultations; detail with CPV/NUTS codes and PDF text |
| Tuchtrecht | Disciplinary rulings for regulated professions (healthcare, bar, notaries, accountants, vets) — not on Rechtspraak.nl |
| Samenwerkende Catalogi | National index of products/services offered by municipalities, provinces and water authorities (KOOP SRU) |
| BRP Gewaspercelen (RVO) | Agricultural parcels with crop, category, area and polygon (PDOK WFS) |
| Kiesraad Verkiezingsuitslagen | Election results per party, nationally and per province/municipality, incl. turnout |
| EUR-Lex / CELLAR (EU) | EU legislation by CELEX or citation, title search, and Dutch national transposition measures per directive (keyless SPARQL; only the Official Journal is authentic, reuse with attribution) |
| LiDO (Linked Data Overheid) | Reference counts per document type to a ruling (ECLI), law article (BWB), EU act (CELEX) or Staatsblad/Staatscourant publication, with portal link (CC0) |
## Key features
### Consistent response contract
Every tool returns the same shape:
- `summary`
- `records[]`
- `provenance`
- optional `access_note`
- optional `failures[]`
- optional `pagination` (offset, limit, total, has_more)
- optional `verbose` (request timings, connector health snapshots)
### PDF text extraction
Most Dutch government "data" is text inside a PDF. Tools that reach a PDF resource extract its text layer instead of handing back a link only:
- `tweede_kamer_document_get` with `include_text: true` returns the text of a Kamerstuk PDF (`text_preview_source: "pdf_text_layer"`, plus page count)
- `tenderned_aanbesteding_get` with `include_text: true` returns the text of the official tender notice PDF
Extraction is capped (`max_chars`, default 12 000) and fails typed rather than hard: a scan without OCR reports `no_text_layer`, an encrypted file `encrypted`, an HTML error page `not_a_pdf`.
### Shared geo primitive
Bbox-driven sources (BRON verkeersongevallen, ruimtelijke plannen, BRP gewaspercelen) accept a `gemeente` name and resolve it to an RD (EPSG:28992) bbox through one shared implementation (`src/utils/geo.ts`), with a single extent validation and a consistent `access_note` when a name cannot be resolved.
### Built-in resilience (zero-config)
No setup required — the following run automatically in-process:
- Per-connector circuit breaker (auto-disables after repeated failures, probes for recovery). A source whose primary endpoint is expected to fail over gives its fallback its own connector name, so a degraded primary cannot lock out the path that still works (see Luchtmeetnet).
- Per-connector concurrency limiter (default 3 in-flight, overflow queued with timeout)
- In-process HTTP response cache with hardcoded TTL per source category
- Per-connector health counters (exposed via `/health/sources` on SSE transport)
### Graceful error handling
Typed errors:
- `timeout`
- `http_error`
- `rate_limited`
- `malformed_response`
- `not_configured`
- `circuit_open`
- `unexpected`
This lets assistants respond meaningfully instead of failing hard.
### Structured output & debug modes
- `outputFormat`: `json` (default), `csv`, `geojson`, `markdown_table`
- `offset` / `limit`: pagination with metadata
- `dryRun`: shows planned API calls without executing them
- `verbose`: adds request timings, fallback steps, and connector health snapshots
Available on `nl_gov_ask` and major individual tools: `cbs_tables_search`, `cbs_observations`, `data_overheid_datasets_search`, `duo_datasets_search`, `tweede_kamer_documents`, `tweede_kamer_search`, `officiele_bekendmakingen_search`, `rijksoverheid_search`, `rijksbegroting_search`, `overheid_api_register_search`.
### CBS trend enrichment
- `cbs_observations` injects lightweight trend fields when the result shape clearly supports it:
- `previous_period`
- `previous_value`
- `delta`
- `delta_pct`
- This only activates when there is a single clear period dimension and one numeric measure, so it stays inert on ambiguous wide tables.
### Smart routing + temporal parsing
- `nl_gov_ask` routes by intent, and can run multi-source queries in parallel.
- Natural date expressions in NL/EN are currently parsed in `nl_gov_ask` and mapped to source filters (`vorige week`, `sinds 2020`, `between 2018 and 2022`, etc.).
- Temporal parsing is resolved server-side with a real reference timestamp, cross-platform via Node runtime APIs (Windows/macOS/Linux).
- Default timezone: `Europe/Amsterdam`.
- Override options for `nl_gov_ask`:
- tool input: `timezone`
- tool input: `reference_now`
- environment: `NL_GOV_TIMEZONE`
- config: `config/default.json` → `temporal.defaultTimeZone`
### Cross-reference linking
Post-processing adds `related_links[]` when records share key identifiers (e.g. `ECLI`, `BWBR`, municipality codes), and can enrich legal references with direct links to `wetten.overheid.nl`.
## Quick start
Requires **Node.js >= 22**.
```bash
npm ci
npm run build
npm run dev # start stdio server (for Claude Desktop / Claude Code)
npm run dev:sse # SSE/HTTP server on port 3333
npm run dev:streamable-http # Streamable HTTP server on port 3333 (MCP spec 2025-03-26)
```
To verify your setup:
```bash
npm run check # type-check without emitting
npm test # unit tests
npm run test:questions # integration test suite (offline fixtures)
npm run test:live # integration test suite (live API calls)
```
## Configuration
### Transport modes
Three transport modes are supported. All expose the same 74 tools.
#### stdio (Claude Desktop, Claude Code)
```bash
npm run dev # development
npm run start # production
```
#### SSE/HTTP (Open WebUI, legacy MCP clients)
```bash
npm run dev:sse # development
npm run start:sse # production
```
| Endpoint | Description |
|----------|-------------|
| `GET /mcp` | SSE stream |
| `POST /messages?sessionId=...` | Message endpoint |
| `GET /health` | Server health check |
| `GET /health/sources` | Per-connector runtime health snapshot |
#### Streamable HTTP (MCP spec 2025-03-26)
```bash
npm run dev:streamable-http # development
npm run start:streamable-http # production
```
| Endpoint | Description |
|----------|-------------|
| `POST /mcp` | Initialize session + send messages |
| `GET /mcp` | Open SSE stream for server-initiated messages |
| `DELETE /mcp` | Terminate session |
| `GET /health` | Server health check |
| `GET /health/sources` | Per-connector runtime health snapshot |
Session management uses the `mcp-session-id` header.
#### Selecting transport via environment
Instead of CLI flags, you can set `MCP_TRANSPORT`:
```bash
MCP_TRANSPORT=sse node dist/src/index.js
MCP_TRANSPORT=streamable-http node dist/src/index.js
```
### Docker
```bash
docker build -f docker/Dockerfile -t nl-gov-mcp .
docker run --rm -p 3333:3333 \
-e KNMI_API_KEY=your-key \
-e OVERHEID_API_KEY=your-key \
-e BAG_API_KEY=your-key \
-e DSO_API_KEY=your-key \
nl-gov-mcp
```
### Claude Desktop integration
Build the project, then add an entry to your Claude Desktop config.
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"nl-gov-mcp": {
"command": "node",
"args": ["/absolute/path/to/NL-GOV-MCP/dist/src/index.js"],
"env": {
"OVERHEID_API_KEY": "...",
"KNMI_API_KEY": "...",
"BAG_API_KEY": "...",
"DSO_API_KEY": "...",
"NL_GOV_TIMEZONE": "Europe/Amsterdam"
}
}
}
}
```
Restart Claude Desktop after saving.
### Environment variables
| Variable | Default | Description |
|----------|---------|-------------|
| `NL_GOV_HTTP_PORT` | `3333` | HTTP port for SSE transport |
| `NL_GOV_TIMEZONE` | `Europe/Amsterdam` | Default timezone used by `nl_gov_ask` for natural date parsing |
| `KNMI_API_KEY` | — | Required for KNMI weather tools ([get a free token](https://developer.dataplatform.knmi.nl/open-data-api#token)) |
| `OVERHEID_API_KEY` | — | Required for API register tool ([request a key](https://apis.developer.overheid.nl/apis/key-aanvragen)) |
| `BAG_API_KEY` | — | Required for authoritative per-address detail via `bag_address_detail` (Kadaster Individuele Bevragingen REST). Without it the tool returns Locatieserver-only (`data_kwaliteit: "lookup_only"`). ([request access](https://www.kadaster.nl/zakelijk/producten/adressen-en-gebouwen/bag-api-individuele-bevragingen)) |
| `DSO_API_KEY` | — | Required for `dso_omgevingsdocumenten_search` (DSO Omgevingsdocumenten Presenteren API v8). Without it the tool returns `not_configured`. ([request access](https://developer.omgevingswet.overheid.nl/formulieren/api-key-aanvragen-0/)) |
| `NED_API_KEY` | — | Required for `ned_energie_search` (Nationaal Energie Dashboard). Without it the tool returns `not_configured`. ([request a free key](https://ned.nl/nl/api)) |
| `EP_ONLINE_API_KEY` | — | Required for `ep_online_energielabel` (RVO EP-Online energielabels). Without it the tool returns `not_configured`. ([request access](https://www.ep-online.nl/)) |
| `NS_API_KEY` | — | Required for `ns_reisinformatie` (NS Reisinformatie API). Subscribe to the **"Ns-App"** product (free external tier ~300 req/5 min) — NOT the deprecated "Public-Travel-Information" product. Without it the tool returns `not_configured`. ([get a free key](https://apiportal.ns.nl/)) |
| `DNB_API_KEY` | — | Required for `dnb_statistics_search` (DNB Statistics API, gateway `api.dnb.nl`). Subscribe to the free **"Public"** product on the portal and generate the key (self-service). Without it the tool returns `not_configured`. ([get a free key](https://api.portal.dnb.nl)) |
| `MCP_TRANSPORT` | `stdio` | Transport mode: `stdio`, `sse`, or `streamable-http` (alternative to CLI flags) |
| `LOG_LEVEL` | `info` | Pino log level (`debug`, `info`, `warn`, `error`, `silent`) |
### Running behind a proxy
Node's built-in `fetch` — which every connector uses — **ignores `HTTP_PROXY` / `HTTPS_PROXY`**. On a network that only allows outbound traffic through a proxy, requests therefore go out directly and individual sources start failing with confusing statuses (403, 406, timeouts) while `curl` to the same URL from the same machine succeeds, because curl *does* honour those variables.
Start the server with Node's proxy support enabled:
```bash
NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://proxy.internal:3128 npm run start
```
Node ≥ 22 prints an "experimental" warning for this flag; it works. Symptom to recognise: some sources work and others do not, with no pattern in the code — that is an egress problem, not a connector problem.
## Source-specific details
### Tweede Kamer document retrieval
- `tweede_kamer_documents` stays lean and returns search/discovery metadata.
- `tweede_kamer_document_get` can optionally:
- resolve the underlying resource URL / file metadata
- include a capped text preview for text-like resources
- extract the text layer of PDF resources (`include_text: true`), reported as `text_preview_source: "pdf_text_layer"` with `resource_pages`
- `nl_gov_ask` may automatically deepen the top Tweede Kamer match when the user explicitly asks for content/summary rather than only discovery.
### Rechtspraak details
`rechtspraak_search_ecli` mirrors the official frontend search backend (`/api/zoek`) instead of the legacy open-data feed.
Uses structured parameters instead of natural-language parsing:
- `sort`: `relevance` (default), `date_newest` (publication date desc), `ruling_newest` (ruling date desc)
- `date_filter`: `week`, `month`, `year`, `last_year` (maps to Rechtspraak facet filters)
The LLM interprets user intent and maps it to these parameters. A lightweight server-side query rewriter strips residual question framing as a safety net.
Responses include facet-driven context in `access_note` when filters are applied.
### EUR-Lex and LiDO details
`eurlex_search`, `eurlex_document` and `eurlex_nl_omzetting` query the keyless CELLAR SPARQL endpoint of the EU Publications Office. `id` accepts a CELEX number (`32016R0679`) or a citation (`Verordening (EU) 2016/679`, `Richtlijn 95/46/EG`); invalid input is rejected before any request. Search matches title words only. Only the electronic Official Journal of the EU is authentic; EUR-Lex content may be reused with attribution. `nl_gov_ask` routes a CELEX number or EU citation (and explicit terms such as "EU-richtlijn", "EUR-Lex") to these tools before the Officiële Bekendmakingen route.
`lido_verwijzingen` returns how often a ruling, law (article), EU act or Staatsblad/Staatscourant publication is referenced in LiDO, per document type, plus a link to the full list on the LiDO portal. It uses only the services LiDO documents as public (`get-id`, `get-aantal-per-informatietype`); LiDO data is CC0.
**Tuchtrecht is a separate source.** Disciplinary rulings against doctors, lawyers, notaries, accountants, vets and bailiffs are published on `tuchtrecht.overheid.nl`, not on Rechtspraak.nl. Use `tuchtrecht_search` for those; `nl_gov_ask` routes disciplinary questions there before it considers Rechtspraak.
### TenderNed details
`tenderned_aanbestedingen_search` sends only parameters that are verified to filter server-side (`search`, `typeOpdracht`, `procedure`, `publicatieDatumVanaf`, `publicatieDatumTot`, `page`, `size`). The upstream silently ignores unknown parameters, so an unsupported filter would look applied while returning everything — hence the deliberately small parameter surface. Page size is capped at 100 by the API; use `page` for more.
### DUO per-school data
`duo_schools` and `duo_exam_results` query the CKAN **datastore** (the rows), not the dataset catalogue:
- `duo_schools` returns individual schools/institutions per sector (`po`, `vo`, `mbo`, `ho`) with address, BRIN/instellingscode, denomination and website. `municipality`, `place` and `postcode` filter exactly (case-insensitive input); `name` is a free-text search.
- `duo_exam_results` returns pass rates and average exam marks per school location, filterable by year, municipality and education type, with `sortByScore` for "which school scores best". Coverage: school years 2013–2017 — the last per-location exam dataset DUO publishes machine-readably; a year outside that range returns 0 records with an explanation in `access_note` rather than a validation error.
### Elections (Kiesraad)
`verkiezingsuitslagen_search` accepts an election code (`TK20251029`), an election kind (`TK`, `gemeenteraad`, `Europees Parlement`) or nothing at all (most recent election). `gebied` drills down to a province or municipality; unknown areas fall back to the national result with an explanatory `access_note`. Use `list_elections: true` for the available elections.
## Documentation
See:
- `docs/ARCHITECTURE.md` — technical internals, layer diagram, request lifecycle, resilience stack
- `docs/SOURCES.md` — endpoint details per connector
- `docs/TOOLS.md` — full tool catalog with behavior notes
- `docs/BACKLOG-SOURCES.md` — planned integrations
## Contributing
PRs are welcome — bug fixes, new source connectors, or improvements to existing ones.
See **[CONTRIBUTING.md](CONTRIBUTING.md)** for setup, workflow, and a step-by-step guide for adding a new source connector.
## License
This project is licensed under the [Apache License 2.0](LICENSE). See [NOTICE](NOTICE) for required attribution.
**WAINUT** and **NL-GOV-MCP** are trademarks of WAINUT B.V. The Apache License 2.0 does not grant permission to use these names, trademarks, or branding to imply endorsement of derivative works. Forks and derivative works must retain the [NOTICE](NOTICE) file as required by the license.
---
**About WAINUT** — WAINUT is your one-stop AI shop in the Netherlands. We help organizations adopt AI and build an AI-enabled workforce — from recruiting the right talent, to implementing the right tools, to training teams that actually use them.
Exploring AI for your organization? → [wainut.ai](https://wainut.ai) — Unleash Your Potential.TDQS
Scored across 64 tools
Most tools are clearly distinguished by their source prefix and action verb, e.g., bag_address_detail vs. bag_linked_data_select. However, some overlap exists between generic search tools (e.g., cbs_tables_search, cbs_observations) and the smart router nl_gov_ask, which could cause confusion for an agent.
All tools follow a consistent pattern: source prefix (e.g., bag_, cbs_, duo_) followed by a descriptive verb_noun. This makes it easy to predict tool names and understand their domain.
With 64 tools, the server covers an extremely broad range of Dutch government data sources. While each tool serves a specific purpose, the sheer number may be cumbersome for an agent to navigate, suggesting a potential need for modularization.
The tool set covers major domains (addresses, statistics, cadastre, legislation, etc.) with read operations. However, some sources only offer discovery without full text retrieval (e.g., dso_omgevingsdocumenten_search, ruimtelijke_plannen_search), leaving gaps for detailed data access.