istat-mcp-suite
# ISTAT MCP Suite
Server [MCP](https://modelcontextprotocol.io) che espone i dati statistici ufficiali
dell'**ISTAT** a un agente LLM, attraverso le API SDMX REST di `esploradati.istat.it`.
L'obiettivo è pratico: le API SDMX dell'ISTAT sono complete ma poco indulgenti — id
criptici, chiavi posizionali, codeliste da 12.000 voci, endpoint che a volte non
rispondono affatto. Un agente lasciato a interrogarle direttamente spreca token,
va in timeout e si fa bloccare l'IP. Questo server si occupa di quella parte.
---
## Indice
- [Cosa fa](#cosa-fa)
- [Installazione](#installazione)
- [Collegare un client MCP](#collegare-un-client-mcp)
- [I tool disponibili](#i-tool-disponibili)
- [Come lo usa un agente](#come-lo-usa-un-agente)
- [Limiti dell'API ISTAT](#limiti-dellapi-istat)
- [Blocco dell'indirizzo IP](#blocco-dellindirizzo-ip)
- [Configurazione](#configurazione)
- [Sviluppo](#sviluppo)
---
## Cosa fa
| | |
|---|---|
| **Ricerca dataset** | Cerca fra i ~4.700 dataflow ISTAT per parole chiave, senza doverne conoscere l'id. |
| **Metadati a costo graduato** | Struttura, dimensioni e codici di un dataflow, chiedendo solo quello che serve invece di scaricare tutto. |
| **Estrazione dati** | Risposta inline per query piccole, download su file per quelle grandi, con diagnosi leggibile quando la chiave è sbagliata. |
| **Risoluzione territoriale offline** | 8.039 codici `REF_AREA` (comuni, province, regioni, ripartizioni) risolti da un file locale, senza toccare la rete. |
| **Cache a due livelli** | In memoria (L1) + su disco (L2). Le codeliste sono condivise fra dataflow e valide 90 giorni. |
| **Rate limiting adattivo** | Due finestre scorrevoli separate per endpoint leggeri e pesanti, che si restringono da sole quando ISTAT protesta. |
---
## Installazione
Serve **Python ≥ 3.11**. Con [`uv`](https://docs.astral.sh/uv/):
```bash
git clone https://github.com/ManoloZocco/istat-mcp-suite.git
cd istat-mcp-suite
uv sync
```
Verifica che parta:
```bash
uv run python -m istat_mcp.cli --transport stdio
```
Il processo resta in attesa sullo stdin: è il comportamento corretto per un server
MCP in modalità `stdio`. Interrompi con `Ctrl+C`.
### Modalità di trasporto
```bash
# STDIO — per Claude Desktop, Claude Code, Cursor e client locali in genere
uv run python -m istat_mcp.cli --transport stdio
# SSE / HTTP — per deploy remoti o client che parlano HTTP
uv run python -m istat_mcp.cli --transport sse --host 0.0.0.0 --port 8000
```
---
## Collegare un client MCP
Esempio per Claude Desktop (`claude_desktop_config.json`) o qualunque client che
usi lo stesso formato:
```json
{
"mcpServers": {
"istat": {
"command": "uv",
"args": [
"--directory", "/percorso/assoluto/a/istat-mcp-suite",
"run", "python", "-m", "istat_mcp.cli", "--transport", "stdio"
]
}
}
}
```
Con Claude Code:
```bash
claude mcp add istat -- uv --directory /percorso/assoluto/a/istat-mcp-suite \
run python -m istat_mcp.cli --transport stdio
```
---
## I tool disponibili
### `discover_dataflows(query, limit)`
Cerca per parole chiave nel catalogo dei dataflow. Restituisce id, nome italiano e
id della datastructure. È il punto di partenza quando non si conosce già l'id.
Il catalogo completo viene scaricato una volta (~2 MB, ~35 s) e messo in cache per
30 giorni; le ricerche successive sono locali.
### `get_constraints(dataflow_id, dimensions_only, dimension, max_codes_per_dimension, wait_seconds)`
Metadati di un dataflow: dimensioni con la loro posizione SDMX, la codelist che sta
dietro a ciascuna, e i codici validi con le etichette italiane.
Ha tre modalità, in ordine di costo crescente:
| Chiamata | Cosa ottieni | Costo |
|---|---|---|
| `dimensions_only=True` | Ordine e posizione delle dimensioni, template della chiave | 1 richiesta, pochi secondi su qualunque dataflow |
| `dimension='NOME_DIM'` | I codici di una sola dimensione | 1–2 richieste |
| default | Tutte le dimensioni con i codici | Può richiedere minuti, o restituire un risultato parziale |
Il troncamento è sempre **dichiarato** nella risposta (`truncated`, `total_codes`,
`truncation_note`) e tiene per primi i codici più aggregati: le sezioni NACE e i
totali prima delle sottoclassi, le regioni prima dei comuni.
Se l'endpoint `availableconstraint` di ISTAT non risponde entro `wait_seconds`, la
chiamata **non si blocca**: torna con `status: "downloading"` o `"partial"`, la
struttura resta valida, e il download prosegue in background finendo in cache per
la chiamata successiva.
### `get_data(dataflow_id, key_filter, start_period, end_period, format, last_n_observations, max_inline_rows, force_file_download)`
Estrae i dati veri e propri.
`key_filter` è una **chiave SDMX puntata**, non un dizionario: un segmento per
dimensione, nell'ordine di posizione dato da `get_constraints`.
```
A.053011.W_SUPPL+W_TOTLOS # frequenza annuale, comune di Grosseto, due misure
..W_SUPPL+W_TOTLOS # le stesse due misure, tutti i comuni, ogni frequenza
```
- Segmento vuoto = tutti i valori di quella dimensione.
- Più valori sullo stesso segmento si separano con `+`.
- `TIME_PERIOD` **non** fa parte della chiave: si filtra con `start_period` / `end_period`.
Sotto le 500 righe la risposta è inline; oltre — o sopra 1 MB, o con
`force_file_download=True` — il dataset viene scritto su disco in
`~/.cache/istat_mcp/downloads` e la risposta contiene percorso, dimensione e
anteprima.
Quando ISTAT rifiuta la chiave (422/400/404), la risposta include l'arità attesa,
il template corretto, il messaggio SDMX originale e un suggerimento su cosa
correggere, invece del solo errore HTTP.
### `get_territorial_codes(search, level, limit)`
Risoluzione **offline** su 8.039 entità `REF_AREA`: 1 nazione, 5 ripartizioni,
22 regioni e province autonome, 107 province, 7.904 comuni. Ricerca
case-insensitive su nome, codice `REF_AREA` e codice ISTAT.
```
search='Grosseto', level='comune' -> 053011
search='Grosseto', level='provincia' -> 053
```
Da preferire sempre a `get_constraints` per la dimensione territoriale: è istantaneo,
non consuma slot del rate limiter e non rischia il blocco IP. Il dataset si rigenera
con `python scripts/build_territorial_codes.py`.
### `get_concepts(concept_id)`
Definizione semantica di un concetto SDMX (`REF_AREA`, `FREQ`, …).
### `get_structure_diagram(dataflow_id)`
Diagramma Mermaid della struttura dimensionale di un dataflow. Usa internamente
`dimensions_only`, quindi è veloce e immune allo stallo di `availableconstraint`.
### `get_cache_diagnostics()`
Stato della cache, occupazione su disco, elenco dei file scaricati.
---
## Come lo usa un agente
Il percorso che funziona, dal nome di un fenomeno alla tabella di dati:
```mermaid
flowchart TD
A["Serve un dato ISTAT"] --> B["discover_dataflows('verde urbano')"]
B --> C["get_constraints(id, dimensions_only=True)"]
C --> D{"Serve un territorio?"}
D -->|sì| E["get_territorial_codes('Grosseto', level='comune')"]
D -->|no| F["get_constraints(id, dimension='DATA_TYPE')"]
E --> F
F --> G["get_data(id, key_filter='A.053011.W_SUPPL')"]
G --> H{"Risposta grande?"}
H -->|inline| I["Dati nel contesto"]
H -->|file| J["Percorso su disco, da leggere con gli strumenti locali"]
```
Le regole che fanno la differenza fra una sessione che funziona e una che brucia
token e finestre di rate limit:
1. **Partire sempre da `dimensions_only=True`.** Dà l'ordine delle dimensioni e il
template della chiave — cioè tutto quello che serve per costruire `key_filter` —
in una sola richiesta. Enumerare i codici di tutte le dimensioni prima di sapere
quali servono è lo spreco più comune.
2. **Per i territori usare `get_territorial_codes`, mai `get_constraints`.**
`CL_ITTER107` ha 12.471 codici: chiederli a ISTAT quando la risposta è già su
disco è tempo e rischio buttati.
3. **Chiedere una dimensione alla volta** con `dimension='<ID>'` invece della
risoluzione completa.
4. **Sui dataflow con serie storiche lunghe passare `last_n_observations`.** Senza
vincolo temporale ISTAT scandisce l'intera serie e la richiesta va in timeout;
con `last_n_observations=1` la stessa estrazione risponde in pochi secondi.
5. **Usare `force_file_download` per i dataset grandi**, invece di far passare
megabyte di TSV dalla finestra di contesto.
6. **Leggere `status` nella risposta.** `"downloading"` e `"partial"` non sono
errori: sono risposte valide su cui si può già lavorare, e la chiamata successiva
troverà il resto in cache.
---
## Limiti dell'API ISTAT
Comportamenti verificati contro `esploradati.istat.it`. Non sono bug di questo
server: sono il terreno su cui lavora, ed è utile conoscerli anche solo per
interpretare le risposte.
**`availableconstraint` è l'endpoint che si pianta.** Su
`183_1163_DF_DICA_ASIAULP_TERRIFDATA_7` non ha risposto dopo 600 s, mentre sullo
stesso dataflow la struttura arriva in ~4 s e l'intera DSD con tutte le codeliste
(11 MB) in ~9 s. Su altri dataflow lo stesso endpoint risponde in 34 s, o fra 4 e
46 s a seconda del momento. Per questo viene scaricato in background e la risposta
torna comunque.
**Le codeliste sono enormi ma veloci.** `CL_ITTER107` sono 12.471 codici (9,1 MB
in ~4 s), `CL_ATECO_2007` 2.063. Il problema che creano non è il tempo ma il limite
di token della risposta: di qui il troncamento dichiarato.
**Un id di dataflow non è un id di datastructure.** `609_1_DF_DCCV_URBANENV_1` è
servito dalla DSD `DCCV_URBANENV`, `22_389` da `DCIS_POPRES1`. Interrogare
`datastructure/IT1/<dataflow_id>` restituisce sempre 404.
**ISTAT serve SDMX-CSV anche quando si chiede TSV.** La risposta riporta il formato
reale in `format` e quello richiesto in `requested_format`; il file salvato prende
l'estensione reale.
**Sugli endpoint di struttura serve un `Accept` XML esplicito**, altrimenti ISTAT
restituisce SDMX-JSON che i parser XML non digeriscono.
**Un codice presente nella codelist può non essere pubblicato dal dataflow.**
`CL_TIPO_DATO_CIS` ha 260 tipi di dato, ma un dato dataflow può esporne due. In quel
caso ISTAT risponde `404 NoRecordsFound`, che significa "nessuna osservazione", non
"codice inesistente".
**Il catalogo dei dataflow va chiesto con `detail=allstubs`**: ~2 MB / ~35 s invece
di ~13 MB / ~70 s.
---
## Blocco dell'indirizzo IP
⚠️ **Il limite più importante da conoscere.**
ISTAT non pubblica alcun limite di frequenza: le risposte non contengono header
`X-RateLimit-*` né `Retry-After`, e raffiche brevi passano senza throttling. Non
esiste quindi una soglia documentata a cui attenersi.
Esiste però un **blocco a livello di indirizzo IP**: dopo un uso intenso e
prolungato — molte estrazioni dati pesanti, sessioni lunghe, download ripetuti —
`esploradati.istat.it` smette di rispondere da quell'IP. Non arriva un 429 con una
spiegazione: le richieste iniziano semplicemente a fallire o a non tornare più,
anche dopo aver rallentato.
**Come si riconosce:** tutte le richieste falliscono o vanno in timeout, comprese
quelle leggere che prima rispondevano in pochi secondi, e il sito ISTAT è
irraggiungibile anche dal browser sulla stessa rete.
**Come si risolve:** cambiando indirizzo IP pubblico.
- Su una linea domestica con IP dinamico: **riavviare il modem/router** e attendere
che il provider ne assegni uno nuovo (a volte servono alcuni minuti di modem
spento perché il vecchio lease scada).
- In alternativa: passare a un'altra rete (hotspot del telefono), o usare una VPN
che esca da un IP diverso.
- Se l'IP è statico, non c'è modo di sbloccarlo se non aspettare.
**Come si evita.** Il server è costruito per ridurre il rischio, ma non può
azzerarlo:
- La cache su disco (30 giorni per la struttura, 90 per le codeliste) fa sì che il
lavoro ripetuto non arrivi mai a ISTAT.
- Il rate limiter separa gli endpoint per costo: struttura a 20/min, dati e
`availableconstraint` a 3/min, ed entrambe le finestre si dimezzano su un 429 o
503 e risalgono di uno slot ogni due minuti.
- La risoluzione territoriale è completamente offline.
Il resto dipende dall'uso: preferire `dimensions_only` e `dimension=`, non
riscaricare dataset già presenti in `~/.cache/istat_mcp/downloads`, ed evitare di
lanciare estrazioni sull'intero catalogo. La cache va tenuta, non svuotata:
cancellarla significa rifare da capo tutte le richieste che aveva assorbito.
---
## Configurazione
Tutte le impostazioni si sovrascrivono con variabili d'ambiente o con un file
`.env` nella directory di lavoro.
| Variabile | Default | Note |
|---|---|---|
| `ISTAT_SDMX_BASE_URL` | `https://esploradati.istat.it/SDMXWS/rest` | Endpoint SDMX |
| `REQUEST_TIMEOUT_SECONDS` | `180` | Timeout richieste normali |
| `DOWNLOAD_TIMEOUT_SECONDS` | `900` | Timeout lettura per i download in streaming |
| `RATE_LIMIT_REQUESTS` | `3` | Slot/minuto per `data` e `availableconstraint` |
| `METADATA_RATE_LIMIT_REQUESTS` | `20` | Slot/minuto per gli endpoint di struttura |
| `RATE_LIMIT_WINDOW_SECONDS` | `60` | Ampiezza della finestra scorrevole |
| `RATE_LIMIT_RECOVERY_SECONDS` | `120` | Intervallo di recupero di uno slot dopo un 429/503 |
| `CACHE_DIR` | `~/.cache/istat_mcp` | Cache L2 su disco |
| `MCP_STORAGE_DIR` | `~/.cache/istat_mcp/downloads` | Dataset scaricati |
| `MAX_INLINE_ROWS` | `500` | Soglia oltre la quale si passa al file |
| `METADATA_TTL_SECONDS` | `2592000` (30 g) | TTL metadati |
| `DATA_TTL_SECONDS` | `86400` (24 h) | TTL dati |
`MCP_STORAGE_DIR` sta sotto `$HOME` e non nella directory del progetto di
proposito: un percorso relativo al repo non è raggiungibile da una shell in
sandbox, e i file risulterebbero elencati dal tool ma non leggibili.
---
## Sviluppo
```bash
uv run pytest # suite completa
uv run pytest -q # output compatto
```
Struttura del sorgente:
```
src/istat_mcp/
├── cli.py # entry point, scelta del trasporto
├── config.py # impostazioni (pydantic-settings)
├── server.py # registrazione dei tool su FastMCP
├── core/
│ ├── cache.py # cache L1 in memoria + L2 su disco
│ ├── client.py # client SDMX, rate limiting, retry, streaming
│ └── storage.py # scrittura dei dataset su disco
├── data/
│ ├── territorial_codes.json
│ └── territory.py # risolutore territoriale offline
└── tools/
├── data.py # get_data, diagnostica cache
├── discovery.py # discover_dataflows
└── metadata.py # get_constraints, concetti, diagrammi, territori
```
Due vincoli da rispettare toccando il codice:
- **Le funzioni dei tool non accettano il client SDMX come parametro.** FastMCP
costruisce il modello degli argomenti con pydantic a partire dalle annotazioni:
una classe arbitraria come `IstatSDMXClient` nella firma fa fallire ogni chiamata
con `PydanticSchemaGenerationError`, prima ancora della richiesta HTTP. Tutti i
tool usano il singleton `istat_mcp.core.client.global_client`.
- **I tool restituiscono oggetti JSON strutturati**, non stringhe JSON annidate,
così il client fa un solo parse.
---
## Licenza
MIT.
I dati restano dell'**Istituto Nazionale di Statistica** e sono soggetti alle
condizioni d'uso pubblicate su [istat.it](https://www.istat.it). Questo è un
progetto indipendente, non affiliato con l'ISTAT.
TDQS
Scored across 7 tools
Each tool has a distinct role: dataflow discovery, dataset constraints, concept definitions, structure diagrams, territorial code lookup, data retrieval, and cache diagnostics. Even the metadata-related tools (constraints, concepts, diagram, codes) are clearly separated by their specific inputs and outputs, so an agent should rarely confuse them.
Most tools follow a consistent get_<noun> pattern (get_constraints, get_concepts, get_data, etc.), with one notable deviation: discover_dataflows. This is still acceptable since 'discover' conveys a search action, but it breaks the uniform get_ prefix. Overall the naming is predictable and readable.
7 tools is well within the ideal range for a domain-specific server. Each tool serves a clear purpose in the workflow (discover → constrain → fetch → diagnose), and none are redundant or unnecessary. The count feels intentionally scoped.
The core workflow of discovering datasets, retrieving their constraints, and fetching data is fully covered. Supporting tools for semantic concepts, territorial codes, structure visualization, and cache diagnostics fill out the domain nicely. Minor gaps include an inability to list all dataflows without a keyword and no explicit way to get raw dataflow metadata beyond constraints, but these are workarounds rather than dead ends.