{
  "markdown": "<div align=\"center\">\n  <h1>@cyanheads/eurostat-mcp-server</h1>\n  <p><b>Search and query the Eurostat catalogue — EU economy, demography, trade, health, and NUTS regional data via MCP. STDIO or Streamable HTTP.</b>\n  <div>6 Tools (8 with the dataframe canvas) • 1 Resource</div>\n  </p>\n</div>\n\n<div align=\"center\">\n\n\n\n[![Version](https://img.shields.io/badge/Version-0.6.3-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/eurostat-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/eurostat-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/eurostat-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)\n\n</div>\n\n<div align=\"center\">\n\n[![Install in Claude Desktop](https://img.shields.io/badge/Install_in-Claude_Desktop-D97757?style=for-the-badge&logo=anthropic&logoColor=white)](https://github.com/cyanheads/eurostat-mcp-server/releases/latest/download/eurostat-mcp-server.mcpb) [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=eurostat-mcp-server&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBjeWFuaGVhZHMvZXVyb3N0YXQtbWNwLXNlcnZlciJdfQ==) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=for-the-badge&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22eurostat-mcp-server%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40cyanheads%2Feurostat-mcp-server%22%5D%7D)\n\n[![Framework](https://img.shields.io/badge/Built%20on-@cyanheads/mcp--ts--core-67E8F9?style=flat-square)](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)\n\n</div>\n\n<div align=\"center\">\n\n**Public Hosted Server:** [https://eurostat.caseyjhand.com/mcp](https://eurostat.caseyjhand.com/mcp)\n\n</div>\n\n---\n\n## Tools\n\n6 tools for discovering and querying Eurostat statistical datasets, plus 2 more when the optional dataframe canvas is enabled:\n\n| Tool | Description |\n|:---|:---|\n| `eurostat_search_datasets` | Search the Eurostat catalogue by keyword — returns codes, descriptions, period coverage, and theme breadcrumbs |\n| `eurostat_browse_themes` | Navigate the Eurostat theme hierarchy — list root themes or drill into subthemes and datasets |\n| `eurostat_get_dataset_info` | Fetch metadata for a dataset: dimensions with sample values, time range, observation count, and last-update date |\n| `eurostat_get_dimension_values` | List all valid codes for a specific dimension (e.g., all geo codes, all unit codes); supports NUTS hierarchy filtering |\n| `eurostat_query_dataset` | Fetch a deterministic preview of decoded statistical observations with dimension filters, NUTS geo-level, and time-range controls |\n| `eurostat_download_dataset` | Download a whole dataset through the SDMX 2.1 TSV bulk endpoint and stage every observation on the dataframe canvas |\n| `eurostat_dataframe_describe` | List the tables staged on a dataframe canvas with their row counts and column types — canvas only |\n| `eurostat_dataframe_query` | Run a read-only SQL SELECT across staged tables — canvas only |\n\n### `eurostat_search_datasets`\n\nSearch the Eurostat dataset catalogue by keyword.\n\n- Tokenized keyword match — whitespace-separated tokens are ANDed case-insensitively across each dataset's label, theme breadcrumb, and code, so word order and theme-named queries resolve without a verbatim label\n- Returns code, label, type (dataset/table), period coverage, observation count, and theme breadcrumb\n- One row per dataset code — Eurostat files some datasets under several theme branches; matches are deduplicated so `totalMatches` and page slots count unique query targets\n- Cursor pagination: `limit` (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 page\n- `nextStep` hint on each result points at the next tool to call\n- Catalogue TOC cached in memory for 12 hours (`EUROSTAT_TOC_CACHE_TTL_MS`), then refreshed on the next call\n- Pair with `eurostat_browse_themes` for structured domain exploration when keywords are unclear\n\n---\n\n### `eurostat_browse_themes`\n\nNavigate the Eurostat theme tree.\n\n- Without `theme_code`: returns the top-level themes (Economy and finance, Population, Transport, etc.)\n- With `theme_code`: returns immediate children — subtheme folders and datasets in that branch\n- Each entry includes code, label, type (folder/dataset/table), data period, and observation count where available\n- Returns a breadcrumb path from root to the current node, plus a `nextStep` hint suited to the level (drill into folders or inspect a dataset)\n- One branch per folder code — Eurostat files a few folder codes under several branches; a code resolves to the first one the catalogue lists, which never has fewer children than the branches it shadows, and `otherPlacements` names those so the ambiguity is visible\n- Use for structured discovery when you know the domain but not the exact dataset code\n\n---\n\n### `eurostat_get_dataset_info`\n\nFetch metadata for a Eurostat dataset before querying it.\n\n- Returns all dimensions with their codes, labels, and up to 10 sample values each\n- Reports overall time range and total observation count across all periods, each omitted when Eurostat does not report it\n- Combines the dataset-scoped SDMX dataflow descendants with its content constraint, so every dimension — including `time` — is described from the full dataset-available value set rather than from a populated observation slice\n- For dimensions with more than 10 values, use `eurostat_get_dimension_values` for the full list\n- Provides a link to the ESMS metadata page when available\n\n---\n\n### `eurostat_get_dimension_values`\n\nList all valid values for a specific dataset dimension.\n\n- Retrieves the complete dataset-available set of codes and labels for any dimension (`unit`, `na_item`, `geo`, `time`, etc.) from the same SDMX content constraint used by dataset metadata\n- For the `geo` 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 ignored\n- Prevents silent no-data returns — invalid dimension values in `eurostat_query_dataset` return nothing without error; verify codes here first\n\n---\n\n### `eurostat_query_dataset`\n\nFetch statistical data from a Eurostat dataset.\n\n- Accepts dimension filters as a map of `{dimension_code: [value1, value2, ...]}`\n- NUTS geo-level filter (`aggregate`, `country`, `nuts1`, `nuts2`, `nuts3`) — mutually exclusive with a non-empty `geo` entry in filters; an empty array is treated as no filter and dropped\n- Time range via `since_period`/`until_period` (e.g., `\"2020\"`, `\"2023-Q1\"`) or `last_n_periods` for the N most recent\n- `preview_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 offset\n- Returns decoded observations with dimension codes and labels, numeric values, an `OBS_FLAG` status (`p` = provisional, `e` = estimated, etc.) and a separate `CONF_STATUS` confidentiality marker (`C` = confidential, usually the reason a value is null)\n- Reports total observation count, missing value count, and the effective time range of the result, each period bound omitted when neither the observations nor Eurostat report it\n- `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 threshold\n- With the dataframe canvas enabled, only a match above 5,000 observations is staged whole as a SQL table and the response returns `canvasId` / `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 rest\n- Pass `canvas_id` from an earlier response to stage several results side by side and join across them\n- Async-response detection — large unfiltered queries return an actionable, non-retryable error with filter guidance rather than silently timing out\n- Fetches a *slice*. When the target is a whole dataset, `eurostat_download_dataset` reads the SDMX bulk endpoint instead, at roughly half the bytes\n\n---\n\n### `eurostat_download_dataset`\n\nDownload a whole dataset through the SDMX 2.1 TSV bulk endpoint (`/sdmx/2.1/data/{dataset}?format=TSV`).\n\n- The TSV wire format runs 48–63% of the JSON-stat body `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\n- Filters take the same `{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 key\n- Narrow periods with `since_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 columns\n- **Byte budget enforced while streaming.** Eurostat sends the body chunked with no `Content-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 ceiling\n- **gzip is sniffed off the stream, not read from headers.** Eurostat compresses large bodies with no `Content-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 decide\n- **The asynchronous queue envelope is detected explicitly.** When an extraction is too costly to serve inline Eurostat answers HTTP 200 with a SOAP `syncResponse` 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 narrow\n- Errors arrive as XML SOAP faults, not JSON: faultcode 100 → `not_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 next\n- With the dataframe canvas enabled, every observation is staged as a SQL table and the response returns `canvasId` / `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_query`\n- Without a canvas the download still runs so `rowCount`, `missingCount` and `periodRange` describe it, but only the preview is retained — the response says so plainly instead of implying the rest is reachable\n\n---\n\n### `eurostat_dataframe_describe` / `eurostat_dataframe_query`\n\nSQL 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.\n\n- `eurostat_dataframe_describe` lists the staged tables with row counts and column names and types — call it before writing SQL\n- `eurostat_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 error\n- Staged columns are flat, and the two stagers write different dimension columns — call `eurostat_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_label`\n- Tables from the two stagers join on their dimension code columns and `time` — 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 table\n- The DuckDB binding ships with the server, so `CANVAS_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\n\n## Resource\n\n| Type | Name | Description |\n|:---|:---|:---|\n| Resource | `eurostat://dataset/{dataset_code}` | Dataset metadata (dimensions, time range, obs count, last-updated) accessible by URI for cache-injectable context |\n\n## Features\n\nBuilt on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core):\n\n- Declarative tool definitions — single file per tool, framework handles registration and validation\n- Unified error handling across all tools\n- Pluggable auth (`none`, `jwt`, `oauth`)\n- Swappable storage backends: `in-memory`, `filesystem`, `Supabase`, `Cloudflare KV/R2/D1`\n- Structured logging with optional OpenTelemetry tracing\n- Runs locally (stdio/HTTP) or on Cloudflare Workers from the same codebase\n\nEurostat-specific:\n\n- TTL-bounded in-memory cache for the TOC file — reused across all search and browse calls, refreshed on the first call past its 12-hour lifetime, with the last loaded copy served if a refresh fails\n- JSON-stat 2.0 stride-based decoder for the Statistics API response format\n- Dataset-scoped SDMX metadata parsing — dataflow descendants supply labels, annotations, concepts, and partial codelists; the content constraint supplies the actual available positions, including every period\n- Async-response detection — Eurostat returns over-limit queries as either an HTTP-200 warning object or an HTTP-413 error array; both fail once with actionable filter guidance\n- NUTS hierarchy geo-level filtering across query and dimension-value tools\n- Status decoding against both published codelists — the `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 it\n- Optional DuckDB dataframe canvas — a query matching more than 5,000 observations is streamed row by row into a SQL table, reaching the rows outside the inline preview without a second request to Eurostat\n- SDMX 2.1 TSV bulk downloads with streaming gzip detection, a mid-transfer byte budget, wide-to-long expansion, and SOAP fault classification — the whole-dataset counterpart to the per-query path\n\nAgent-friendly output:\n\n- Discovery workflow: `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 dataset\n- Invalid dimension codes in query filters silently return no data from Eurostat — the `eurostat_get_dimension_values` tool prevents this by letting agents verify codes first\n- Structured error contracts with typed reasons and recovery hints on all tools\n\n## Getting started\n\n### Public Hosted Instance\n\nA public instance is available at `https://eurostat.caseyjhand.com/mcp` — no installation required. Point any MCP client at it via Streamable HTTP:\n\n```json\n{\n  \"mcpServers\": {\n    \"eurostat-mcp-server\": {\n      \"type\": \"streamable-http\",\n      \"url\": \"https://eurostat.caseyjhand.com/mcp\"\n    }\n  }\n}\n```\n\n### Self-Hosted / Local\n\nAdd the following to your MCP client configuration file.\n\n```json\n{\n  \"mcpServers\": {\n    \"eurostat-mcp-server\": {\n      \"type\": \"stdio\",\n      \"command\": \"bunx\",\n      \"args\": [\"@cyanheads/eurostat-mcp-server@latest\"],\n      \"env\": {\n        \"MCP_TRANSPORT_TYPE\": \"stdio\",\n        \"MCP_LOG_LEVEL\": \"info\"\n      }\n    }\n  }\n}\n```\n\nOr with npx (no Bun required):\n\n```json\n{\n  \"mcpServers\": {\n    \"eurostat-mcp-server\": {\n      \"type\": \"stdio\",\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"@cyanheads/eurostat-mcp-server@latest\"],\n      \"env\": {\n        \"MCP_TRANSPORT_TYPE\": \"stdio\",\n        \"MCP_LOG_LEVEL\": \"info\"\n      }\n    }\n  }\n}\n```\n\nOr with Docker:\n\n```json\n{\n  \"mcpServers\": {\n    \"eurostat-mcp-server\": {\n      \"type\": \"stdio\",\n      \"command\": \"docker\",\n      \"args\": [\"run\", \"-i\", \"--rm\", \"-e\", \"MCP_TRANSPORT_TYPE=stdio\", \"ghcr.io/cyanheads/eurostat-mcp-server:latest\"]\n    }\n  }\n}\n```\n\nFor Streamable HTTP, set the transport and start the server:\n\n```sh\nMCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http\n# Server listens at http://localhost:3010/mcp\n```\n\n### Prerequisites\n\n- [Bun v1.3.2](https://bun.sh/) or higher. No API key required — Eurostat's dissemination API is public.\n\n### Installation\n\n1. **Clone the repository:**\n\n```sh\ngit clone https://github.com/cyanheads/eurostat-mcp-server.git\n```\n\n2. **Navigate into the directory:**\n\n```sh\ncd eurostat-mcp-server\n```\n\n3. **Install dependencies:**\n\n```sh\nbun install\n```\n\n## Configuration\n\nAll configuration is validated at startup via Zod schemas in `src/config/server-config.ts`. Key environment variables:\n\n| Variable | Description | Default |\n|:---|:---|:---|\n| `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http` | `stdio` |\n| `MCP_HTTP_PORT` | HTTP server port | `3010` |\n| `MCP_HTTP_ENDPOINT_PATH` | HTTP endpoint path | `/mcp` |\n| `MCP_PUBLIC_URL` | Public origin override for TLS-terminating reverse-proxy deployments | none |\n| `MCP_AUTH_MODE` | Authentication: `none`, `jwt`, or `oauth` | `none` |\n| `MCP_LOG_LEVEL` | Log level (`debug`, `info`, `warning`, `error`, etc.) | `info` |\n| `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) |\n| `LOGS_DIR` | Directory for log files (Node.js only) | `<project-root>/logs` |\n| `STORAGE_PROVIDER_TYPE` | Storage backend: `in-memory`, `filesystem`, `supabase`, `cloudflare-kv/r2/d1` | `in-memory` |\n| `EUROSTAT_BASE_URL` | Eurostat API base URL | `https://ec.europa.eu/eurostat/api/dissemination` |\n| `EUROSTAT_REQUEST_TIMEOUT_MS` | HTTP request timeout in ms | `30000` |\n| `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) |\n| `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) |\n| `EUROSTAT_BULK_MAX_BYTES` | Byte budget for one bulk download, counted on the decoded TSV and enforced while streaming | `52428800` (50 MiB) |\n| `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` |\n| `CANVAS_TEMP_PATH` | Directory DuckDB writes canvas spill files to. Must be writable by the server process | `<os tmpdir>/mcp-canvas` |\n| `CANVAS_TTL_MS` | Sliding lifetime of a staged canvas in ms; every call against it extends the window | `86400000` (24 hours) |\n| `CANVAS_DEFAULT_ROW_LIMIT` | Max rows one `eurostat_dataframe_query` returns before reporting `truncated` | `10000` |\n| `OTEL_ENABLED` | Enable OpenTelemetry | `false` |\n\n## Running the server\n\n### Local development\n\n- **Build and run the production version**:\n\n  ```sh\n  # One-time build\n  bun run rebuild\n\n  # Run the built server\n  bun run start:http\n  # or\n  bun run start:stdio\n  ```\n\n- **Run checks and tests**:\n  ```sh\n  bun run devcheck  # Lints, formats, type-checks, and more\n  bun run test      # Runs the test suite\n  ```\n\n## Project structure\n\n| Directory | Purpose |\n|:---|:---|\n| `src/mcp-server/tools` | Tool definitions (`*.tool.ts`). Six tools for discovery and data access, plus two canvas-gated dataframe tools. |\n| `src/mcp-server/resources` | Resource definitions. Dataset metadata resource. |\n| `src/services/eurostat-catalogue` | Catalogue service — fetches and parses the Eurostat TOC TXT file; TTL-bounded in-memory cache. |\n| `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. |\n| `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. |\n| `src/config` | Server-specific environment variable parsing and validation with Zod. |\n| `tests/` | Unit and integration tests, mirroring the `src/` structure. |\n\n## Development guide\n\nSee [`CLAUDE.md`](./CLAUDE.md) for development guidelines and architectural rules. The short version:\n\n- Handlers throw, framework catches — no `try/catch` in tool logic\n- Use `ctx.log` for logging, `ctx.state` for storage\n- Register new tools and resources in the `createApp()` arrays\n\n## Contributing\n\nIssues and pull requests are welcome. Run checks and tests before submitting:\n\n```sh\nbun run devcheck\nbun run test\n```\n\n## License\n\nThis project is licensed under the Apache 2.0 License. See the [LICENSE](./LICENSE) file for details.\n",
  "bytes": 23412,
  "sha": "41e5d99306e120de889ea8b84461ec78e77948493399b0915a9f878ef61473bb",
  "repo_slug": "cyanheads/eurostat-mcp-server",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_cyanheads_eurostat_mcp_server_62f4dce5/readme"
}