normattiva-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 normattiva-mcp contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.it.md ADDED
@@ -0,0 +1,116 @@
1
+ # normattiva-mcp
2
+
3
+ [English](./README.md) · **Italiano**
4
+
5
+ Server [Model Context Protocol](https://modelcontextprotocol.io) leggero che espone l'API OpenData di [Normattiva](https://www.normattiva.it) — il portale ufficiale della legislazione italiana gestito da IPZS — a client LLM come Claude Desktop, Cursor e Claude Code.
6
+
7
+ Wrappa gli endpoint sincroni di lettura (ricerca, dettaglio atto, atti aggiornati) e i cataloghi delle tipologiche statiche come **tools**, **resources** e **prompts** MCP.
8
+
9
+ ## Funzionalità
10
+
11
+ ### Tools
12
+
13
+ | Tool | Scopo |
14
+ | --- | --- |
15
+ | `search_acts` | Ricerca atti normativi italiani per testo libero, titolo, denominazione atto, anno/numero, intervallo di date, classe provvedimento o data di vigenza. Restituisce i metadati di ciascun atto trovato, incluso `codice_redazionale` e `data_gu` (necessari a `read_article`). |
16
+ | `list_articles` | Scopre i numeri degli articoli di un atto sondando in sequenza `read_article` a partire da `articolo=1`. Per default trova solo gli articoli base; con `include_suffixes: true` sonda anche `bis`/`ter`/`quater`/… per ognuno. Ogni voce porta i flag opzionali `is_preamble` e `is_abrogated` (informativi; nessun filtro lato server). Gli articoli interni a gruppi strutturati (`id_gruppo != 0`) non vengono enumerati automaticamente. |
17
+ | `read_article` | Recupera il testo di un singolo articolo di uno specifico atto a una data di vigenza. Per default ritorna testo semplice; con `format: "html"` restituisce il markup originale (preserva i marcatori di emendamento). In modalità testo i target dei link sono inlinati come `L. 5/2003 [urn:nir:stato:legge:2003-06-05;131]` così le citazioni URN sopravvivono alla conversione. Le risposte di successo includono `found: true`; articoli inesistenti (`sotto_articolo`, `id_gruppo` errato o `articolo` fuori range) ritornano `{ found: false, reason, richiesta }` senza sollevare eccezioni — il sondaggio è exception-free. L'LLM può chiamarlo in parallelo per più articoli dello stesso atto. |
18
+ | `read_act` | Lettura aggregata di un intero atto: enumera internamente gli articoli e ne recupera ognuno, ritornandoli in ordine fino al limite `max_chars` (default 80 000 ≈ 20k token Claude). Onora `include_suffixes` e `id_gruppo` come `list_articles`. Quando il budget è esaurito la risposta include `truncated: true`, `truncated_reason` e `articolo_successivo` per riprendere con una chiamata successiva impostando `articolo_da` a quel valore. |
19
+ | `recent_updates` | Elenca atti normativi modificati in una finestra temporale (max 12 mesi). Utile per monitorare le modifiche legislative. |
20
+
21
+ ### Resources
22
+
23
+ | URI | Contenuto |
24
+ | --- | --- |
25
+ | `normattiva://tipologiche/denominazioni` | Codici di denominazione atto (es. `PLE` → `LEGGE`, `PDL` → `DECRETO-LEGGE`). Usa il value come argomento `denominazione` di `search_acts`. |
26
+ | `normattiva://tipologiche/classi-provvedimento` | Codici classe provvedimento: `1` = atto normativo senza aggiornamenti, `2` = aggiornato, `3` = abrogato. |
27
+ | `normattiva://collezioni-predefinite` | Collezioni preconfezionate di atti con il numero di elementi di ciascuna. |
28
+ | `normattiva://ricerche-predefinite` | Ricerche predefinite con i relativi filtri preimpostati. |
29
+
30
+ ### Prompts
31
+
32
+ | Nome | Argomenti | Scopo |
33
+ | --- | --- | --- |
34
+ | `ricerca-articolo` | `argomento` | Cerca la normativa italiana su un argomento e legge gli articoli più rilevanti. |
35
+ | `monitoraggio-modifiche` | `giorni` (default `7`) | Riepiloga le modifiche normative degli ultimi *N* giorni. |
36
+
37
+ ## Installazione e configurazione del client
38
+
39
+ Una volta pubblicato, eseguilo con `npx`:
40
+
41
+ ```bash
42
+ npx normattiva-mcp
43
+ ```
44
+
45
+ ### Claude Desktop / Claude Code
46
+
47
+ Aggiungi alla configurazione del tuo client MCP (`claude_desktop_config.json` per Claude Desktop, oppure `~/.claude.json` / `.mcp.json` per Claude Code):
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "normattiva": {
53
+ "command": "npx",
54
+ "args": ["-y", "normattiva-mcp"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### Cursor
61
+
62
+ In `.cursor/mcp.json`:
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "normattiva": {
68
+ "command": "npx",
69
+ "args": ["-y", "normattiva-mcp"]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Configurazione
76
+
77
+ Il server dialoga sempre con l'**ambiente di esercizio dell'API OpenData** di Normattiva (`https://api.normattiva.it/t/normattiva.api`). L'endpoint è hard-coded — non c'è alcuna variabile d'ambiente per sovrascriverlo. L'ambiente di test (PRE) non è supportato di proposito perché protetto da mTLS.
78
+
79
+ L'API OpenData è aperta e non richiede autenticazione.
80
+
81
+ ## Build da sorgente
82
+
83
+ Richiede Node.js ≥ 20.
84
+
85
+ ```bash
86
+ git clone <repo>
87
+ cd normattiva-mcp
88
+ npm install
89
+ npm run build
90
+ node dist/index.js # parla MCP via stdio
91
+ ```
92
+
93
+ Smoke test live (chiama l'API pubblica):
94
+
95
+ ```bash
96
+ node tests/smoke.mjs
97
+ ```
98
+
99
+ ## Note e limitazioni
100
+
101
+ - **Nessun endpoint di indice articoli.** Normattiva OpenData non espone un sommario sincrono. `list_articles` è un sondaggio best-effort: chiama `read_article` per `articolo=1,2,3,…` finché smette di trovare risultati. Per default scopre solo gli **articoli base**; `include_suffixes: true` estende il sondaggio a `bis`/`ter`/`quater`/… (round-by-round, fermando ciascuna catena al primo miss) ma non può trovare sotto-articoli il cui base manca, né articoli interni a gruppi strutturati (`id_gruppo != 0`).
102
+ - **`id_gruppo` è opaco.** Alcuni atti — tipicamente codici strutturati (codici, testi unici, leggi articolate) — restituiscono contenuto solo quando `read_article` viene chiamato con un `id_gruppo` non zero. L'API OpenData non offre alcun modo sincrono per scoprire il valore corretto. Strategia pratica: provare prima con `id_gruppo=0`; se i risultati appaiono vuoti o errati, provare interi piccoli (1, 2, 3, …) finché l'articolo non torna.
103
+ - **Un articolo per chiamata upstream.** L'endpoint `dettaglio-atto` di Normattiva serve un articolo alla volta. `read_article` lo espone direttamente (parallelizza dal client quando leggi più articoli); `read_act` nasconde l'orchestrazione e restituisce un payload aggregato unico, limitato da `max_chars` e ripristinabile via `articolo_successivo`.
104
+ - **Stripping dell'HTML.** Il default `format: "text"` collassa il markup HTML di Normattiva ma preserva i marcatori italiani di emendamento `(( ... ))` (inserimenti) e `[ ... ]` (cancellazioni), che hanno valore semantico. I link verso altri atti normativi sono inlinati come `<testo> [urn:nir:…]` così l'URN Akoma Ntoso sopravvive in formato testo.
105
+ - **Versioning degli articoli non scopribile.** Ogni chiamata `read_article` ritorna la versione dell'articolo in vigore alla `data_vigenza` (default `versione=0` dell'API). L'API OpenData non espone quante versioni storiche esistano per un articolo, quindi i testi precedenti possono essere recuperati solo variando `data_vigenza` (o tentando `versione=1, 2, …`).
106
+ - **`is_abrogated` è euristico.** Il flag riconosce il marcatore canonico `((ARTICOLO ABROGATO …))` che gli emendamenti successivi inseriscono in testa a un articolo interamente abrogato. Articoli con singoli commi abrogati ma articolo nel complesso vivo (es. `((COMMA ABROGATO …))` in alcune sottosezioni) **non** vengono segnalati.
107
+ - **Export asincrono e ambiente PRE** (protetto da mTLS) **non** sono wrappati — il server resta leggero e sincrono.
108
+ - **`recent_updates`** tratta `data_fine` come fine giornata inclusiva (UTC). La finestra è limitata dall'API a 12 mesi e 7000 atti; entrambi i limiti emergono come codici di errore IPZS (`1501`, `1502`).
109
+
110
+ ## Licenza
111
+
112
+ Il codice sorgente di questo server è MIT — vedi [LICENSE](./LICENSE).
113
+
114
+ I contenuti normativi recuperati tramite questo server sono pubblicati da IPZS con licenza [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Il server è un pass-through e non rilicenzia il contenuto: qualunque uso o redistribuzione a valle deve attribuire Normattiva / IPZS come fonte.
115
+
116
+ Normattiva è un marchio dell'Istituto Poligrafico e Zecca dello Stato Italiano (IPZS); questo progetto non è affiliato né sponsorizzato da IPZS.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # normattiva-mcp
2
+
3
+ **English** · [Italiano](./README.it.md)
4
+
5
+ A lightweight [Model Context Protocol](https://modelcontextprotocol.io) server that exposes the [Normattiva](https://www.normattiva.it) OpenData API — the official Italian legislation portal run by IPZS — to LLM clients like Claude Desktop, Cursor, and Claude Code.
6
+
7
+ It wraps the synchronous read endpoints (search, article detail, recent updates) and the static reference catalogs as MCP **tools**, **resources**, and **prompts**.
8
+
9
+ ## Capabilities
10
+
11
+ ### Tools
12
+
13
+ | Tool | Purpose |
14
+ | --- | --- |
15
+ | `search_acts` | Search Italian legislation by free text, title, act type, year/number, date range, vigenza class, or in-force date. Returns metadata for each matching atto including `codice_redazionale` and `data_gu` (which `read_article` needs). |
16
+ | `list_articles` | Discover the article numbers of an atto by sequentially probing `read_article` from `articolo=1`. Finds base articles by default; pass `include_suffixes: true` to also probe `bis`/`ter`/`quater`/… for each one. Each entry carries optional `is_preamble` and `is_abrogated` flags (informational; nothing is filtered server-side). Articles inside structured groups (`id_gruppo != 0`) are still not enumerated automatically. |
17
+ | `read_article` | Fetch the text of a single article of a specific atto at a given date of vigenza. Returns plain text by default; pass `format: "html"` for the original markup (preserves amendment markers). In text mode, hyperlink targets are inlined as `L. 5/2003 [urn:nir:stato:legge:2003-06-05;131]` so URN citations survive the conversion. Successful responses include `found: true`; non-existent articles (wrong `sotto_articolo`, `id_gruppo`, or out-of-range `articolo`) return `{ found: false, reason, richiesta }` instead of throwing — so probing is exception-free. The LLM can call this in parallel for several articles of the same atto. |
18
+ | `read_act` | Aggregate-read of an entire atto: internally enumerates articles and fetches each one, returning them in order capped by `max_chars` (default 80 000 ≈ 20k Claude tokens). Honors `include_suffixes` and `id_gruppo` like `list_articles`. When the budget is exhausted the response includes `truncated: true`, `truncated_reason`, and `articolo_successivo` for resuming via a follow-up call with `articolo_da` set to that value. |
19
+ | `recent_updates` | Lists atti normativi modified within a date window (max 12 months). Useful for monitoring legislative changes. |
20
+
21
+ ### Resources
22
+
23
+ | URI | Contents |
24
+ | --- | --- |
25
+ | `normattiva://tipologiche/denominazioni` | Act-type codes (e.g. `PLE` → `LEGGE`, `PDL` → `DECRETO-LEGGE`). Use the value as the `denominazione` argument of `search_acts`. |
26
+ | `normattiva://tipologiche/classi-provvedimento` | Vigenza class codes: `1` = senza aggiornamenti, `2` = aggiornato, `3` = abrogato. |
27
+ | `normattiva://collezioni-predefinite` | Predefined collections of acts with their item counts. |
28
+ | `normattiva://ricerche-predefinite` | Predefined searches with their preset filters. |
29
+
30
+ ### Prompts
31
+
32
+ | Name | Args | Purpose |
33
+ | --- | --- | --- |
34
+ | `ricerca-articolo` | `argomento` | Search Italian law on a topic and read the most relevant articles. |
35
+ | `monitoraggio-modifiche` | `giorni` (default `7`) | Summarize legislative changes in the last *N* days. |
36
+
37
+ ## Installation & client configuration
38
+
39
+ Once published, run via `npx`:
40
+
41
+ ```bash
42
+ npx normattiva-mcp
43
+ ```
44
+
45
+ ### Claude Desktop / Claude Code
46
+
47
+ Add to your MCP client config (`claude_desktop_config.json` for Claude Desktop, or `~/.claude.json` / `.mcp.json` for Claude Code):
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "normattiva": {
53
+ "command": "npx",
54
+ "args": ["-y", "normattiva-mcp"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### Cursor
61
+
62
+ In `.cursor/mcp.json`:
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "normattiva": {
68
+ "command": "npx",
69
+ "args": ["-y", "normattiva-mcp"]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ## Configuration
76
+
77
+ The server always talks to Normattiva's **production OpenData API** (`https://api.normattiva.it/t/normattiva.api`). The endpoint is hard-coded — there is no environment variable to override it. The PRE/test environment is intentionally not supported because it is mTLS-protected.
78
+
79
+ The OpenData API is open and does not require authentication.
80
+
81
+ ## Building from source
82
+
83
+ Requires Node.js ≥ 20.
84
+
85
+ ```bash
86
+ git clone <repo>
87
+ cd normattiva-mcp
88
+ npm install
89
+ npm run build
90
+ node dist/index.js # speaks MCP on stdio
91
+ ```
92
+
93
+ A live smoke test (hits the public API):
94
+
95
+ ```bash
96
+ node tests/smoke.mjs
97
+ ```
98
+
99
+ ## Notes & limitations
100
+
101
+ - **No table-of-contents endpoint.** Normattiva OpenData has no synchronous TOC. `list_articles` is a best-effort probe: it calls `read_article` for `articolo=1,2,3,…` until it stops finding results. By default it discovers **base articles** only; `include_suffixes: true` extends the probe to `bis`/`ter`/`quater`/… (round-by-round, stopping each chain at the first miss) but cannot find sub-articles whose base is missing, nor articles inside structured groups (`id_gruppo != 0`).
102
+ - **`id_gruppo` is opaque.** Some atti — typically structured codes (codici, testi unici, leggi articolate) — return content only when `read_article` is called with a non-zero `id_gruppo`. The OpenData API offers no way to discover the right value synchronously. Practical strategy: try `id_gruppo=0` first; if results look empty or wrong, try small integers (1, 2, 3, …) until the article comes back.
103
+ - **One article per call upstream.** Normattiva's `dettaglio-atto` endpoint serves one article at a time. `read_article` exposes that directly (parallelize from the client when reading several articles); `read_act` hides the orchestration and returns a single aggregated payload, capped by `max_chars` and resumable via `articolo_successivo`.
104
+ - **HTML stripping.** The default `format: "text"` collapses Normattiva's HTML markup but preserves the Italian-law amendment markers `(( ... ))` (insertions) and `[ ... ]` (deletions), which carry semantic meaning. Hyperlinks to other normative acts are inlined as `<text> [urn:nir:…]` so the Akoma Ntoso URN survives in plain text.
105
+ - **Article versioning is non-discoverable.** Each `read_article` call returns the version of the article in force on `data_vigenza` (the API's `versione=0` default). The OpenData API does not expose how many historical versions exist for an article, so previous texts can only be retrieved by varying `data_vigenza` (or by guessing `versione=1, 2, …`).
106
+ - **`is_abrogated` is heuristic.** The flag matches the canonical `((ARTICOLO ABROGATO …))` marker that successive amendments insert at the head of a wholly-abrogated article. Articles where individual commi are abrogated but the article itself is alive (e.g. `((COMMA ABROGATO …))` in a few subsections) are **not** flagged.
107
+ - **Async export and PRE environment** (mTLS-protected) are **not** wrapped — this server stays light and synchronous.
108
+ - **`recent_updates`** treats `data_fine` as inclusive end-of-day (UTC). The window is capped at 12 months and 7000 atti by the API; both limits surface as IPZS error codes (`1501`, `1502`).
109
+
110
+ ## License
111
+
112
+ This server's source code is MIT — see [LICENSE](./LICENSE).
113
+
114
+ Legislative content retrieved through this server is published by IPZS under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The server is a pass-through and does not relicense the content: any downstream use or redistribution must attribute Normattiva / IPZS as the source.
115
+
116
+ Normattiva is a trademark of the Istituto Poligrafico e Zecca dello Stato Italiano (IPZS); this project is not affiliated with or endorsed by IPZS.
package/dist/api.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { RicercaAvanzataBody, RicercaResponse, DettaglioAttoBody, DettaglioAttoResponse, AggiornatiBody, DenominazioneItem, ClasseItem, CollezioneItem, RicerchePredefiniteResponse } from "./types.js";
2
+ export declare const BASE_URL = "https://api.normattiva.it/t/normattiva.api";
3
+ export declare function ricercaAvanzata(body: RicercaAvanzataBody): Promise<RicercaResponse>;
4
+ export declare function dettaglioAtto(body: DettaglioAttoBody): Promise<DettaglioAttoResponse>;
5
+ export declare function ricercaAggiornati(body: AggiornatiBody): Promise<RicercaResponse>;
6
+ export declare const getDenominazioni: () => Promise<DenominazioneItem[]>;
7
+ export declare const getClassiProvvedimento: () => Promise<ClasseItem[]>;
8
+ export declare const getCollezioniPredefinite: () => Promise<CollezioneItem[]>;
9
+ export declare const getRicerchePredefinite: () => Promise<RicerchePredefiniteResponse>;
package/dist/api.js ADDED
@@ -0,0 +1,92 @@
1
+ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
2
+ // Hard-coded to the production OpenData environment. The PRE environment
3
+ // requires mTLS and is intentionally unsupported here.
4
+ export const BASE_URL = "https://api.normattiva.it/t/normattiva.api";
5
+ const COMMON_HEADERS = {
6
+ Accept: "application/json, text/plain, */*",
7
+ "Accept-Language": "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7",
8
+ Origin: "https://dati.normattiva.it",
9
+ "User-Agent": "normattiva-mcp/0.1.0 (+https://www.npmjs.com/package/normattiva-mcp)",
10
+ };
11
+ const REQUEST_TIMEOUT_MS = 20_000;
12
+ async function request(path, init) {
13
+ const url = BASE_URL + path;
14
+ let res;
15
+ try {
16
+ res = await fetch(url, {
17
+ ...init,
18
+ signal: init?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS),
19
+ headers: { ...COMMON_HEADERS, ...init?.headers },
20
+ });
21
+ }
22
+ catch (err) {
23
+ throw new McpError(ErrorCode.InternalError, `Network error contacting Normattiva: ${err.message}`);
24
+ }
25
+ if (!res.ok) {
26
+ let detail = "";
27
+ let apiCode;
28
+ try {
29
+ const body = (await res.json());
30
+ apiCode = body.code;
31
+ detail = body.message ?? body.description ?? body.error ?? JSON.stringify(body);
32
+ }
33
+ catch {
34
+ detail = await res.text().catch(() => "");
35
+ }
36
+ // IPZS-specific 4xx codes (per spec §1.3.x): 1003 invalid export format,
37
+ // 1005/1006 invalid input/vigenza, 1200 resource missing, 1204 token unknown,
38
+ // 1501 window > 12 months, 1502 > 7000 atti, 1503 inverted date range.
39
+ const isClientError = res.status === 400 ||
40
+ res.status === 404 ||
41
+ res.status === 422 ||
42
+ (apiCode !== undefined && /^1[0-9]{3}$/.test(apiCode));
43
+ const mcpCode = isClientError ? ErrorCode.InvalidRequest : ErrorCode.InternalError;
44
+ const codeTag = apiCode ? ` [code=${apiCode}]` : "";
45
+ throw new McpError(mcpCode, `Normattiva API ${res.status}${codeTag}: ${detail || res.statusText}`);
46
+ }
47
+ return (await res.json());
48
+ }
49
+ function postJson(path, body) {
50
+ return request(path, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify(body),
54
+ });
55
+ }
56
+ export function ricercaAvanzata(body) {
57
+ return postJson("/bff-opendata/v1/api/v1/ricerca/avanzata", body);
58
+ }
59
+ export async function dettaglioAtto(body) {
60
+ try {
61
+ return await postJson("/bff-opendata/v1/api/v1/atto/dettaglio-atto", body);
62
+ }
63
+ catch (err) {
64
+ // Soft-fail "not found" responses so callers can probe sotto_articolo / id_gruppo
65
+ // without try/catch: real bad-input or server errors still surface as exceptions.
66
+ if (err instanceof McpError &&
67
+ err.code === ErrorCode.InvalidRequest &&
68
+ /non\s+trovat[oa]/i.test(err.message)) {
69
+ return { success: false, message: err.message };
70
+ }
71
+ throw err;
72
+ }
73
+ }
74
+ export function ricercaAggiornati(body) {
75
+ return postJson("/bff-opendata/v1/api/v1/ricerca/aggiornati", body);
76
+ }
77
+ function memoize(fn) {
78
+ let cache;
79
+ return () => {
80
+ if (!cache) {
81
+ cache = fn().catch((err) => {
82
+ cache = undefined;
83
+ throw err;
84
+ });
85
+ }
86
+ return cache;
87
+ };
88
+ }
89
+ export const getDenominazioni = memoize(() => request("/bff-opendata/v1/api/v1/tipologiche/denominazione-atto"));
90
+ export const getClassiProvvedimento = memoize(() => request("/bff-opendata/v1/api/v1/tipologiche/classe-provvedimento"));
91
+ export const getCollezioniPredefinite = memoize(() => request("/bff-opendata/v1/api/v1/collections/collection-predefinite"));
92
+ export const getRicerchePredefinite = memoize(() => request("/bff-opendata/v1/api/v1/ricerca/predefinita"));
package/dist/html.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function htmlToText(html: string): string;
package/dist/html.js ADDED
@@ -0,0 +1,81 @@
1
+ const NAMED_ENTITIES = {
2
+ amp: "&",
3
+ lt: "<",
4
+ gt: ">",
5
+ quot: '"',
6
+ apos: "'",
7
+ nbsp: " ",
8
+ laquo: "«",
9
+ raquo: "»",
10
+ lsquo: "‘",
11
+ rsquo: "’",
12
+ ldquo: "“",
13
+ rdquo: "”",
14
+ hellip: "…",
15
+ ndash: "–",
16
+ mdash: "—",
17
+ bull: "•",
18
+ middot: "·",
19
+ euro: "€",
20
+ copy: "©",
21
+ reg: "®",
22
+ trade: "™",
23
+ agrave: "à",
24
+ egrave: "è",
25
+ eacute: "é",
26
+ igrave: "ì",
27
+ ograve: "ò",
28
+ ugrave: "ù",
29
+ Agrave: "À",
30
+ Egrave: "È",
31
+ Eacute: "É",
32
+ Igrave: "Ì",
33
+ Ograve: "Ò",
34
+ Ugrave: "Ù",
35
+ sect: "§",
36
+ para: "¶",
37
+ deg: "°",
38
+ };
39
+ function decodeEntity(entity) {
40
+ // Numeric: &#1234; or &#xAB;
41
+ const numeric = /^&#(x?)([0-9a-f]+);$/i.exec(entity);
42
+ if (numeric && numeric[2]) {
43
+ const code = parseInt(numeric[2], numeric[1] ? 16 : 10);
44
+ if (Number.isFinite(code) && code > 0 && code <= 0x10ffff) {
45
+ try {
46
+ return String.fromCodePoint(code);
47
+ }
48
+ catch {
49
+ return entity;
50
+ }
51
+ }
52
+ return entity;
53
+ }
54
+ // Named: &name;
55
+ const named = /^&([a-z]+);$/i.exec(entity);
56
+ if (named && named[1]) {
57
+ return NAMED_ENTITIES[named[1]] ?? entity;
58
+ }
59
+ return entity;
60
+ }
61
+ export function htmlToText(html) {
62
+ return html
63
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
64
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
65
+ .replace(/<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, inner) => {
66
+ // Normattiva citations carry an Akoma-Ntoso URN (urn:nir:...) inside the
67
+ // href. Preserve it as an inline annotation so plain-text consumers can
68
+ // still resolve cross-references; for non-URN links keep just the text.
69
+ const urn = /urn:[a-z]+(?::[^\s"'&<>]+)+/i.exec(href);
70
+ return urn ? `${inner} [${urn[0]}]` : inner;
71
+ })
72
+ .replace(/<br\s*\/?>/gi, "\n")
73
+ .replace(/<\/(p|div|h[1-6]|li|tr|article|section)>/gi, "\n")
74
+ .replace(/<div\b/gi, "\n<div")
75
+ .replace(/<[^>]+>/g, "")
76
+ .replace(/&(?:#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, decodeEntity)
77
+ .replace(/[ \t]+/g, " ")
78
+ .replace(/\n[ \t]+/g, "\n")
79
+ .replace(/\n{3,}/g, "\n\n")
80
+ .trim();
81
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { registerTools } from "./tools.js";
5
+ import { registerResources } from "./resources.js";
6
+ import { registerPrompts } from "./prompts.js";
7
+ async function main() {
8
+ const server = new McpServer({
9
+ name: "normattiva-mcp",
10
+ version: "0.1.0",
11
+ });
12
+ registerTools(server);
13
+ registerResources(server);
14
+ registerPrompts(server);
15
+ const transport = new StdioServerTransport();
16
+ await server.connect(transport);
17
+ }
18
+ main().catch((err) => {
19
+ console.error("[normattiva-mcp] fatal:", err);
20
+ process.exit(1);
21
+ });
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerPrompts(server: McpServer): void;
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ export function registerPrompts(server) {
3
+ server.registerPrompt("ricerca-articolo", {
4
+ title: "Cerca un articolo per argomento",
5
+ description: "Cerca atti normativi italiani su un argomento e legge gli articoli più rilevanti.",
6
+ argsSchema: {
7
+ argomento: z.string().describe("Argomento di interesse, es. 'protezione dei dati personali'."),
8
+ },
9
+ }, ({ argomento }) => ({
10
+ messages: [
11
+ {
12
+ role: "user",
13
+ content: {
14
+ type: "text",
15
+ text: `Voglio capire la normativa italiana su: ${argomento}.\n\n` +
16
+ `Procedi così:\n` +
17
+ `1. Usa lo strumento search_acts per individuare gli atti normativi più rilevanti sull'argomento.\n` +
18
+ `2. Identifica l'atto (o gli atti) più pertinenti dai risultati.\n` +
19
+ `3. Usa read_article per leggere gli articoli più probabilmente rilevanti — fino a circa 10 articoli, scelti in base ai titoli/descrizioni.\n` +
20
+ `4. Fornisci una sintesi chiara con i riferimenti normativi (atto, articolo) per ogni punto.`,
21
+ },
22
+ },
23
+ ],
24
+ }));
25
+ server.registerPrompt("monitoraggio-modifiche", {
26
+ title: "Monitora modifiche normative recenti",
27
+ description: "Recupera gli atti normativi italiani modificati negli ultimi N giorni e ne fornisce un riepilogo.",
28
+ argsSchema: {
29
+ giorni: z
30
+ .string()
31
+ .regex(/^\d+$/, "Numero di giorni come stringa intera")
32
+ .default("7")
33
+ .describe("Quanti giorni indietro guardare (default 7)."),
34
+ },
35
+ }, ({ giorni }) => {
36
+ const n = Number.parseInt(giorni ?? "7", 10);
37
+ const oggi = new Date();
38
+ const inizio = new Date(oggi.getTime() - n * 24 * 60 * 60 * 1000);
39
+ const fmt = (d) => d.toISOString().slice(0, 10);
40
+ return {
41
+ messages: [
42
+ {
43
+ role: "user",
44
+ content: {
45
+ type: "text",
46
+ text: `Recupera con recent_updates gli atti normativi italiani modificati ` +
47
+ `tra il ${fmt(inizio)} e il ${fmt(oggi)}. ` +
48
+ `Raggruppa i risultati per tipologia di atto e fornisci un riepilogo ` +
49
+ `delle modifiche più rilevanti, evidenziando gli atti modificanti principali.`,
50
+ },
51
+ },
52
+ ],
53
+ };
54
+ });
55
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerResources(server: McpServer): void;
@@ -0,0 +1,34 @@
1
+ import { getDenominazioni, getClassiProvvedimento, getCollezioniPredefinite, getRicerchePredefinite, } from "./api.js";
2
+ function jsonResource(uri, data) {
3
+ return {
4
+ contents: [
5
+ {
6
+ uri: uri.href,
7
+ mimeType: "application/json",
8
+ text: JSON.stringify(data, null, 2),
9
+ },
10
+ ],
11
+ };
12
+ }
13
+ export function registerResources(server) {
14
+ server.registerResource("denominazioni", "normattiva://tipologiche/denominazioni", {
15
+ title: "Codici denominazione atto",
16
+ description: "List of valid act-type codes accepted by Normattiva (label = code, value = full Italian description). Use the value (e.g. 'LEGGE') for the denominazione parameter of search_acts.",
17
+ mimeType: "application/json",
18
+ }, async (uri) => jsonResource(uri, await getDenominazioni()));
19
+ server.registerResource("classi-provvedimento", "normattiva://tipologiche/classi-provvedimento", {
20
+ title: "Classi di provvedimento (vigenza)",
21
+ description: "Vigenza class codes: 1 = atto senza aggiornamenti, 2 = aggiornato, 3 = abrogato. Use the label as the classe parameter of search_acts.",
22
+ mimeType: "application/json",
23
+ }, async (uri) => jsonResource(uri, await getClassiProvvedimento()));
24
+ server.registerResource("collezioni-predefinite", "normattiva://collezioni-predefinite", {
25
+ title: "Collezioni preconfezionate Normattiva",
26
+ description: "Predefined collections of acts available on Normattiva, with the number of acts in each.",
27
+ mimeType: "application/json",
28
+ }, async (uri) => jsonResource(uri, await getCollezioniPredefinite()));
29
+ server.registerResource("ricerche-predefinite", "normattiva://ricerche-predefinite", {
30
+ title: "Ricerche predefinite Normattiva",
31
+ description: "Predefined searches with their preset filters (date ranges, classe, etc.).",
32
+ mimeType: "application/json",
33
+ }, async (uri) => jsonResource(uri, await getRicerchePredefinite()));
34
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerTools(server: McpServer): void;
package/dist/tools.js ADDED
@@ -0,0 +1,584 @@
1
+ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
2
+ import { z } from "zod";
3
+ import { ricercaAvanzata, dettaglioAtto, ricercaAggiornati, } from "./api.js";
4
+ import { htmlToText } from "./html.js";
5
+ const dateString = z
6
+ .string()
7
+ .regex(/^\d{4}-\d{2}-\d{2}$/, "Use ISO date YYYY-MM-DD");
8
+ function todayIso() {
9
+ return new Date().toISOString().slice(0, 10);
10
+ }
11
+ function jsonContent(data) {
12
+ return {
13
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
14
+ };
15
+ }
16
+ export function registerTools(server) {
17
+ server.registerTool("search_acts", {
18
+ title: "Search Italian legislation (Normattiva)",
19
+ description: "Search Italian legislation on Normattiva by free text, title, act type, year/number, date range, or vigenza class. Returns metadata for each matching atto including codice_redazionale and data_gu, which are required to fetch article text via read_article. The denominazione parameter expects an Italian act-type label like 'LEGGE' or 'DECRETO LEGISLATIVO' — see resource normattiva://tipologiche/denominazioni for the full list.",
20
+ inputSchema: {
21
+ testo: z.string().optional().describe("Keywords searched in the act body."),
22
+ titolo: z.string().optional().describe("Keywords searched in the act title."),
23
+ denominazione: z
24
+ .string()
25
+ .optional()
26
+ .describe("Italian act-type label, e.g. 'LEGGE', 'DECRETO LEGISLATIVO', 'DECRETO DEL PRESIDENTE DELLA REPUBBLICA'."),
27
+ anno: z.number().int().optional().describe("Year of the provvedimento."),
28
+ numero: z.string().optional().describe("Number of the provvedimento."),
29
+ mese: z.number().int().min(1).max(12).optional(),
30
+ giorno: z.number().int().min(1).max(31).optional(),
31
+ data_emanazione_da: dateString.optional(),
32
+ data_emanazione_a: dateString.optional(),
33
+ data_pubblicazione_da: dateString.optional(),
34
+ data_pubblicazione_a: dateString.optional(),
35
+ classe: z
36
+ .enum(["1", "2", "3"])
37
+ .optional()
38
+ .describe("Vigenza class: 1=senza aggiornamenti (single-version atto), 2=aggiornato, 3=abrogato."),
39
+ codice_tipo: z
40
+ .string()
41
+ .optional()
42
+ .describe("Faceted filter: act-type code (e.g. 'PLE' for LEGGE)."),
43
+ data_vigenza: dateString
44
+ .optional()
45
+ .describe("Restrict results to atti in force on this date (YYYY-MM-DD). Maps to the spec's 'vigenza' field."),
46
+ page: z.number().int().min(1).default(1),
47
+ per_page: z.number().int().min(1).max(50).default(5),
48
+ order: z.enum(["recente", "vecchio"]).default("recente"),
49
+ },
50
+ }, async (args) => {
51
+ const body = {
52
+ orderType: args.order,
53
+ paginazione: {
54
+ paginaCorrente: args.page,
55
+ numeroElementiPerPagina: args.per_page,
56
+ },
57
+ };
58
+ if (args.testo)
59
+ body.testoRicerca = args.testo;
60
+ if (args.titolo)
61
+ body.titoloRicerca = args.titolo;
62
+ if (args.denominazione)
63
+ body.denominazioneAtto = args.denominazione;
64
+ if (args.anno !== undefined)
65
+ body.annoProvvedimento = String(args.anno);
66
+ if (args.numero)
67
+ body.numeroProvvedimento = args.numero;
68
+ if (args.mese !== undefined)
69
+ body.meseProvvedimento = String(args.mese);
70
+ if (args.giorno !== undefined)
71
+ body.giornoProvvedimento = String(args.giorno);
72
+ if (args.data_emanazione_da)
73
+ body.dataInizioEmanazione = args.data_emanazione_da;
74
+ if (args.data_emanazione_a)
75
+ body.dataFineEmanazione = args.data_emanazione_a;
76
+ if (args.data_pubblicazione_da)
77
+ body.dataInizioPubProvvedimento = args.data_pubblicazione_da;
78
+ if (args.data_pubblicazione_a)
79
+ body.dataFinePubProvvedimento = args.data_pubblicazione_a;
80
+ if (args.classe)
81
+ body.classeProvvedimento = args.classe;
82
+ if (args.data_vigenza)
83
+ body.vigenza = args.data_vigenza;
84
+ if (args.codice_tipo)
85
+ body.filtriMap = { codice_tipo_provvedimento: args.codice_tipo };
86
+ const data = await ricercaAvanzata(body);
87
+ return jsonContent({
88
+ totale_atti: data.numeroAttiTrovati ?? 0,
89
+ pagine_totali: data.numeroPagine ?? 0,
90
+ pagina_corrente: data.paginaCorrente ?? args.page,
91
+ atti: (data.listaAtti ?? []).map((a) => ({
92
+ anno: a.annoProvvedimento,
93
+ numero: a.numeroProvvedimento,
94
+ denominazione: a.denominazioneAtto,
95
+ descrizione: a.descrizioneAtto,
96
+ titolo: a.titoloAtto,
97
+ codice_redazionale: a.codiceRedazionale,
98
+ data_gu: a.dataGU,
99
+ data_emanazione: a.dataEmanazione,
100
+ data_pubblicazione: a.dataPubblicazione,
101
+ classe: a.classeProvvedimento,
102
+ })),
103
+ });
104
+ });
105
+ server.registerTool("read_article", {
106
+ title: "Read one article of an Italian act",
107
+ description: "Fetches the text of a single article of a specific atto, at a given date of vigenza. Prefer calling search_acts first to obtain codice_redazionale and data_gu, then list_articles to discover which article numbers exist, and finally read_article on the relevant ones (you may call it in parallel for multiple articles of the same atto). Returns plain text by default; pass format='html' for the original markup (which preserves amendment markers). The text format inlines URN citations as 'L. 5/2003 [urn:nir:stato:legge:2003-06-05;131]'. When the article does not exist (wrong sotto_articolo, id_gruppo or articolo number) the tool returns {found:false, reason, ...} instead of throwing — successful responses include {found:true}. Note: 'bis'/'ter' suffixes require sotto_articolo (1=base, 2=bis, 3=ter, ...), and some atti (e.g. structured codes) require a non-zero id_gruppo whose value is not synchronously discoverable.",
108
+ inputSchema: {
109
+ data_gu: dateString.describe("Publication date in Gazzetta Ufficiale (from search_acts)."),
110
+ codice_redazionale: z.string().describe("Editorial code of the atto (from search_acts)."),
111
+ articolo: z.number().int().describe("Article number."),
112
+ sotto_articolo: z
113
+ .number()
114
+ .int()
115
+ .min(1)
116
+ .default(1)
117
+ .describe("Article suffix index. 1 = base article (e.g. 'art. 13'), 2 = bis, 3 = ter, 4 = quater, ..."),
118
+ sotto_articolo_1: z.number().int().default(0),
119
+ data_vigenza: dateString
120
+ .default(todayIso)
121
+ .describe("Date at which to read the article (defaults to today)."),
122
+ id_gruppo: z.number().int().default(0),
123
+ progressivo: z.number().int().default(0),
124
+ versione: z
125
+ .number()
126
+ .int()
127
+ .default(0)
128
+ .describe("Version selector. 0 (default) is the standard choice and pairs with data_vigenza to return the in-force text on that date."),
129
+ format: z.enum(["text", "html"]).default("text"),
130
+ },
131
+ }, async (args) => {
132
+ const data = await dettaglioAtto({
133
+ dataGU: args.data_gu,
134
+ codiceRedazionale: args.codice_redazionale,
135
+ idArticolo: args.articolo,
136
+ sottoArticolo: args.sotto_articolo,
137
+ sottoArticolo1: args.sotto_articolo_1,
138
+ dataVigenza: args.data_vigenza,
139
+ idGruppo: args.id_gruppo,
140
+ progressivo: args.progressivo,
141
+ versione: args.versione,
142
+ });
143
+ if (!data.success || !data.data?.atto) {
144
+ return jsonContent({
145
+ found: false,
146
+ reason: "no-such-article",
147
+ message: data.message ?? null,
148
+ richiesta: {
149
+ articolo: args.articolo,
150
+ sotto_articolo: args.sotto_articolo,
151
+ codice_redazionale: args.codice_redazionale,
152
+ data_gu: args.data_gu,
153
+ data_vigenza: args.data_vigenza,
154
+ id_gruppo: args.id_gruppo,
155
+ },
156
+ });
157
+ }
158
+ const atto = data.data.atto;
159
+ const html = atto.articoloHtml ?? "";
160
+ const articolo_testo = args.format === "html" ? html : htmlToText(html);
161
+ return jsonContent({
162
+ found: true,
163
+ titolo: atto.titolo,
164
+ tipo: atto.tipoProvvedimentoDescrizione,
165
+ codice_tipo: atto.tipoProvvedimentoCodice,
166
+ format: args.format,
167
+ articolo: articolo_testo,
168
+ data_inizio_vigenza: atto.articoloDataInizioVigenza,
169
+ data_fine_vigenza: atto.articoloDataFineVigenza,
170
+ });
171
+ });
172
+ server.registerTool("list_articles", {
173
+ title: "List articles of an Italian act (sequential probe)",
174
+ description: "Discovers which articles exist in an atto by probing dettaglio-atto sequentially from articolo=1. Normattiva OpenData has no synchronous table-of-contents endpoint, so this is a best-effort enumeration. By default only base articles (sotto_articolo=1) are discovered; pass include_suffixes=true to also probe bis/ter/quater/... for each base article (extra round-trips, stops at the first miss per article). Articles inside structured groups (id_gruppo!=0) are not discovered automatically. Each probe is a real API call. The base probe stops early once a full chunk yields no results after at least one article has been found. Use this before read_article when you need to know the article range.",
175
+ inputSchema: {
176
+ data_gu: dateString.describe("Publication date in Gazzetta Ufficiale (from search_acts)."),
177
+ codice_redazionale: z.string().describe("Editorial code of the atto (from search_acts)."),
178
+ data_vigenza: dateString
179
+ .default(todayIso)
180
+ .describe("Date at which to test article existence (defaults to today)."),
181
+ max_articoli: z
182
+ .number()
183
+ .int()
184
+ .min(1)
185
+ .max(200)
186
+ .default(30)
187
+ .describe("Hard cap on the highest article number to probe."),
188
+ id_gruppo: z
189
+ .number()
190
+ .int()
191
+ .default(0)
192
+ .describe("Article-group id (default 0). Non-zero values target a specific group inside a structured atto."),
193
+ include_suffixes: z
194
+ .boolean()
195
+ .default(false)
196
+ .describe("If true, after finding base articles also probe sotto_articolo=2,3,... for each one until the first miss, discovering bis/ter/quater/... Adds 1-N extra calls per base article."),
197
+ },
198
+ }, async (args) => {
199
+ const CHUNK = 5;
200
+ // Whole-article abrogation marker: '((ARTICOLO ABROGATO ...))' inserted by
201
+ // a successive amendment. Distinct from '((COMMA ABROGATO ...))' which only
202
+ // tags individual commi inside an otherwise-live article.
203
+ const ABROGATED_RE = /\(\(\s*ARTICOLO\s+ABROGATO/i;
204
+ const SUFFIX_LABELS = [
205
+ "base",
206
+ "bis",
207
+ "ter",
208
+ "quater",
209
+ "quinquies",
210
+ "sexies",
211
+ "septies",
212
+ "octies",
213
+ "novies",
214
+ "decies",
215
+ ];
216
+ const MAX_SUFFIX_INDEX = SUFFIX_LABELS.length;
217
+ const found = [];
218
+ let probedUpTo = 0;
219
+ let stoppedEarly = false;
220
+ for (let start = 1; start <= args.max_articoli; start += CHUNK) {
221
+ const end = Math.min(start + CHUNK - 1, args.max_articoli);
222
+ const batch = await Promise.all(Array.from({ length: end - start + 1 }, (_, i) => start + i).map(async (n) => {
223
+ try {
224
+ const data = await dettaglioAtto({
225
+ dataGU: args.data_gu,
226
+ codiceRedazionale: args.codice_redazionale,
227
+ idArticolo: n,
228
+ sottoArticolo: 1,
229
+ sottoArticolo1: 0,
230
+ dataVigenza: args.data_vigenza,
231
+ idGruppo: args.id_gruppo,
232
+ progressivo: 0,
233
+ versione: 0,
234
+ });
235
+ if (!data.success || !data.data?.atto?.articoloHtml) {
236
+ return { articolo: n, ok: false };
237
+ }
238
+ const html = data.data.atto.articoloHtml;
239
+ // Detect the canonical "Art. N" header. Atti often expose the
240
+ // promulgation preamble at idArticolo=1 with no article header.
241
+ const isArticle = /<h2[^>]*class="[^"]*article-num-akn[^"]*"[^>]*>\s*Art\.\s/i.test(html);
242
+ const text = htmlToText(html);
243
+ const preview = text
244
+ .split("\n")
245
+ .map((l) => l.trim())
246
+ .filter((l) => l.length > 0)
247
+ .slice(0, 4)
248
+ .join(" — ")
249
+ .slice(0, 200);
250
+ return {
251
+ articolo: n,
252
+ ok: true,
253
+ titolo: preview,
254
+ is_preamble: !isArticle,
255
+ is_abrogated: ABROGATED_RE.test(text),
256
+ };
257
+ }
258
+ catch (err) {
259
+ // Treat "article not found" (client-error) as a miss; let
260
+ // network/5xx errors surface so the caller sees the failure
261
+ // instead of getting a silently truncated list.
262
+ if (err instanceof McpError && err.code === ErrorCode.InvalidRequest) {
263
+ return { articolo: n, ok: false };
264
+ }
265
+ throw err;
266
+ }
267
+ }));
268
+ probedUpTo = end;
269
+ const hits = batch.filter((b) => b.ok);
270
+ for (const h of hits) {
271
+ if (h.ok) {
272
+ const entry = {
273
+ articolo: h.articolo,
274
+ titolo: h.titolo,
275
+ };
276
+ if (h.is_preamble)
277
+ entry.is_preamble = true;
278
+ if (h.is_abrogated)
279
+ entry.is_abrogated = true;
280
+ found.push(entry);
281
+ }
282
+ }
283
+ if (hits.length === 0 && found.length > 0) {
284
+ stoppedEarly = true;
285
+ break;
286
+ }
287
+ }
288
+ const suffixHits = [];
289
+ if (args.include_suffixes && found.length > 0) {
290
+ // Round-by-round probing: at sa=2 probe every base article, then at sa=3
291
+ // only those that hit at sa=2, etc. Stops at the first miss per article,
292
+ // which matches how bis/ter/quater are densely numbered in practice.
293
+ let live = found
294
+ .filter((f) => !f.is_preamble)
295
+ .map((f) => f.articolo);
296
+ for (let sa = 2; sa <= MAX_SUFFIX_INDEX && live.length > 0; sa++) {
297
+ const round = await Promise.all(live.map(async (n) => {
298
+ try {
299
+ const data = await dettaglioAtto({
300
+ dataGU: args.data_gu,
301
+ codiceRedazionale: args.codice_redazionale,
302
+ idArticolo: n,
303
+ sottoArticolo: sa,
304
+ sottoArticolo1: 0,
305
+ dataVigenza: args.data_vigenza,
306
+ idGruppo: args.id_gruppo,
307
+ progressivo: 0,
308
+ versione: 0,
309
+ });
310
+ if (!data.success || !data.data?.atto?.articoloHtml) {
311
+ return { articolo: n, hit: false };
312
+ }
313
+ const text = htmlToText(data.data.atto.articoloHtml);
314
+ const preview = text
315
+ .split("\n")
316
+ .map((l) => l.trim())
317
+ .filter((l) => l.length > 0)
318
+ .slice(0, 4)
319
+ .join(" — ")
320
+ .slice(0, 200);
321
+ return {
322
+ articolo: n,
323
+ hit: true,
324
+ titolo: preview,
325
+ is_abrogated: ABROGATED_RE.test(text),
326
+ };
327
+ }
328
+ catch (err) {
329
+ if (err instanceof McpError && err.code === ErrorCode.InvalidRequest) {
330
+ return { articolo: n, hit: false };
331
+ }
332
+ throw err;
333
+ }
334
+ }));
335
+ const nextLive = [];
336
+ for (const r of round) {
337
+ if (r.hit) {
338
+ const sx = {
339
+ articolo: r.articolo,
340
+ sotto_articolo: sa,
341
+ suffisso: SUFFIX_LABELS[sa - 1] ?? `sotto_articolo_${sa}`,
342
+ titolo: r.titolo,
343
+ };
344
+ if (r.is_abrogated)
345
+ sx.is_abrogated = true;
346
+ suffixHits.push(sx);
347
+ nextLive.push(r.articolo);
348
+ }
349
+ }
350
+ live = nextLive;
351
+ }
352
+ }
353
+ const articoli = [];
354
+ for (const base of found) {
355
+ articoli.push(base);
356
+ const itsSuffixes = suffixHits
357
+ .filter((s) => s.articolo === base.articolo)
358
+ .sort((a, b) => (a.sotto_articolo ?? 0) - (b.sotto_articolo ?? 0));
359
+ for (const sx of itsSuffixes)
360
+ articoli.push(sx);
361
+ }
362
+ return jsonContent({
363
+ codice_redazionale: args.codice_redazionale,
364
+ data_gu: args.data_gu,
365
+ data_vigenza: args.data_vigenza,
366
+ id_gruppo: args.id_gruppo,
367
+ probed_up_to: probedUpTo,
368
+ stopped_early: stoppedEarly,
369
+ truncated: !stoppedEarly && probedUpTo === args.max_articoli,
370
+ include_suffixes: args.include_suffixes,
371
+ articoli_trovati: articoli.length,
372
+ articoli,
373
+ });
374
+ });
375
+ server.registerTool("recent_updates", {
376
+ title: "Atti recently modified on Normattiva",
377
+ description: "Lists Italian normative acts whose text was modified within a date window. Useful for monitoring legislative changes. The window must be at most 12 months wide and data_fine cannot precede data_inizio.",
378
+ inputSchema: {
379
+ data_inizio: dateString.describe("Start of the modification window (YYYY-MM-DD)."),
380
+ data_fine: dateString.describe("End of the modification window (YYYY-MM-DD), max 12 months after data_inizio."),
381
+ },
382
+ }, async ({ data_inizio, data_fine }) => {
383
+ const data = await ricercaAggiornati({
384
+ dataInizioAggiornamento: `${data_inizio}T00:00:00.000Z`,
385
+ dataFineAggiornamento: `${data_fine}T23:59:59.999Z`,
386
+ });
387
+ return jsonContent({
388
+ numero_atti: data.numeroAttiTrovati ?? 0,
389
+ atti: (data.listaAtti ?? []).map((a) => ({
390
+ descrizione: a.descrizioneAtto,
391
+ titolo: a.titoloAtto,
392
+ codice_redazionale: a.codiceRedazionale,
393
+ data_gu: a.dataGU,
394
+ data_ultima_modifica: a.dataUltimaModifica,
395
+ ultimi_atti_modificanti: a.ultimiAttiModificanti
396
+ ? a.ultimiAttiModificanti.trim().split(/\s+/)
397
+ : [],
398
+ })),
399
+ });
400
+ });
401
+ server.registerTool("read_act", {
402
+ title: "Read an entire Italian act in one call",
403
+ description: "Fetches every article of an atto sequentially and returns them aggregated, capped by max_chars (default 80000 ≈ 20k Claude tokens). Useful when you want the whole law text without orchestrating list_articles + N parallel read_article calls. Probes from articolo_da onward, includes bis/ter when include_suffixes=true, and stops at the first empty chunk or when adding the next unit would exceed max_chars. Truncation is reported via {truncated, truncated_reason, articolo_successivo} so the caller can resume by re-invoking with articolo_da set to that value. Each article entry mirrors read_article output (testo + vigenza dates + is_abrogated flag).",
404
+ inputSchema: {
405
+ data_gu: dateString.describe("Publication date in Gazzetta Ufficiale (from search_acts)."),
406
+ codice_redazionale: z.string().describe("Editorial code of the atto (from search_acts)."),
407
+ data_vigenza: dateString
408
+ .default(todayIso)
409
+ .describe("Date at which to read the atto (defaults to today)."),
410
+ format: z.enum(["text", "html"]).default("text"),
411
+ max_chars: z
412
+ .number()
413
+ .int()
414
+ .min(1000)
415
+ .max(500_000)
416
+ .default(80_000)
417
+ .describe("Soft cap on the total number of characters across all aggregated articles. Checked before adding each unit. Default ≈ 20k Claude tokens."),
418
+ max_articoli: z
419
+ .number()
420
+ .int()
421
+ .min(1)
422
+ .max(200)
423
+ .default(50)
424
+ .describe("Hard cap on how many base article numbers to probe past articolo_da."),
425
+ articolo_da: z
426
+ .number()
427
+ .int()
428
+ .min(1)
429
+ .default(1)
430
+ .describe("Starting article number. Set to articolo_successivo from a previous truncated response to resume."),
431
+ include_suffixes: z
432
+ .boolean()
433
+ .default(false)
434
+ .describe("If true, after each base article also fetch its bis/ter/quater/... chain. Each suffix counts against max_chars. If the budget runs out mid-chain, the remaining suffixes of that article are skipped — fetch them with read_article if needed."),
435
+ id_gruppo: z
436
+ .number()
437
+ .int()
438
+ .default(0)
439
+ .describe("Article-group id. Non-zero values target a specific group inside a structured atto."),
440
+ },
441
+ }, async (args) => {
442
+ const CHUNK = 5;
443
+ const SUFFIX_LABELS = [
444
+ "base",
445
+ "bis",
446
+ "ter",
447
+ "quater",
448
+ "quinquies",
449
+ "sexies",
450
+ "septies",
451
+ "octies",
452
+ "novies",
453
+ "decies",
454
+ ];
455
+ const ABROGATED_RE = /\(\(\s*ARTICOLO\s+ABROGATO/i;
456
+ const articoli = [];
457
+ let totalChars = 0;
458
+ let attoTitolo;
459
+ let attoTipo;
460
+ let attoCodiceTipo;
461
+ let probedUpTo = args.articolo_da - 1;
462
+ let stoppedEarly = false;
463
+ let truncatedReason = null;
464
+ let articoloSuccessivo = null;
465
+ const fetchUnit = async (n, sa) => {
466
+ try {
467
+ const data = await dettaglioAtto({
468
+ dataGU: args.data_gu,
469
+ codiceRedazionale: args.codice_redazionale,
470
+ idArticolo: n,
471
+ sottoArticolo: sa,
472
+ sottoArticolo1: 0,
473
+ dataVigenza: args.data_vigenza,
474
+ idGruppo: args.id_gruppo,
475
+ progressivo: 0,
476
+ versione: 0,
477
+ });
478
+ if (!data.success || !data.data?.atto?.articoloHtml)
479
+ return null;
480
+ const atto = data.data.atto;
481
+ const html = atto.articoloHtml;
482
+ // Narrow for TS: the guard above already excluded undefined.
483
+ if (typeof html !== "string")
484
+ return null;
485
+ attoTitolo ??= atto.titolo;
486
+ attoTipo ??= atto.tipoProvvedimentoDescrizione;
487
+ attoCodiceTipo ??= atto.tipoProvvedimentoCodice;
488
+ const isArticle = /<h2[^>]*class="[^"]*article-num-akn[^"]*"[^>]*>\s*Art\.\s/i.test(html);
489
+ const testo = args.format === "html" ? html : htmlToText(html);
490
+ return {
491
+ testo,
492
+ isArticle,
493
+ // The '((ARTICOLO ABROGATO' marker is plain text in both formats —
494
+ // test on the raw HTML to avoid an extra conversion when format=html.
495
+ isAbrogated: ABROGATED_RE.test(html),
496
+ atto,
497
+ };
498
+ }
499
+ catch (err) {
500
+ if (err instanceof McpError && err.code === ErrorCode.InvalidRequest)
501
+ return null;
502
+ throw err;
503
+ }
504
+ };
505
+ const upperBound = args.articolo_da + args.max_articoli - 1;
506
+ outer: for (let start = args.articolo_da; start <= upperBound; start += CHUNK) {
507
+ const end = Math.min(start + CHUNK - 1, upperBound);
508
+ const numbers = Array.from({ length: end - start + 1 }, (_, i) => start + i);
509
+ const baseResults = await Promise.all(numbers.map((n) => fetchUnit(n, 1).then((r) => ({ n, r }))));
510
+ probedUpTo = end;
511
+ const hits = baseResults.filter((x) => x.r !== null);
512
+ if (hits.length === 0 && articoli.length > 0) {
513
+ stoppedEarly = true;
514
+ break;
515
+ }
516
+ for (const { n, r } of baseResults) {
517
+ if (!r)
518
+ continue;
519
+ if (totalChars + r.testo.length > args.max_chars && articoli.length > 0) {
520
+ truncatedReason = "max_chars";
521
+ articoloSuccessivo = n;
522
+ break outer;
523
+ }
524
+ const entry = {
525
+ articolo: n,
526
+ testo: r.testo,
527
+ data_inizio_vigenza: r.atto.articoloDataInizioVigenza,
528
+ data_fine_vigenza: r.atto.articoloDataFineVigenza,
529
+ };
530
+ if (!r.isArticle)
531
+ entry.is_preamble = true;
532
+ if (r.isAbrogated)
533
+ entry.is_abrogated = true;
534
+ articoli.push(entry);
535
+ totalChars += r.testo.length;
536
+ if (args.include_suffixes && r.isArticle) {
537
+ for (let sa = 2; sa < SUFFIX_LABELS.length; sa++) {
538
+ const sx = await fetchUnit(n, sa);
539
+ if (!sx)
540
+ break;
541
+ if (totalChars + sx.testo.length > args.max_chars) {
542
+ truncatedReason = "max_chars";
543
+ articoloSuccessivo = n + 1;
544
+ entry.suffixes_truncated = true;
545
+ break outer;
546
+ }
547
+ const sxEntry = {
548
+ articolo: n,
549
+ sotto_articolo: sa,
550
+ suffisso: SUFFIX_LABELS[sa - 1] ?? `sotto_articolo_${sa}`,
551
+ testo: sx.testo,
552
+ data_inizio_vigenza: sx.atto.articoloDataInizioVigenza,
553
+ data_fine_vigenza: sx.atto.articoloDataFineVigenza,
554
+ };
555
+ if (sx.isAbrogated)
556
+ sxEntry.is_abrogated = true;
557
+ articoli.push(sxEntry);
558
+ totalChars += sx.testo.length;
559
+ }
560
+ }
561
+ }
562
+ }
563
+ const exhaustedRange = truncatedReason === null && probedUpTo >= upperBound && !stoppedEarly;
564
+ return jsonContent({
565
+ titolo: attoTitolo ?? null,
566
+ tipo: attoTipo ?? null,
567
+ codice_tipo: attoCodiceTipo ?? null,
568
+ codice_redazionale: args.codice_redazionale,
569
+ data_gu: args.data_gu,
570
+ data_vigenza: args.data_vigenza,
571
+ format: args.format,
572
+ articolo_da: args.articolo_da,
573
+ probed_up_to: probedUpTo,
574
+ stopped_early: stoppedEarly,
575
+ truncated: truncatedReason !== null || exhaustedRange,
576
+ truncated_reason: truncatedReason ?? (exhaustedRange ? "max_articoli" : null),
577
+ articolo_successivo: articoloSuccessivo ?? (exhaustedRange ? upperBound + 1 : null),
578
+ char_count: totalChars,
579
+ articoli_inclusi: articoli.length,
580
+ include_suffixes: args.include_suffixes,
581
+ articoli,
582
+ });
583
+ });
584
+ }
@@ -0,0 +1,98 @@
1
+ export interface AttoListItem {
2
+ annoProvvedimento?: number;
3
+ numeroProvvedimento?: string;
4
+ denominazioneAtto?: string;
5
+ descrizioneAtto?: string;
6
+ titoloAtto?: string;
7
+ codiceRedazionale?: string;
8
+ dataGU?: string;
9
+ dataEmanazione?: string;
10
+ dataPubblicazione?: string;
11
+ classeProvvedimento?: string;
12
+ dataUltimaModifica?: string;
13
+ ultimiAttiModificanti?: string;
14
+ }
15
+ export interface RicercaResponse {
16
+ numeroAttiTrovati?: number;
17
+ numeroPagine?: number;
18
+ paginaCorrente?: number;
19
+ listaAtti?: AttoListItem[];
20
+ facetMap?: Record<string, Array<{
21
+ descrizione: string;
22
+ valore: number;
23
+ }>>;
24
+ }
25
+ export interface DettaglioAttoBody {
26
+ dataGU: string;
27
+ codiceRedazionale: string;
28
+ idArticolo: number;
29
+ sottoArticolo: number;
30
+ sottoArticolo1: number;
31
+ dataVigenza: string;
32
+ idGruppo: number;
33
+ progressivo: number;
34
+ versione: number;
35
+ }
36
+ export interface DettaglioAttoResponse {
37
+ success?: boolean;
38
+ data?: {
39
+ atto?: {
40
+ titolo?: string;
41
+ tipoProvvedimentoDescrizione?: string;
42
+ tipoProvvedimentoCodice?: string;
43
+ articoloHtml?: string;
44
+ articoloDataInizioVigenza?: string;
45
+ articoloDataFineVigenza?: string;
46
+ };
47
+ };
48
+ message?: string;
49
+ code?: string | null;
50
+ }
51
+ export interface RicercaAvanzataBody {
52
+ testoRicerca?: string;
53
+ titoloRicerca?: string;
54
+ denominazioneAtto?: string;
55
+ annoProvvedimento?: string;
56
+ numeroProvvedimento?: string;
57
+ meseProvvedimento?: string;
58
+ giornoProvvedimento?: string;
59
+ dataInizioEmanazione?: string;
60
+ dataFineEmanazione?: string;
61
+ dataInizioPubProvvedimento?: string;
62
+ dataFinePubProvvedimento?: string;
63
+ vigenza?: string;
64
+ classeProvvedimento?: string;
65
+ orderType: "recente" | "vecchio";
66
+ paginazione: {
67
+ paginaCorrente: number;
68
+ numeroElementiPerPagina: number;
69
+ };
70
+ filtriMap?: Record<string, string | number>;
71
+ }
72
+ export interface AggiornatiBody {
73
+ dataInizioAggiornamento: string;
74
+ dataFineAggiornamento: string;
75
+ }
76
+ export interface DenominazioneItem {
77
+ label: string;
78
+ value: string;
79
+ }
80
+ export interface ClasseItem {
81
+ label: string;
82
+ value: string;
83
+ }
84
+ export interface CollezioneItem {
85
+ nomeCollezione: string;
86
+ numeroAtti: number;
87
+ }
88
+ export interface RicercaPredefinita {
89
+ nome: string;
90
+ dettagli: Array<{
91
+ nomeCampo: string;
92
+ valoreCampo: string;
93
+ }>;
94
+ dataCreazione: string;
95
+ }
96
+ export interface RicerchePredefiniteResponse {
97
+ ricerchePredefinite: RicercaPredefinita[];
98
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "normattiva-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server wrapping the Normattiva OpenData API for Italian legislation (search, read articles, monitor recent updates).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "normattiva-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc && chmod +x dist/index.js",
20
+ "start": "node dist/index.js",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "normattiva",
27
+ "italian-law",
28
+ "legislation",
29
+ "ipzs",
30
+ "ai",
31
+ "claude"
32
+ ],
33
+ "author": "adellorto (https://github.com/adellorto)",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/adellorto/normattiva-mcp.git"
38
+ },
39
+ "homepage": "https://github.com/adellorto/normattiva-mcp/tree/main",
40
+ "bugs": {
41
+ "url": "https://github.com/adellorto/normattiva-mcp/issues"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.29.0",
45
+ "zod": "^4.4.3"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.10.0",
49
+ "typescript": "^5.6.0"
50
+ }
51
+ }