eurostat-mcp-suite
<div align="center">
# ๐ช๐บ eurostat-mcp-suite
**The Unified Model Context Protocol (MCP) Server for Eurostat Statistics & EU Data Analysis**
*Seamlessly search, query, and analyze 8,900+ official European Union statistical datasets via MCP.*
*Supports SDMX 3.0, SDMX 2.1 Comext (International Trade), DuckDB In-Memory SQL Canvas, NUTS Regional Levels, CSV Exports, Resources, and Prompts.*
*Plus Italian open data: ISPRA environment and BDAP public finance, on the same SQL canvas.*
[](https://www.python.org/)
[](https://modelcontextprotocol.io/)
[](https://github.com/jlowin/fastmcp)
[](https://duckdb.org/)
[](./LICENSE)
</div>
---
## ๐ Overview
**`eurostat-mcp-suite`** is a unified, high-performance MCP server designed to provide AI Agents (Claude Desktop, Cursor, Roo Code, Antigravity CLI, Windsurf, Goose, Zed) with complete access to official **Eurostat**, **ISPRA** (Italian environmental agency) and **BDAP** (Italian public finance, Ragioneria Generale dello Stato) data.
It combines all capabilities into one robust, clean architecture:
1. **Full API Coverage**: Connects to both Eurostat **SDMX 3.0** (economy, demography, health, energy) and **SDMX 2.1 Comext** (international trade with `DS-` prefixes).
2. **DuckDB SQL Dataframe Canvas**: Stages complete datasets into an in-memory DuckDB database so AI agents can execute read-only `SELECT` queries without blowing context window token limits.
3. **NUTS Regional Filtering**: Filter geographic observations by NUTS regional levels (`aggregate`, `country`, `nuts1`, `nuts2`, `nuts3`).
4. **Catalogue Discovery**: Tokenized search with cursor pagination and Eurostat theme tree navigation.
5. **Local CSV Exporting**: Export single or batch datasets directly to local CSV files.
6. **Native Resources & Prompts**: Standardized `eurostat://` URI resources and guided analytical workflows.
7. **ISPRA Environmental Data**: Italian environmental statistics via three distinct ISPRA surfaces โ Linked Open Data (SPARQL), the IdroGEO REST API, and INSPIRE OGC WFS โ exposed as `ispra_*` tools that share the same DuckDB canvas, so Eurostat and ISPRA tables can be joined in one query.
8. **BDAP Public Finance Data**: Italian state budget, SIOPE cash flows, public bodies' balance sheets, debt and public works from the Ragioneria Generale dello Stato (~3,850 datasets), exposed as `bdap_*` tools on the same DuckDB canvas.
---
## โก Agent Auto-Installation Prompt
You can copy and paste the prompt below directly to your AI Assistant (Cursor, Roo Code, Antigravity CLI, Windsurf, Claude) to let it **automatically configure and verify** `eurostat-mcp-suite`:
```text
Please read the instructions from https://github.com/ManoloZocco/eurostat-mcp-suite and automatically install the Eurostat MCP Suite server for my environment:
1. Detect my OS (macOS/Windows/Linux) and current client editor (Claude Desktop, Cursor, Roo Code, VS Code, Windsurf, AGY).
2. Check if Python 3.10+ and `uv` (or `pip`) are available.
3. Clone https://github.com/ManoloZocco/eurostat-mcp-suite.git to a persistent directory if not present.
4. Add `eurostat-mcp-suite` to my client's MCP configuration file (e.g. claude_desktop_config.json, mcp_config.json, settings.json).
5. Verify that the server starts successfully and test `search_datasets("GDP")`.
```
---
## ๐งฐ Available Tools (Eurostat core โ 12 tools; 32 across the suite)
### ๐ Discovery & Catalogue Navigation
| Tool | Description |
|:---|:---|
| `search_datasets(query, limit, cursor)` | Search Eurostat catalogue by keyword with tokenized matching & cursor pagination |
| `browse_themes(theme_code)` | Drill down Eurostat top-level statistical themes (Economy, Population, Trade, Energy, etc.) |
| `list_popular_datasets(category)` | List pre-curated high-value Eurostat datasets by domain |
### ๐ Metadata & Data Retrieval
| Tool | Description |
|:---|:---|
| `get_dataset_info(dataset_id)` | Inspect dataset title, dimensions, observation count, and sample values |
| `get_dimension_values(dataset_id, dimension_code, nuts_level)` | List valid codes for a dimension with optional NUTS regional filtering (`country`, `nuts1`, `nuts2`, `nuts3`) |
| `query_dataset(dataset_id, filters, nuts_level, since_period, until_period, last_n_periods, limit)` | Query decoded statistical observations with `OBS_FLAG` status markers |
### ๐ฆ In-Memory DuckDB SQL Canvas
| Tool | Description |
|:---|:---|
| `download_dataset_to_sql(dataset_id, table_name, filters, since_period, until_period, last_n_periods, limit)` | Download complete dataset into an in-memory SQL table for high-speed analysis |
| `sql_query(query)` | Run read-only SQL `SELECT` queries across staged DuckDB tables |
| `list_staged_tables()` | List currently staged SQL tables and row counts |
| `describe_table(table_name)` | Inspect table schema and sample rows |
### ๐ Local File Export
| Tool | Description |
|:---|:---|
| `export_to_csv(dataset_id, filepath, filters, since_period, until_period, last_n_periods, limit)` | Save dataset observations directly to a local CSV file |
| `export_multiple_datasets(dataset_ids, output_dir, last_n_periods)` | Batch export multiple datasets to a target directory |
---
## ๐๏ธ Using `filters`
`filters` maps a dimension name to one code or to a list of codes. Both shapes are
accepted, and a JSON string encoding the same object works too (some MCP clients
serialise objects to text before they reach the server):
```python
export_to_csv("urb_cenv", "./out.csv", filters={"indic_ur": "EN5206V"})
export_to_csv("urb_cenv", "./out.csv", filters={"indic_ur": ["EN5206V", "EN5207V"]})
query_dataset("nama_10_gdp", filters={"geo": ["IT", "FR"], "unit": "CP_MEUR"})
export_to_csv("urb_cenv", "./out.csv", filters='{"indic_ur": "EN5206V"}')
```
The geographic dimension is **not always called `geo`** โ urban audit datasets such as
`urb_cenv` call it `cities`. Call `get_dataset_info(dataset_id)` first: it reports
`dimension_names` and `geo_dimension` for that specific dataset.
### Partial results are always declared
`query_dataset`, `export_to_csv` and `download_dataset_to_sql` return `applied_criteria`
plus a per-dimension breakdown (`dimension_values_returned` / `dimension_values_exported`
/ `dimension_values_staged`) of exactly which codes reached the output, along with
`is_complete` and a `warning` when the result is a subset.
`query_dataset` defaults to `last_n_periods=5`, so it returns a recent slice; pass
`last_n_periods=0` for the full time range. `export_to_csv` and `download_dataset_to_sql`
default to the **full** time range and no row cap.
---
## ๐ฎ๐น ISPRA Tools
ISPRA does not publish a single API, so three separate surfaces are wrapped. All ISPRA
tools are prefixed `ispra_` and never mix semantics with the Eurostat ones.
### ๐ฑ Linked Open Data (`dati.isprambiente.it/sparql`)
| Tool | Description |
|:---|:---|
| `ispra_list_datasets()` | List the environmental LOD datasets and the years each covers |
| `ispra_get_dataset_info(dataset_id)` | Years, administrative levels carrying data, sample parameters |
| `ispra_get_parameters(dataset_id, query, limit)` | List measurable parameters โ the ISPRA analogue of a Eurostat dimension's codes |
| `ispra_query_indicators(dataset_id, parameters, places, place_codes, place_level, years, limit)` | Fetch indicator values |
| `ispra_export_to_csv(dataset_id, filepath, ...)` | Export indicator values to a local CSV |
| `ispra_download_to_sql(dataset_id, table_name, ...)` | Stage indicator values into the shared DuckDB canvas |
| `ispra_sparql(query, limit)` | Read-only SPARQL escape hatch (SELECT/ASK only) |
Datasets: `soilc` (consumo di suolo), `urban`, `bathw` (acque di balneazione), `pest`
(pesticidi), `marind`, `ihi`, `ostreopsis`, `rmn`, `ron`.
### โฐ๏ธ IdroGEO REST API
| Tool | Description |
|:---|:---|
| `ispra_idrogeo_search_places(level, name, limit)` | Resolve a place name to the IdroGEO numeric `uid` |
| `ispra_idrogeo_indicators(level, place_id, dataset)` | Landslide/flood hazard and risk indicators (`pir`) or IFFI landslide inventory (`iffi`) |
### ๐บ๏ธ INSPIRE OGC WFS
| Tool | Description |
|:---|:---|
| `ispra_geo_list_layers(service)` | List feature layers of the `sdi`, `sgi2` and `sgi3` GeoServer instances |
| `ispra_geo_query_features(service, layer, bbox, cql_filter, properties, limit, include_geometry)` | Download features as tabular rows |
### Model differences from Eurostat
ISPRA has no fixed dimension grid. An indicator is identified by a **parameter** code
(e.g. `soilc_DISECO11`) measured for a **place** in a **year**, so use
`ispra_get_parameters` where you would use `get_dimension_values`.
Place names are ambiguous across administrative levels โ "Firenze" is both a *comune*
and a *cittร metropolitana*, with very different values. Every row therefore carries
`place_type`, and `place_level` narrows the query:
```python
ispra_query_indicators("soilc", parameters="soilc_DISECO11",
places=["Firenze"], years=2021)
# -> Firenze/MetropolitanCity = 7.38 % and Firenze/Municipality = 42.53 %
```
### Joining ISPRA and Eurostat
Both sources stage into the same DuckDB canvas, so one `sql_query` spans them:
```python
download_dataset_to_sql("urb_cenv", "eu_soil", filters={"indic_ur": "EN5206V"})
ispra_download_to_sql("soilc", "ispra_soil", parameters="soilc_DISECO11",
years=2021, place_level="comune")
sql_query("""SELECT i.place_name, i.value AS ispra_consumo_pct,
CAST(e.value AS DOUBLE) AS eurostat_agri_pct
FROM ispra_soil i JOIN eu_soil e ON e.cities_label = i.place_name
WHERE e.time = '2012' ORDER BY i.value DESC""")
```
โ ๏ธ **Joining on place name is lossy.** The two sources use different geographies:
Eurostat's urban audit includes "greater city" aggregates (`Bergamo (greater city)`)
that have no ISPRA *comune* counterpart, and spellings can differ. Prefer a `LEFT JOIN`
so unmatched rows stay visible, and check the ISTAT code (`ispra_soil.istat`) when you
need certainty.
---
## ๐ฎ๐น BDAP Tools (Italian public finance)
[BDAP OpenData](https://bdap-opendata.rgs.mef.gov.it) is the open data catalogue of the
**Ragioneria Generale dello Stato** (MEF): state budget, SIOPE cash flows, public bodies'
balance sheets and debt, public works, public employment, healthcare accounts โ
~3,850 datasets across 13 themes. All tools are prefixed `bdap_`.
| Tool | Description |
|:---|:---|
| `bdap_search_datasets(query, limit, start)` | Search the catalogue by a single keyword, with `start` paging |
| `bdap_list_themes()` | List the 13 thematic groups |
| `bdap_list_theme_datasets(theme_id, limit, offset, resolve_titles)` | Browse a theme's datasets โ the only working way to filter by subject |
| `bdap_list_tags()` | List the catalogue's keywords |
| `bdap_get_dataset_info(dataset_id)` | Description, theme, licence, update frequency, resources |
| `bdap_preview_dataset(dataset_id, limit)` | Columns and first rows โ constant cost on any dataset size |
| `bdap_dump_status(dataset_id)` | Whether the dump is cached, downloading (with progress), or absent |
| `bdap_download_to_sql(dataset_id, table_name, filters, columns, limit, wait_seconds)` | Stage a dataset into the shared DuckDB canvas |
| `bdap_export_to_csv(dataset_id, filepath, filters, columns, limit, wait_seconds)` | Export to a local CSV |
### Handling dumps that reach hundreds of megabytes
Dump sizes are brutally skewed โ a 2% sample of the catalogue has a **median of 0.6 MB
but a maximum of 262 MB**. Since there is no DataStore, the whole dump is the only way in,
so the tools are built around that rather than pretending it away:
* **Preview never downloads the file.** It closes the connection as soon as it has enough
rows: 0.4 s on a 262 MB dataset, same as on a small one. Use it to learn column names
before filtering. It deliberately does not populate the cache โ a truncated dump must
never be mistaken for a complete one.
* **Downloads stream to an on-disk cache and continue in the background.** If the dump is
not ready within `wait_seconds` (default 25 s, comfortably under a client's tool
timeout), the tool returns `status: "downloading"` with progress instead of hanging.
**The transfer is not cancelled** โ call the tool again, or poll `bdap_dump_status`.
Once cached, staging is ~1 s and the cache is reused for a week.
* **Filters, column projection and limits are pushed into DuckDB's CSV reader**, so rows
never travel through Python. Staging all 1,088,987 rows of the 262 MB SIOPE dump takes
1.7 s from cache; with a filter and three columns, 1.5 s.
* Cache location defaults to a temp directory; override with the `BDAP_CACHE_DIR`
environment variable.
โ ๏ธ **Encoding varies per dataset.** Some dumps are UTF-8, others are Windows-1252 โ the
262 MB SIOPE dump carries `\xe0` for "ร " in 81k places. The encoding is detected during
the download and the cache is normalised to UTF-8, so `Indennitร ` arrives intact rather
than as a replacement character.
```python
bdap_preview_dataset("42f61eb2-5475-4de2-8d58-34f9a2340608") # 0.4s on 262 MB
bdap_download_to_sql("42f61eb2-...", "siope") # returns "downloading"
bdap_dump_status("42f61eb2-...") # poll until ready
bdap_download_to_sql("42f61eb2-...", "siope", # ~1.5s from cache
filters={"Provincia BDAP": "TORINO"},
columns=["Anno/Mese calendario", "Importo cumulato"])
```
### API deviations you need to know
BDAP advertises a CKAN API but ships a partial, modified implementation. Verified
against the live portal on 2026-08-08:
* **The base path is `/SpodCkanApi/api/3/action`,** not the `/api/3/action` CKAN
documents โ the portal itself is Drupal, and the bare path returns a Drupal 404.
* **`package_search` reports `count` as the size of the returned page**, not the total
number of matches. The real total is not exposed; page with `start` until a page comes
back empty. The tools report `"total_matches": "unknown"` rather than inventing one.
* **`fq` is silently ignored.** A made-up field name returns the same first page as no
filter at all, so there is no faceted search. Use `bdap_list_theme_datasets` instead.
* **`q` is a literal substring match, not a tokenised search.** `"opere pubbliche"` works
because those words are adjacent in the title, and so does the fragment `"ere pubbl"` โ
but `"pubbliche opere"`, `"Opere Pubbliche"` (double space) and `"SIOPE Toscana"` all
return nothing. Boolean operators do not work. Prefer one distinctive term and narrow
locally.
* **There is no DataStore.** `datastore_search`, `resource_show` and
`current_package_list_with_resources` return a Tomcat 404, so no server-side row
filtering exists. Data comes from the whole-dataset CSV dump at
`.../api/3/datastore/dump/{id}.csv` and is filtered locally โ which is exactly why
`bdap_download_to_sql` plus `sql_query` is the right way to work with it.
* **`HEAD` and `Range` are both useless.** `HEAD` returns `text/html` with no
`Content-Length`; a `Range: bytes=0-999` request returns the entire body with HTTP 200
and no `Accept-Ranges`. So a dump's size is only knowable by opening the GET and reading
the response header, a partial read means closing the connection early, and a failed
download cannot be resumed โ it restarts from zero.
* **Resource metadata lies.** A resource declared `"format": "CSV"` is routinely a PDF
report (`"mimetype": "application/pdf"`). `bdap_get_dataset_info` reports both fields
and warns when they disagree.
* **`group_show` returns bare dataset ids**, not package objects, so
`bdap_list_theme_datasets` resolves titles with one extra request per dataset. Pass
`resolve_titles=False` for a fast id-only listing.
### Data conventions
The dump is semicolon-separated with a trailing separator (the phantom empty column is
dropped on load). Every column is staged as **text**: amounts use a dot decimal separator
and dates are `dd/mm/yyyy`, so cast in SQL. Filter values are always passed to DuckDB as
bound parameters, never interpolated into SQL.
```python
bdap_search_datasets("SIOPE", limit=10)
bdap_preview_dataset("d032b3a2-2b70-4193-a0c8-cb7eb69f8710", limit=5)
bdap_download_to_sql("d032b3a2-2b70-4193-a0c8-cb7eb69f8710", "ssn_cce")
sql_query("""SELECT "Anno di Riferimento" AS anno,
ROUND(SUM(CAST("Importo Totale" AS DOUBLE))/1e9, 2) AS miliardi_eur
FROM ssn_cce GROUP BY 1 ORDER BY 1""")
# 2008 โ 977.68 | 2009 โ 1006.34 | 2010 โ 1024.95 | 2011 โ 1033.84
```
Because BDAP shares the same DuckDB canvas, an Italian public finance table can be joined
against a Eurostat or ISPRA one in a single `sql_query` call.
---
## ๐ Resources & Prompts
### ๐ฆ MCP Resources
* `eurostat://datasets/{query}`: Live search resource matching keywords.
* `eurostat://dataset/{code}/dimensions`: Dimension structure resource for a dataset.
* `eurostat://themes`: Root theme tree resource.
* `eurostat://popular`: Pre-curated popular datasets list resource.
### ๐ก MCP Prompts
* `explore_topic(topic)`: Guided step-by-step workflow to analyze any European statistical topic.
* `compare_countries(indicator_dataset, countries)`: Template for cross-country EU comparative analysis.
* `trade_analysis(trade_dataset, reporter)`: Template for EU international trade & Comext statistical analysis.
---
## ๐ Installation & Configuration
### Prerequisites
* **Python 3.10+**
* **`uv`** (Recommended, `curl -LsSf https://astral.sh/uv/install.sh | sh`) or `pip`
---
### 1. Claude Desktop
Add the following to your `claude_desktop_config.json`:
* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
* **Linux**: `~/.config/Claude/claude_desktop_config.json`
**With `uv` (recommended):**
```json
{
"mcpServers": {
"eurostat-suite": {
"command": "uv",
"args": [
"--directory",
"/path/to/eurostat-mcp-suite",
"run",
"server.py"
]
}
}
}
```
**With standard `python`:**
```json
{
"mcpServers": {
"eurostat-suite": {
"command": "python",
"args": [
"/path/to/eurostat-mcp-suite/server.py"
]
}
}
}
```
---
### 2. Cursor IDE
1. Open **Cursor Settings** -> **Features** -> **MCP**.
2. Click **+ Add New MCP Server**.
3. Set **Name**: `eurostat-suite`
4. Set **Type**: `command`
5. Set **Command**: `uv --directory /path/to/eurostat-mcp-suite run server.py`
Alternatively, add to your Cursor `.cursor/mcp.json`:
```json
{
"mcpServers": {
"eurostat-suite": {
"command": "uv",
"args": ["--directory", "/path/to/eurostat-mcp-suite", "run", "server.py"]
}
}
}
```
---
### 3. VS Code (Roo Code / Cline / MCP Extension)
In your VS Code `settings.json` or Roo Code / Cline MCP settings (`cline_mcp_settings.json`):
```json
{
"mcpServers": {
"eurostat-suite": {
"command": "uv",
"args": [
"--directory",
"/path/to/eurostat-mcp-suite",
"run",
"server.py"
]
}
}
}
```
---
### 4. Windsurf / Codeium
Add to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"eurostat-suite": {
"command": "uv",
"args": [
"--directory",
"/path/to/eurostat-mcp-suite",
"run",
"server.py"
]
}
}
}
```
---
### 5. Antigravity CLI / AGY
Run:
```bash
agy mcp add eurostat-suite -- command uv --directory /path/to/eurostat-mcp-suite run server.py
```
---
### 6. Goose CLI
Run:
```bash
goose mcp add eurostat-suite -- command uv --directory /path/to/eurostat-mcp-suite run server.py
```
---
## ๐ Usage Examples
### Example 1: High-Speed GDP Analysis via DuckDB SQL Canvas
```text
User: "Download the annual GDP dataset nama_10_gdp and run a SQL query to show average GDP for Germany, France, and Italy over the last 5 years."
Agent steps:
1. download_dataset_to_sql(dataset_id="nama_10_gdp", table_name="gdp_table")
2. sql_query("SELECT geo, geo_label, AVG(value) as avg_gdp FROM gdp_table WHERE geo IN ('DE', 'FR', 'IT') GROUP BY geo, geo_label ORDER BY avg_gdp DESC")
```
### Example 2: International Trade Analysis (Comext SDMX 2.1)
```text
User: "Query EU extra-trade statistics for dataset DS-045409."
Agent steps:
1. get_dataset_info("DS-045409")
2. query_dataset(dataset_id="DS-045409", filters={"reporter": ["EU27_2020"]}, limit=50)
```
### Example 3: ISPRA Environmental & Hydrogeological Hazard Analysis
```text
User: "Check soil consumption in Tuscany municipalities for 2021 and retrieve landslide hazard indicators for Florence."
Agent steps:
1. ispra_query_indicators("soilc", parameters="soilc_DISECO11", years=2021, place_level="comune")
2. ispra_idrogeo_search_places(level="comune", name="Firenze")
3. ispra_idrogeo_indicators(level="comune", place_id=48017, dataset="pir")
```
### Example 4: Italian Public Finance (BDAP) & Multi-Source SQL Join
```text
User: "Compare ISPRA soil consumption data against Eurostat urban agricultural indicators in DuckDB."
Agent steps:
1. download_dataset_to_sql("urb_cenv", "eu_soil", filters={"indic_ur": "EN5206V"})
2. ispra_download_to_sql("soilc", "ispra_soil", parameters="soilc_DISECO11", years=2021, place_level="comune")
3. sql_query("""
SELECT i.place_name, i.value AS ispra_consumo_pct, CAST(e.value AS DOUBLE) AS eurostat_agri_pct
FROM ispra_soil i
LEFT JOIN eu_soil e ON e.cities_label = i.place_name
WHERE e.time = '2012'
ORDER BY i.value DESC
LIMIT 10
""")
```
---
## ๐ License
This project is licensed under the **MIT License** - see the [LICENSE](./LICENSE) file for details.
TDQS
Scored across 12 tools
Each tool targets a distinct phase of the Eurostat workflow: discovery, metadata inspection, data querying, SQL staging, and export. Even query_dataset and sql_query are clearly separatedโone queries the Eurostat API, the other queries staged DuckDB tables.
Most tools follow a verb-first snake_case pattern (get_, search_, browse_, list_, download_, export_), but sql_query breaks the pattern (noun-led) and export_to_csv vs export_multiple_datasets have inconsistent phrasing. Overall still readable and predictable.
With 12 tools, the server is well-scoped for Eurostat data access, covering discovery, metadata, querying, DL staging, SQL analysis, and export without ballooning in size. Each tool serves a clear purpose in the pipeline.
The tool surface covers the full Eurostat data lifecycle: find datasets, inspect dimensions, query data, stage to SQL, run SQL, and export. Minor gaps include no way to drop staged tables or list all datasets within a theme, but these are not critical for typical use.