boe-eurlex-mcp 1.0.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 +21 -0
- package/README.md +120 -0
- package/assets/boe_ai.gif +0 -0
- package/dist/boe/api.js +149 -0
- package/dist/eurlex/api.js +145 -0
- package/dist/index.js +175 -0
- package/dist/utils/hash.js +4 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
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.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="assets/boe_ai.gif" width="300" alt="BOE AI Demo">
|
|
3
|
+
|
|
4
|
+
<h1>BOE & EUR-Lex MCP Server</h1>
|
|
5
|
+
<h3>La Fuente de Verdad Jurídica Definitiva para tu Agente</h3>
|
|
6
|
+
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](https://modelcontextprotocol.io)
|
|
9
|
+
[](https://www.npmjs.com/package/boe-eurlex-mcp)
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="#-la-solución">Solución</a> •
|
|
13
|
+
<a href="#-instalación">Instalación</a> •
|
|
14
|
+
<a href="#-quick-start">Quick Start</a> •
|
|
15
|
+
<a href="#-garantía-de-verdad">Garantía</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 🧐 La Solución
|
|
23
|
+
|
|
24
|
+
Seamos honestos: los LLMs **alucinan leyes**. Se inventan artículos, mezclan normativas derogadas y te citan leyes que suenan muy convincentes pero que **no existen**.
|
|
25
|
+
|
|
26
|
+
El **BOE & EUR-Lex MCP Server** es el "abogado en la sombra" que tu IA necesita. Conecta directamente a tu agente con la base de datos oficial del **Boletín Oficial del Estado (España)** y **EUR-Lex (Unión Europea)**.
|
|
27
|
+
|
|
28
|
+
> **Tu agente ya no "cree saber". Ahora sabe buscar, leer y citar la fuente oficial.**
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 🚀 Instalación
|
|
33
|
+
|
|
34
|
+
### Opción A: "Soy un usuario civilizado" (NPM Global)
|
|
35
|
+
Instálalo una vez, úsalo siempre.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install -g boe-eurlex-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Opción B: "Me gusta vivir al límite" (NPX)
|
|
42
|
+
Ejecútalo bajo demanda sin instalar nada.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx -y boe-eurlex-mcp
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## ⚡ Quick Start
|
|
51
|
+
|
|
52
|
+
Integra el poder del BOE en tu herramienta favorita en segundos.
|
|
53
|
+
|
|
54
|
+
### 💎 Gemini CLI
|
|
55
|
+
Dale superpoderes legales a tu terminal:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
gemini mcp add boe_eurlex npx -y boe-eurlex-mcp --scope user
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 🤖 Claude Desktop
|
|
62
|
+
Añade esto a tu `claude_desktop_config.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"boe_eurlex": {
|
|
68
|
+
"command": "npx",
|
|
69
|
+
"args": ["-y", "boe-eurlex-mcp"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 🌌 Antigravity / Cursor / Cline
|
|
76
|
+
Simplemente busca la sección MCP en tu configuración y añade:
|
|
77
|
+
|
|
78
|
+
**Comando:** `npx -y boe-eurlex-mcp`
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 🛠️ Arsenal Legal (Herramientas)
|
|
83
|
+
|
|
84
|
+
Tu agente tendrá acceso a estas herramientas de precisión quirúrgica:
|
|
85
|
+
|
|
86
|
+
### 🇪🇸 Jurisdicción Española (BOE)
|
|
87
|
+
|
|
88
|
+
| Herramienta | Lo que hace (y lo que no) |
|
|
89
|
+
| :--- | :--- |
|
|
90
|
+
| **`boe.search`** | Busca en toda la legislación consolidada. *Nota: Ahora busca en título Y texto, porque sabemos que nadie se sabe el nombre oficial de la "Ley de Startups".* |
|
|
91
|
+
| **`boe.get_article`** | Extrae un artículo específico con precisión láser. Nada de "resúmenes vagos". Texto literal. |
|
|
92
|
+
| **`boe.get_daily_summary`** | ¿Qué ha pasado hoy en el BOE? Consúltalo con el café de la mañana. |
|
|
93
|
+
|
|
94
|
+
### 🇪🇺 Jurisdicción Europea (EUR-Lex)
|
|
95
|
+
|
|
96
|
+
| Herramienta | Misión |
|
|
97
|
+
| :--- | :--- |
|
|
98
|
+
| **`eurlex.search`** | Busca Directivas y Reglamentos en la "jungla" de Bruselas. |
|
|
99
|
+
| **`eurlex.get_article`** | Saca ese artículo 5 del RGPD sin despeinarse. |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 🔐 Garantía de Verdad (Anti-Alucinaciones)
|
|
104
|
+
|
|
105
|
+
Para que duermas tranquilo, cada respuesta del servidor incluye una **Huella Digital Criptográfica (SHA-256)**.
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"source": "BOE",
|
|
110
|
+
"id": "BOE-A-1996-8930",
|
|
111
|
+
"mensaje": "Si el hash no coincide, alguien ha tocado el texto. (Spoiler: no hemos sido nosotros).",
|
|
112
|
+
"hash_value": "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 📜 Licencia
|
|
119
|
+
|
|
120
|
+
**MIT**. Haz lo que quieras con el código. Solo no nos culpes si pierdes un juicio (esto es una herramienta, no un abogado colegiado).
|
|
Binary file
|
package/dist/boe/api.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
const BOE_API_BASE = "https://www.boe.es/datosabiertos/api";
|
|
4
|
+
export async function searchBoe(query, limit = 5) {
|
|
5
|
+
try {
|
|
6
|
+
// Construct query JSON as per PDF
|
|
7
|
+
// If query does not contain field specifier, search in both titulo and texto
|
|
8
|
+
// This allows finding "Ley de startups" which mentions "startups" in text but not title
|
|
9
|
+
const queryString = query.includes(":") ? query : `(titulo:${query} OR texto:${query})`;
|
|
10
|
+
const queryObj = {
|
|
11
|
+
query: {
|
|
12
|
+
query_string: {
|
|
13
|
+
query: queryString
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
// According to PDF: /datosabiertos/api/legislacion-consolidada?query={...}
|
|
18
|
+
const response = await axios.get(`${BOE_API_BASE}/legislacion-consolidada`, {
|
|
19
|
+
params: {
|
|
20
|
+
query: JSON.stringify(queryObj),
|
|
21
|
+
limit: limit
|
|
22
|
+
},
|
|
23
|
+
headers: {
|
|
24
|
+
Accept: "application/json"
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
if (response.data?.status?.code !== "200") {
|
|
28
|
+
console.error("BOE API Error:", response.data);
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
return response.data.data || [];
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
console.error("Error searching BOE:", error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Helper to extract text from a block
|
|
39
|
+
function extractTextFromBlock(block) {
|
|
40
|
+
if (!block.version)
|
|
41
|
+
return "";
|
|
42
|
+
// Find latest version (usually last one? or check dates. For now assume request gives consolidated current/latest valid?)
|
|
43
|
+
// The PDF says "returns consolidated text complete with all versions".
|
|
44
|
+
// We probably want the current valid version.
|
|
45
|
+
// If it's an array, we need to filter by date? Or usually the logic is complex.
|
|
46
|
+
// Simplification: Take the last version (often the current one).
|
|
47
|
+
let version = block.version;
|
|
48
|
+
if (Array.isArray(version)) {
|
|
49
|
+
version = version[version.length - 1];
|
|
50
|
+
}
|
|
51
|
+
if (!version)
|
|
52
|
+
return "";
|
|
53
|
+
// Content is in 'p', 'table', etc.
|
|
54
|
+
// In JSON, 'p' might be an array of objects or strings?
|
|
55
|
+
// PDF page 14: <p class="parrafo">Text</p>
|
|
56
|
+
// In JSON conversion, it typically becomes { "class": "parrafo", "$t": "Text" } or similar.
|
|
57
|
+
// Since exact JSON structure is not guaranteed to be clean, XML might be safer to parse with cheerio.
|
|
58
|
+
// But let's try JSON first.
|
|
59
|
+
let text = "";
|
|
60
|
+
if (version.p) {
|
|
61
|
+
const paragraphs = Array.isArray(version.p) ? version.p : [version.p];
|
|
62
|
+
text = paragraphs.map((p) => typeof p === 'string' ? p : p['$t'] || p['_text'] || JSON.stringify(p)).join("\n");
|
|
63
|
+
}
|
|
64
|
+
return text;
|
|
65
|
+
}
|
|
66
|
+
export async function getBoeArticle(id, articleNum) {
|
|
67
|
+
try {
|
|
68
|
+
const url = `${BOE_API_BASE}/legislacion-consolidada/id/${id}/texto`;
|
|
69
|
+
// We use XML because structure of text content in JSON can be messy (elements mixed).
|
|
70
|
+
// Cheerio is great for this.
|
|
71
|
+
const response = await axios.get(url, {
|
|
72
|
+
headers: { Accept: "application/xml" } // Explicitly ask for XML
|
|
73
|
+
});
|
|
74
|
+
const $ = cheerio.load(response.data, { xmlMode: true });
|
|
75
|
+
// Find block with type "precepto" (usually articles) and title matching "Artículo X"
|
|
76
|
+
// Title might be "Artículo 1", "Artículo 1.", "Art. 1", etc.
|
|
77
|
+
// We'll normalize.
|
|
78
|
+
const articleRegex = new RegExp(`^Art[ií]culo\\s+${articleNum}[^0-9]*$`, 'i');
|
|
79
|
+
let foundBlock = null;
|
|
80
|
+
$("bloque").each((i, el) => {
|
|
81
|
+
const tipo = $(el).attr("tipo");
|
|
82
|
+
const titulo = $(el).attr("titulo") || "";
|
|
83
|
+
// console.log(`Checking block: ${titulo} (${tipo})`);
|
|
84
|
+
// Check if title matches "Artículo {num}"
|
|
85
|
+
// Flexible match: "Artículo 10", "Artículo 10.", "Artículo 10: ..."
|
|
86
|
+
if ((tipo === "precepto" || tipo === "articulo") && titulo) {
|
|
87
|
+
// Exact number match is hard. "Artículo 10" vs "Artículo 10 bis".
|
|
88
|
+
// If user asks for "10", we probably want "Artículo 10" exactly, or "Artículo 10."
|
|
89
|
+
const normBooking = titulo.trim().replace(/\.$/, ""); // remove trailing dot
|
|
90
|
+
if (normBooking.toLowerCase() === `artículo ${articleNum.toLowerCase()}` || normBooking.toLowerCase() === `articulo ${articleNum.toLowerCase()}`) {
|
|
91
|
+
foundBlock = el;
|
|
92
|
+
return false; // break
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
if (!foundBlock) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
// Extract content from the block.
|
|
100
|
+
// We need the *content* of the latest version.
|
|
101
|
+
// Inside <bloque>, there are <version> tags.
|
|
102
|
+
// We want the one that is currently valid.
|
|
103
|
+
// <version fecha_vigencia="..." fecha_derogacion="...">
|
|
104
|
+
// If multiple versions, pick the one with highest start date that isn't derogated?
|
|
105
|
+
// Or usually the API returns them in order?
|
|
106
|
+
// Let's take the last version entry which usually represents the current state.
|
|
107
|
+
const lastVersion = $(foundBlock).find("version").last();
|
|
108
|
+
// Extract text content from <p> tags, maintaining order using Cheerio
|
|
109
|
+
// We want to preserve newlines.
|
|
110
|
+
let textContent = "";
|
|
111
|
+
lastVersion.children().each((i, el) => {
|
|
112
|
+
if (el.type === 'tag' && el.name === 'p') {
|
|
113
|
+
textContent += $(el).text() + "\n";
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
source: "BOE",
|
|
118
|
+
id_or_celex: id,
|
|
119
|
+
article: articleNum,
|
|
120
|
+
text: textContent.trim(),
|
|
121
|
+
citation: {
|
|
122
|
+
url: `${BOE_API_BASE}/legislacion-consolidada/id/${id}`, // Or the web URL
|
|
123
|
+
retrieved_at: new Date().toISOString()
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
console.error("Error getting BOE article:", error);
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function getDailySummary(date) {
|
|
133
|
+
try {
|
|
134
|
+
// Date format YYYYMMDD
|
|
135
|
+
const url = `${BOE_API_BASE}/boe/sumario/${date}`;
|
|
136
|
+
const response = await axios.get(url, {
|
|
137
|
+
headers: { Accept: "application/json" }
|
|
138
|
+
});
|
|
139
|
+
if (response.data?.status?.code !== "200") {
|
|
140
|
+
// Try XML if generic error or 404 in JSON wrapper (though axios throws on 404 usually)
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return response.data;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error("Error getting daily summary:", error);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
const EURLEX_SEARCH_BASE = "https://eur-lex.europa.eu/search.html";
|
|
4
|
+
const EURLEX_CONTENT_BASE = "https://eur-lex.europa.eu/legal-content";
|
|
5
|
+
export async function searchEurlex(query, lang = "ES", limit = 5) {
|
|
6
|
+
try {
|
|
7
|
+
// Construct search URL
|
|
8
|
+
// scope=EURLEX&text=...&lang=...&type=quick
|
|
9
|
+
const url = `${EURLEX_SEARCH_BASE}`;
|
|
10
|
+
const params = {
|
|
11
|
+
scope: "EURLEX",
|
|
12
|
+
text: query,
|
|
13
|
+
lang: lang,
|
|
14
|
+
type: "quick",
|
|
15
|
+
sortOne: "DD", // Date descending? Or default. Let's use default relevance.
|
|
16
|
+
sortOneOrder: "desc"
|
|
17
|
+
};
|
|
18
|
+
const response = await axios.get(url, { params });
|
|
19
|
+
const $ = cheerio.load(response.data);
|
|
20
|
+
const results = [];
|
|
21
|
+
// Selectors are tricky and might change.
|
|
22
|
+
// Usually results are in .SearchResult
|
|
23
|
+
$(".SearchResult").each((i, el) => {
|
|
24
|
+
if (i >= limit)
|
|
25
|
+
return false;
|
|
26
|
+
const titleEl = $(el).find("h2 > a, h4 > a").first(); // Typically h2 or h4
|
|
27
|
+
const title = titleEl.text().trim();
|
|
28
|
+
const href = titleEl.attr("href");
|
|
29
|
+
if (!title || !href)
|
|
30
|
+
return;
|
|
31
|
+
// Href usually starts with ./legal-content/... or absolute
|
|
32
|
+
// format: ./legal-content/ES/TXT/?uri=CELEX:32016R0679&qid=...
|
|
33
|
+
// We need to extract CELEX.
|
|
34
|
+
let celex = "";
|
|
35
|
+
const celexMatch = href.match(/CELEX:([A-Za-z0-9]+)/);
|
|
36
|
+
if (celexMatch) {
|
|
37
|
+
celex = celexMatch[1];
|
|
38
|
+
}
|
|
39
|
+
// Fix URL to absolute
|
|
40
|
+
const absoluteUrl = href.startsWith("http") ? href : `https://eur-lex.europa.eu${href.replace(/^\./, "")}`;
|
|
41
|
+
results.push({
|
|
42
|
+
title,
|
|
43
|
+
celex,
|
|
44
|
+
url: absoluteUrl
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error("Error searching EUR-Lex:", error);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function getEurlexArticle(celex, articleNum, lang = "ES") {
|
|
55
|
+
try {
|
|
56
|
+
// Direct HTML content URL
|
|
57
|
+
// https://eur-lex.europa.eu/legal-content/ES/TXT/HTML/?uri=CELEX:32016R0679
|
|
58
|
+
const url = `${EURLEX_CONTENT_BASE}/${lang}/TXT/HTML/?uri=CELEX:${celex}`;
|
|
59
|
+
const response = await axios.get(url, {
|
|
60
|
+
// header 'User-Agent' might be needed to avoid bot detection?
|
|
61
|
+
// EUR-Lex is usually open but let's see.
|
|
62
|
+
});
|
|
63
|
+
const $ = cheerio.load(response.data);
|
|
64
|
+
// Find the article.
|
|
65
|
+
// Search for element with text matching "Artículo {num}" exactly or close to it.
|
|
66
|
+
// Structure is often: <p class="ti-art">Artículo 1</p>
|
|
67
|
+
let foundArticle = false;
|
|
68
|
+
let articleText = "";
|
|
69
|
+
let capture = false;
|
|
70
|
+
// Iterate over all elements in the body or standard container
|
|
71
|
+
// Accessing flattened list of elements can be hard.
|
|
72
|
+
// We can traverse body children.
|
|
73
|
+
const body = $("body");
|
|
74
|
+
// Helper to normalize text for comparison
|
|
75
|
+
const targetTitle = `Artículo ${articleNum}`;
|
|
76
|
+
const targetTitlePoints = `Artículo ${articleNum}.`; // Sometimes with dot
|
|
77
|
+
const targetTitleSpace = `Artículo ${articleNum} `; // With trailing space
|
|
78
|
+
// We will loop through ALL paragraph-like elements or headers.
|
|
79
|
+
// Or just traverse all children of the main container (usually div class="bbody" or similar, or just body)
|
|
80
|
+
// Let's try traversing all elements and detecting start/stop.
|
|
81
|
+
// In Cheerio, we can select all *relevant* tags in order.
|
|
82
|
+
// p, div, table...
|
|
83
|
+
// But hierarchy matters.
|
|
84
|
+
// Better: Find the Header element, then use .nextUntil() logic?
|
|
85
|
+
// Find the start element
|
|
86
|
+
let startElement = null;
|
|
87
|
+
$("*").each((i, el) => {
|
|
88
|
+
// Optimization: check tag type
|
|
89
|
+
if ('name' in el) {
|
|
90
|
+
const tag = el.name;
|
|
91
|
+
if (!['p', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag))
|
|
92
|
+
return;
|
|
93
|
+
const text = $(el).text().trim().replace(/\s+/g, " ");
|
|
94
|
+
// Regex: ^Artículo 1(\.| |$) case insensitive
|
|
95
|
+
const regex = new RegExp(`^Art[ií]culo\\s+${articleNum}(\\.|\\s|$)`, 'i');
|
|
96
|
+
if (regex.test(text) && text.length < 50) {
|
|
97
|
+
if (!startElement) {
|
|
98
|
+
startElement = $(el);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (!startElement) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
// Now capture text from startElement until next Article
|
|
107
|
+
// We need to traverse siblings.
|
|
108
|
+
articleText += startElement.text() + "\n";
|
|
109
|
+
let next = startElement.next();
|
|
110
|
+
while (next.length > 0) {
|
|
111
|
+
const text = next.text().trim().replace(/\s+/g, " ");
|
|
112
|
+
// Check if next element is start of NEW article
|
|
113
|
+
// Regex for ANY article: ^Artículo \d+
|
|
114
|
+
if (/^Art[ií]culo\s+\d+/i.test(text) && text.length < 50) {
|
|
115
|
+
break; // Stop
|
|
116
|
+
}
|
|
117
|
+
// Append text
|
|
118
|
+
if (next.is('table')) {
|
|
119
|
+
// handle table parsing if needed, or just "Table content"
|
|
120
|
+
articleText += "[Tabla]\n";
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
articleText += next.text().trim() + "\n";
|
|
124
|
+
}
|
|
125
|
+
next = next.next();
|
|
126
|
+
}
|
|
127
|
+
if (!articleText) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
source: "EUR-Lex",
|
|
132
|
+
id_or_celex: celex,
|
|
133
|
+
article: articleNum,
|
|
134
|
+
text: articleText.trim(),
|
|
135
|
+
citation: {
|
|
136
|
+
url: `${EURLEX_CONTENT_BASE}/${lang}/TXT/HTML/?uri=CELEX:${celex}`,
|
|
137
|
+
retrieved_at: new Date().toISOString()
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
console.error("Error getting EUR-Lex article:", error);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { searchBoe, getBoeArticle, getDailySummary } from "./boe/api.js";
|
|
6
|
+
import { searchEurlex, getEurlexArticle } from "./eurlex/api.js";
|
|
7
|
+
import { calculateHash } from "./utils/hash.js";
|
|
8
|
+
// Define the tools
|
|
9
|
+
const BOE_SEARCH_TOOL = {
|
|
10
|
+
name: "boe_search",
|
|
11
|
+
description: "Busca normativa en la legislación consolidada del BOE.",
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
q: { type: "string", description: "Término de búsqueda" },
|
|
16
|
+
limit: { type: "number", description: "Número máximo de resultados", default: 5 },
|
|
17
|
+
},
|
|
18
|
+
required: ["q"],
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
const BOE_GET_ARTICLE_TOOL = {
|
|
22
|
+
name: "boe_get_article",
|
|
23
|
+
description: "Obtiene un artículo concreto de una norma del BOE.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
id: { type: "string", description: "Identificador de la norma (ej. BOE-A-1996-8930)" },
|
|
28
|
+
article: { type: "string", description: "Número del artículo" },
|
|
29
|
+
},
|
|
30
|
+
required: ["id", "article"],
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const BOE_GET_DAILY_SUMMARY_TOOL = {
|
|
34
|
+
name: "boe_get_daily_summary",
|
|
35
|
+
description: "Obtiene el sumario del BOE de una fecha concreta.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
date: { type: "string", description: "Fecha en formato YYYYMMDD" },
|
|
40
|
+
},
|
|
41
|
+
required: ["date"],
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const EURLEX_SEARCH_TOOL = {
|
|
45
|
+
name: "eurlex_search",
|
|
46
|
+
description: "Busca normativa europea.",
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
q: { type: "string", description: "Término de búsqueda" },
|
|
51
|
+
limit: { type: "number", description: "Número máximo de resultados", default: 5 },
|
|
52
|
+
lang: { type: "string", description: "Idioma (ej. ES)", default: "ES" },
|
|
53
|
+
},
|
|
54
|
+
required: ["q"],
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
const EURLEX_GET_ARTICLE_TOOL = {
|
|
58
|
+
name: "eurlex_get_article",
|
|
59
|
+
description: "Obtiene un artículo concreto de normativa europea.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
celex: { type: "string", description: "Identificador CELEX" },
|
|
64
|
+
article: { type: "string", description: "Número del artículo" },
|
|
65
|
+
lang: { type: "string", description: "Idioma (ej. ES)", default: "ES" },
|
|
66
|
+
},
|
|
67
|
+
required: ["celex", "article"],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
// Server implementation
|
|
71
|
+
const server = new Server({
|
|
72
|
+
name: "boe-eurlex-mcp",
|
|
73
|
+
version: "1.0.0",
|
|
74
|
+
}, {
|
|
75
|
+
capabilities: {
|
|
76
|
+
tools: {},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
// List tools handler
|
|
80
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
81
|
+
return {
|
|
82
|
+
tools: [
|
|
83
|
+
BOE_SEARCH_TOOL,
|
|
84
|
+
BOE_GET_ARTICLE_TOOL,
|
|
85
|
+
BOE_GET_DAILY_SUMMARY_TOOL,
|
|
86
|
+
EURLEX_SEARCH_TOOL,
|
|
87
|
+
EURLEX_GET_ARTICLE_TOOL,
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
// Call tool handler
|
|
92
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
93
|
+
const { name, arguments: args } = request.params;
|
|
94
|
+
try {
|
|
95
|
+
switch (name) {
|
|
96
|
+
case "boe_search": {
|
|
97
|
+
const { q, limit = 5 } = args;
|
|
98
|
+
const results = await searchBoe(q, limit);
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
case "boe_get_article": {
|
|
104
|
+
const { id, article } = args;
|
|
105
|
+
const result = await getBoeArticle(id, article);
|
|
106
|
+
if (!result) {
|
|
107
|
+
return {
|
|
108
|
+
isError: true,
|
|
109
|
+
content: [{ type: "text", text: `No se encontró el artículo ${article} en la norma ${id}` }]
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Add Hash
|
|
113
|
+
result.hash_algo = "SHA-256";
|
|
114
|
+
result.hash_value = calculateHash(result.text);
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
case "boe_get_daily_summary": {
|
|
120
|
+
const { date } = args;
|
|
121
|
+
const result = await getDailySummary(date);
|
|
122
|
+
if (!result) {
|
|
123
|
+
return {
|
|
124
|
+
isError: true,
|
|
125
|
+
content: [{ type: "text", text: `No se encontró sumario para la fecha ${date}` }]
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
case "eurlex_search": {
|
|
133
|
+
const { q, limit = 5, lang = "ES" } = args;
|
|
134
|
+
const results = await searchEurlex(q, lang, limit);
|
|
135
|
+
return {
|
|
136
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
case "eurlex_get_article": {
|
|
140
|
+
const { celex, article, lang = "ES" } = args;
|
|
141
|
+
const result = await getEurlexArticle(celex, article, lang);
|
|
142
|
+
if (!result) {
|
|
143
|
+
return {
|
|
144
|
+
isError: true,
|
|
145
|
+
content: [{ type: "text", text: `No se encontró el artículo ${article} en la norma ${celex} (${lang})` }]
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Add Hash
|
|
149
|
+
result.hash_algo = "SHA-256";
|
|
150
|
+
result.hash_value = calculateHash(result.text);
|
|
151
|
+
return {
|
|
152
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
throw new Error(`Tool unknown: ${name}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
return {
|
|
161
|
+
isError: true,
|
|
162
|
+
content: [{ type: "text", text: `Error executing tool ${name}: ${error.message}` }]
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
// Start server
|
|
167
|
+
async function runServer() {
|
|
168
|
+
const transport = new StdioServerTransport();
|
|
169
|
+
await server.connect(transport);
|
|
170
|
+
console.error("BOE & EUR-Lex MCP Server running on stdio");
|
|
171
|
+
}
|
|
172
|
+
runServer().catch((error) => {
|
|
173
|
+
console.error("Fatal error running server:", error);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "boe-eurlex-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP Server que permite a agentes de IA consultar directamente legislación oficial española (BOE) y europea (EUR-Lex)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"boe-eurlex-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"test": "npx tsx tests/manual_test.ts"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mcp",
|
|
17
|
+
"boe",
|
|
18
|
+
"eur-lex",
|
|
19
|
+
"legislation",
|
|
20
|
+
"ai",
|
|
21
|
+
"agent"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.0.1",
|
|
27
|
+
"axios": "^1.6.0",
|
|
28
|
+
"cheerio": "^1.0.0-rc.12",
|
|
29
|
+
"zod": "^3.22.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"ts-node": "^10.9.2",
|
|
34
|
+
"tsx": "^4.7.0",
|
|
35
|
+
"typescript": "^5.3.0"
|
|
36
|
+
}
|
|
37
|
+
}
|