
Wraps the Eurostat Statistics and TOC APIs to make 8,933 EU datasets searchable and queryable through MCP. You get five tools: keyword search across the catalogue, theme tree navigation, dataset metadata inspection, dimension value enumeration, and filtered data queries with NUTS geo-level support. The query tool decodes JSON-stat 2.0 responses into labeled observations and catches Eurostat's async-response warnings before they silently fail. Useful when you need programmatic access to EU economy, demography, trade, or regional statistics without manually navigating the web interface or writing your own API client. Ships with a public hosted instance at eurostat.caseyjhand.com and runs locally via stdio or HTTP.
Search and query the Eurostat catalogue — EU economy, demography, trade, health, and NUTS regional data via MCP. STDIO or Streamable HTTP.
Public Hosted Server: https://eurostat.caseyjhand.com/mcp
6 tools for discovering and querying Eurostat statistical datasets, plus 2 more when the optional dataframe canvas is enabled:
| Tool | Description |
|---|---|
eurostat_search_datasets | Search the Eurostat catalogue by keyword — returns codes, descriptions, period coverage, and theme breadcrumbs |
eurostat_browse_themes | Navigate the Eurostat theme hierarchy — list root themes or drill into subthemes and datasets |
eurostat_get_dataset_info | Fetch metadata for a dataset: dimensions with sample values, time range, observation count, and last-update date |
eurostat_get_dimension_values | List all valid codes for a specific dimension (e.g., all geo codes, all unit codes); supports NUTS hierarchy filtering |
eurostat_query_dataset | Fetch a deterministic preview of decoded statistical observations with dimension filters, NUTS geo-level, and time-range controls |
eurostat_download_dataset | Download a whole dataset through the SDMX 2.1 TSV bulk endpoint and stage every observation on the dataframe canvas |
eurostat_dataframe_describe | List the tables staged on a dataframe canvas with their row counts and column types — canvas only |
eurostat_dataframe_query | Run a read-only SQL SELECT across staged tables — canvas only |
eurostat_search_datasetsSearch the Eurostat dataset catalogue by keyword.
totalMatches and page slots count unique query targetslimit (1–100, default 20) sets the page size, totalMatches reports the full count, and passing the returned nextCursor back as cursor pages through every match over a stable order. Cursors are bound to their originating query and catalogue snapshot — reusing one with a different query, or after the catalogue refreshes, returns invalid_cursor instead of a silently shifted pagenextStep hint on each result points at the next tool to callEUROSTAT_TOC_CACHE_TTL_MS), then refreshed on the next calleurostat_browse_themes for structured domain exploration when keywords are uncleareurostat_browse_themesNavigate the Eurostat theme tree.
theme_code: returns the top-level themes (Economy and finance, Population, Transport, etc.)theme_code: returns immediate children — subtheme folders and datasets in that branchnextStep hint suited to the level (drill into folders or inspect a dataset)otherPlacements names those so the ambiguity is visibleeurostat_get_dataset_infoFetch metadata for a Eurostat dataset before querying it.
time — is described from the full dataset-available value set rather than from a populated observation sliceeurostat_get_dimension_values for the full listeurostat_get_dimension_valuesList all valid values for a specific dataset dimension.
unit, na_item, geo, time, etc.) from the same SDMX content constraint used by dataset metadatageo dimension, supports NUTS hierarchy filtering: aggregate (EU/EA totals), country (the default when omitted), nuts1, nuts2, and nuts3. The response reports the effective level; an empty level is no_results, not a claim that the dataset is missing. Pairing geo_level with any other dimension is rejected rather than ignoredeurostat_query_dataset return nothing without error; verify codes here firsteurostat_query_datasetFetch statistical data from a Eurostat dataset.
{dimension_code: [value1, value2, ...]}aggregate, country, nuts1, nuts2, nuts3) — mutually exclusive with a non-empty geo entry in filters; an empty array is treated as no filter and droppedsince_period/until_period (e.g., "2020", "2023-Q1") or last_n_periods for the N most recentpreview_limit controls the deterministic inline prefix (default 50, max 500). It does not change the match, totals, period coverage, or staged rows; filters and period controls reduce the match itself. There is deliberately no cursor or offsetOBS_FLAG status (p = provisional, e = estimated, etc.) and a separate CONF_STATUS confidentiality marker (C = confidential, usually the reason a value is null)obsCount, missingObsCount, and timeRange always describe the full match. truncated is independent of preview_limit and is true only when the match crosses the 5,000-observation staging thresholdcanvasId / tableName / stagedRowCount; matches at or below 5,000 are never staged. The rows stream into the table one at a time from the response body already in memory, so nothing extra is fetched and the match is never materialized as an array. Call eurostat_dataframe_describe before eurostat_dataframe_query. Without a canvas those fields and tool guidance are absent, and narrowing the query is the way to the restcanvas_id from an earlier response to stage several results side by side and join across themeurostat_download_dataset reads the SDMX bulk endpoint instead, at roughly half the byteseurostat_download_datasetDownload a whole dataset through the SDMX 2.1 TSV bulk endpoint (/sdmx/2.1/data/{dataset}?format=TSV).
eurostat_query_dataset reads for the same data, because the wide layout writes each dimension key once per row instead of once per observation. Measured across four datasets from 1.1M to 12.8M observations{dimension_code: [value, ...]} map as eurostat_query_dataset and are applied by Eurostat before the body is sent. They become a positional key on the request path, which must carry one position per dimension — the server builds it from the dataset's own dimension order, so a filter naming a dimension the dataset does not have is rejected with the real list rather than sent as a malformed keysince_period / until_period. There is deliberately no "last N periods": the TSV layout keeps a column for every period whichever selector is used, and lastNObservations merely blanks the unselected cells — measured at ~3× the equivalent JSON-stat body. startPeriod removes the columnsContent-Length, so the limit is applied as bytes arrive and the transfer is aborted the moment it is spent — not measured after the fact. A truncated download returns its rows with budgetExceeded: true rather than an error, so the work already paid for is not discarded. EUROSTAT_BULK_MAX_BYTES sets the ceilingContent-Encoding header; the only header-level tell is a .tsv.gz filename on Content-Disposition, and the switch does not track dataset size, so the magic bytes are what decidesyncResponse ticket instead of data; read as TSV that yields a header row of XML and no observations, so it is classified up front as a non-retryable error naming what to narrownot_found, 140 → filter_arity, 150 → invalid_dimension (which also covers a period range outside the dataset's coverage). Each maps to a typed reason with a recovery hint naming the tool to call nextcanvasId / tableName / stagedRowCount; rows stream into the table one at a time, so a multi-million-row download never materializes as an array. Only preview_limit rows (default 50, max 500) come back inline, and they are the leading rows of the staged table. Call eurostat_dataframe_describe before eurostat_dataframe_queryrowCount, missingCount and periodRange describe it, but only the preview is retained — the response says so plainly instead of implying the rest is reachableeurostat_dataframe_describe / eurostat_dataframe_querySQL over the results eurostat_query_dataset and eurostat_download_dataset stage. Listed only when the dataframe canvas is enabled (CANVAS_PROVIDER_TYPE=duckdb); the server is fully functional without it, and clients never see tools they cannot call.
eurostat_dataframe_describe lists the staged tables with row counts and column names and types — call it before writing SQLeurostat_dataframe_query runs a single read-only SELECT. Statement chaining, non-SELECT verbs, and functions that read files or external data are rejected with a typed erroreurostat_dataframe_describe rather than assuming. eurostat_query_dataset gives each dimension a code column named after the dimension (geo) plus a label companion (geo_label); eurostat_download_dataset gives code columns only, since the bulk endpoint carries no labels, plus a time column. Both write the same five measure columns: obs_value, obs_flag, obs_flag_label, conf_status, conf_status_labeltime — same names, same VARCHAR type, obs_value DOUBLE on both — and their measure columns carry the same codes for the same observation. JSON-stat has no CONF_STATUS field and folds the marker into the observation status as |C; eurostat_query_dataset splits it back out before staging, so a confidential cell reads obs_flag = NULL with conf_status = 'C' on either tableCANVAS_PROVIDER_TYPE=duckdb is the only switch. The exception is the one-click .mcpb bundle, which strips platform-specific native bindings to stay portable — a bundle install cannot run the canvas, so reach for the npm, Docker, or from-source install for SQL analytics| Type | Name | Description |
|---|---|---|
| Resource | eurostat://dataset/{dataset_code} | Dataset metadata (dimensions, time range, obs count, last-updated) accessible by URI for cache-injectable context |
Built on @cyanheads/mcp-ts-core:
none, jwt, oauth)in-memory, filesystem, Supabase, Cloudflare KV/R2/D1Eurostat-specific:
OBS_FLAG observation flag (provisional, estimated, definition differs) and the CONF_STATUS confidentiality marker, each in its own field. JSON-stat folds the two into one string and SDMX TSV into one cell; both are split on their separator, so a given observation reads the same whichever endpoint served itAgent-friendly output:
eurostat_search_datasets / eurostat_browse_themes → eurostat_get_dataset_info → eurostat_get_dimension_values → eurostat_query_dataset for a slice, or eurostat_download_dataset for the whole dataseteurostat_get_dimension_values tool prevents this by letting agents verify codes firstA public instance is available at https://eurostat.caseyjhand.com/mcp — no installation required. Point any MCP client at it via Streamable HTTP:
{
"mcpServers": {
"eurostat-mcp-server": {
"type": "streamable-http",
"url": "https://eurostat.caseyjhand.com/mcp"
}
}
}
Add the following to your MCP client configuration file.
{
"mcpServers": {
"eurostat-mcp-server": {
"type": "stdio",
"command": "bunx",
"args": ["@cyanheads/eurostat-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with npx (no Bun required):
{
"mcpServers": {
"eurostat-mcp-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@cyanheads/eurostat-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info"
}
}
}
}
Or with Docker:
{
"mcpServers": {
"eurostat-mcp-server": {
"type": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/eurostat-mcp-server:latest"]
}
}
}
For Streamable HTTP, set the transport and start the server:
MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
# Server listens at http://localhost:3010/mcp
git clone https://github.com/cyanheads/eurostat-mcp-server.git
cd eurostat-mcp-server
bun install
All configuration is validated at startup via Zod schemas in src/config/server-config.ts. Key environment variables:
| Variable | Description | Default |
|---|---|---|
MCP_TRANSPORT_TYPE | Transport: stdio or http | stdio |
MCP_HTTP_PORT | HTTP server port | 3010 |
MCP_HTTP_ENDPOINT_PATH | HTTP endpoint path | /mcp |
MCP_PUBLIC_URL | Public origin override for TLS-terminating reverse-proxy deployments | none |
MCP_AUTH_MODE | Authentication: none, jwt, or oauth | none |
MCP_LOG_LEVEL | Log level (debug, info, warning, error, etc.) | info |
MCP_GC_PRESSURE_INTERVAL_MS | Opt-in Bun-only forced-GC pressure loop (ms). Recommended starting point if heap growth is observed: 60000. | 0 (disabled) |
LOGS_DIR | Directory for log files (Node.js only) | <project-root>/logs |
STORAGE_PROVIDER_TYPE | Storage backend: in-memory, filesystem, supabase, cloudflare-kv/r2/d1 | in-memory |
EUROSTAT_BASE_URL | Eurostat API base URL | https://ec.europa.eu/eurostat/api/dissemination |
EUROSTAT_REQUEST_TIMEOUT_MS | HTTP request timeout in ms | 30000 |
EUROSTAT_TOC_CACHE_TTL_MS | Catalogue TOC cache lifetime in ms — the first search or browse call past this age refreshes it | 43200000 (12 hours) |
EUROSTAT_BULK_TIMEOUT_MS | HTTP timeout for one eurostat_download_dataset transfer in ms — held separate because a bulk body streams for minutes | 120000 (2 minutes) |
EUROSTAT_BULK_MAX_BYTES | Byte budget for one bulk download, counted on the decoded TSV and enforced while streaming | 52428800 (50 MiB) |
CANVAS_PROVIDER_TYPE | duckdb enables the dataframe canvas: lists the two dataframe tools, lets eurostat_query_dataset stage a match above 5,000 observations, and lets eurostat_download_dataset retain a bulk download | none |
CANVAS_TEMP_PATH | Directory DuckDB writes canvas spill files to. Must be writable by the server process | <os tmpdir>/mcp-canvas |
CANVAS_TTL_MS | Sliding lifetime of a staged canvas in ms; every call against it extends the window | 86400000 (24 hours) |
CANVAS_DEFAULT_ROW_LIMIT | Max rows one eurostat_dataframe_query returns before reporting truncated | 10000 |
OTEL_ENABLED | Enable OpenTelemetry | false |
Build and run the production version:
# One-time build
bun run rebuild
# Run the built server
bun run start:http
# or
bun run start:stdio
Run checks and tests:
bun run devcheck # Lints, formats, type-checks, and more
bun run test # Runs the test suite
| Directory | Purpose |
|---|---|
src/mcp-server/tools | Tool definitions (*.tool.ts). Six tools for discovery and data access, plus two canvas-gated dataframe tools. |
src/mcp-server/resources | Resource definitions. Dataset metadata resource. |
src/services/eurostat-catalogue | Catalogue service — fetches and parses the Eurostat TOC TXT file; TTL-bounded in-memory cache. |
src/services/eurostat-data | Data service — dataset-scoped SDMX metadata parser plus Statistics API querying, JSON-stat 2.0 decoding, async-response detection, and dataframe row source. |
src/services/canvas-accessor.ts | Module-level accessor for the optional DataCanvas, plus the acquire helper that names the misconfigured path on a permission failure. |
src/config | Server-specific environment variable parsing and validation with Zod. |
tests/ | Unit and integration tests, mirroring the src/ structure. |
See CLAUDE.md for development guidelines and architectural rules. The short version:
try/catch in tool logicctx.log for logging, ctx.state for storagecreateApp() arraysIssues and pull requests are welcome. Run checks and tests before submitting:
bun run devcheck
bun run test
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.
MCP_LOG_LEVELdefault: infoSets the minimum log level for output (e.g., 'debug', 'info', 'warn').
EUROSTAT_BASE_URLdefault: https://ec.europa.eu/eurostat/api/disseminationEurostat API base URL. Override when using a mirror or proxy.
EUROSTAT_REQUEST_TIMEOUT_MSdefault: 30000HTTP request timeout in milliseconds for Eurostat API calls.
MCP_HTTP_HOSTdefault: 127.0.0.1The hostname for the HTTP server.
MCP_HTTP_PORTdefault: 3010The port to run the HTTP server on.
MCP_HTTP_ENDPOINT_PATHdefault: /mcpThe endpoint path for the MCP server.
MCP_PUBLIC_URLPublic origin override for deployments behind a TLS-terminating reverse proxy (e.g. https://mcp.example.com).
MCP_AUTH_MODEdefault: noneAuthentication mode to use: 'none', 'jwt', or 'oauth'.