cjeu-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/README.md +61 -0
- package/dist/celex.d.ts +9 -0
- package/dist/celex.js +38 -0
- package/dist/cellar.d.ts +21 -0
- package/dist/cellar.js +137 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +217 -0
- package/dist/sparql.d.ts +17 -0
- package/dist/sparql.js +192 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# cjeu-mcp
|
|
2
|
+
|
|
3
|
+
MCP server that gives Claude access to EU Court of Justice case law. Search cases by number or keyword, read full judgments, AG opinions, and orders directly in Claude.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g cjeu-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configure for Claude Desktop
|
|
12
|
+
|
|
13
|
+
Add to your `claude_desktop_config.json`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"cjeu": {
|
|
19
|
+
"command": "npx",
|
|
20
|
+
"args": ["-y", "cjeu-mcp"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Available Tools
|
|
27
|
+
|
|
28
|
+
### `search_case`
|
|
29
|
+
|
|
30
|
+
Search for all documents related to a CJEU case number.
|
|
31
|
+
|
|
32
|
+
**Example prompts:**
|
|
33
|
+
- "Find all documents for case C-131/12"
|
|
34
|
+
- "What documents are available for C-362/14?"
|
|
35
|
+
- "Download links for all documents in T-750/21"
|
|
36
|
+
|
|
37
|
+
### `get_document`
|
|
38
|
+
|
|
39
|
+
Get a CJEU document by CELEX number — either as readable text (for analysis) or as PDF download URLs.
|
|
40
|
+
|
|
41
|
+
**Example prompts:**
|
|
42
|
+
- "Read the judgment in case C-131/12" (use `search_case` first to find the CELEX number, then `get_document`)
|
|
43
|
+
- "Read the AG opinion in case C-362/14 and summarize the key arguments"
|
|
44
|
+
- "Compare the judgment and AG opinion in C-311/18 — did the Court follow the AG?"
|
|
45
|
+
|
|
46
|
+
### `search_by_keyword`
|
|
47
|
+
|
|
48
|
+
Search CJEU case law by keyword, with optional year and court filters.
|
|
49
|
+
|
|
50
|
+
**Example prompts:**
|
|
51
|
+
- "Search for CJEU cases about data protection from 2020"
|
|
52
|
+
- "Find General Court cases about state aid"
|
|
53
|
+
- "Search for recent cases about Article 101 TFEU"
|
|
54
|
+
|
|
55
|
+
## Data Source
|
|
56
|
+
|
|
57
|
+
All data comes from the [EU Cellar](https://publications.europa.eu/) SPARQL endpoint and REST API — the official open data repository of the EU Publications Office. No authentication required.
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
package/dist/celex.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Case number normalization and CELEX conversion — ported from app.py */
|
|
2
|
+
export interface ParsedCase {
|
|
3
|
+
prefix: string;
|
|
4
|
+
numberPadded: string;
|
|
5
|
+
fullYear: number;
|
|
6
|
+
formatted: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseCaseNumber(raw: string): ParsedCase | null;
|
|
9
|
+
export declare function typeUriToLabel(uri: string): string;
|
package/dist/celex.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Case number normalization and CELEX conversion — ported from app.py */
|
|
2
|
+
const TYPE_LABELS = {
|
|
3
|
+
JUDG: "Judgment",
|
|
4
|
+
ORDER: "Order",
|
|
5
|
+
OPIN_AG: "Opinion of Advocate General",
|
|
6
|
+
VIEW_AG: "View of Advocate General",
|
|
7
|
+
};
|
|
8
|
+
export function parseCaseNumber(raw) {
|
|
9
|
+
let s = raw.trim();
|
|
10
|
+
// Normalize dashes (en-dash, em-dash, non-breaking hyphen)
|
|
11
|
+
s = s.replace(/\u2011/g, "-").replace(/\u2013/g, "-").replace(/\u2014/g, "-");
|
|
12
|
+
// Remove leading "Case"
|
|
13
|
+
s = s.replace(/^case\s+/i, "");
|
|
14
|
+
const m = s.match(/^([CT])[\s-]*(\d+)\s*\/\s*(\d+)$/i);
|
|
15
|
+
if (!m)
|
|
16
|
+
return null;
|
|
17
|
+
const prefix = m[1].toUpperCase();
|
|
18
|
+
const number = parseInt(m[2], 10);
|
|
19
|
+
const yearShort = m[3];
|
|
20
|
+
let fullYear;
|
|
21
|
+
if (yearShort.length === 4) {
|
|
22
|
+
fullYear = parseInt(yearShort, 10);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const y = parseInt(yearShort, 10);
|
|
26
|
+
fullYear = y >= 54 ? 1900 + y : 2000 + y;
|
|
27
|
+
}
|
|
28
|
+
const numberPadded = String(number).padStart(4, "0");
|
|
29
|
+
const formatted = `${prefix}-${number}/${yearShort}`;
|
|
30
|
+
return { prefix, numberPadded, fullYear, formatted };
|
|
31
|
+
}
|
|
32
|
+
export function typeUriToLabel(uri) {
|
|
33
|
+
for (const [key, label] of Object.entries(TYPE_LABELS)) {
|
|
34
|
+
if (uri.endsWith("/" + key))
|
|
35
|
+
return label;
|
|
36
|
+
}
|
|
37
|
+
return uri.split("/").pop() ?? uri;
|
|
38
|
+
}
|
package/dist/cellar.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Cellar REST API download + content negotiation — ported from app.py */
|
|
2
|
+
/**
|
|
3
|
+
* Fetch the HTML version of a document and extract clean legal text.
|
|
4
|
+
* Strategy (matches app.py):
|
|
5
|
+
* 1. Try Cellar HTML manifestation URL (if known)
|
|
6
|
+
* 2. Try Cellar content negotiation: GET celex URI with Accept: xhtml
|
|
7
|
+
* 3. Try EUR-Lex HTML
|
|
8
|
+
*/
|
|
9
|
+
export declare function fetchDocumentText(celex: string, htmlManifUrl?: string): Promise<string | null>;
|
|
10
|
+
/**
|
|
11
|
+
* Get PDF download URLs for a document.
|
|
12
|
+
*/
|
|
13
|
+
export declare function getPdfUrls(celex: string, pdfManifUrl?: string): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Look up manifestation URLs for a CELEX by querying SPARQL.
|
|
16
|
+
* Returns { pdfUrl, htmlUrl } if found.
|
|
17
|
+
*/
|
|
18
|
+
export declare function lookupManifestations(celex: string): Promise<{
|
|
19
|
+
pdfUrl: string;
|
|
20
|
+
htmlUrl: string;
|
|
21
|
+
}>;
|
package/dist/cellar.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** Cellar REST API download + content negotiation — ported from app.py */
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
const FETCH_TIMEOUT = 30_000;
|
|
4
|
+
function log(msg) {
|
|
5
|
+
process.stderr.write(`[cjeu-mcp] ${msg}\n`);
|
|
6
|
+
}
|
|
7
|
+
async function fetchWithTimeout(url, init = {}) {
|
|
8
|
+
return fetch(url, {
|
|
9
|
+
...init,
|
|
10
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
|
11
|
+
redirect: "follow",
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Fetch the HTML version of a document and extract clean legal text.
|
|
16
|
+
* Strategy (matches app.py):
|
|
17
|
+
* 1. Try Cellar HTML manifestation URL (if known)
|
|
18
|
+
* 2. Try Cellar content negotiation: GET celex URI with Accept: xhtml
|
|
19
|
+
* 3. Try EUR-Lex HTML
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchDocumentText(celex, htmlManifUrl) {
|
|
22
|
+
const urls = [];
|
|
23
|
+
if (htmlManifUrl)
|
|
24
|
+
urls.push(htmlManifUrl);
|
|
25
|
+
// Cellar content negotiation is unreliable for celex URIs (often 404),
|
|
26
|
+
// but the manifestation URL works, so we skip celex negotiation for HTML.
|
|
27
|
+
urls.push(`https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:${celex}`);
|
|
28
|
+
for (const url of urls) {
|
|
29
|
+
try {
|
|
30
|
+
const resp = await fetchWithTimeout(url, {
|
|
31
|
+
headers: {
|
|
32
|
+
Accept: "application/xhtml+xml, text/html",
|
|
33
|
+
"Accept-Language": "eng",
|
|
34
|
+
"User-Agent": "cjeu-mcp/1.0",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
if (!resp.ok)
|
|
38
|
+
continue;
|
|
39
|
+
const ct = resp.headers.get("content-type") ?? "";
|
|
40
|
+
if (!ct.includes("html") && !ct.includes("xhtml"))
|
|
41
|
+
continue;
|
|
42
|
+
const html = await resp.text();
|
|
43
|
+
if (!html.trim())
|
|
44
|
+
continue;
|
|
45
|
+
// EUR-Lex WAF returns 202 with JS challenge
|
|
46
|
+
if (resp.status === 202 || html.includes("awsWafCookie"))
|
|
47
|
+
continue;
|
|
48
|
+
return extractLegalText(html);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
log(`fetchDocumentText failed for ${url}: ${e}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get PDF download URLs for a document.
|
|
58
|
+
*/
|
|
59
|
+
export function getPdfUrls(celex, pdfManifUrl) {
|
|
60
|
+
const urls = [];
|
|
61
|
+
if (pdfManifUrl)
|
|
62
|
+
urls.push(pdfManifUrl);
|
|
63
|
+
urls.push(`https://eur-lex.europa.eu/legal-content/EN/TXT/PDF/?uri=CELEX:${celex}`);
|
|
64
|
+
return urls;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Look up manifestation URLs for a CELEX by querying SPARQL.
|
|
68
|
+
* Returns { pdfUrl, htmlUrl } if found.
|
|
69
|
+
*/
|
|
70
|
+
export async function lookupManifestations(celex) {
|
|
71
|
+
const query = `
|
|
72
|
+
PREFIX cdm: <http://publications.europa.eu/ontology/cdm#>
|
|
73
|
+
SELECT ?pdfManif ?htmlManif WHERE {
|
|
74
|
+
?work cdm:resource_legal_id_celex ?celex .
|
|
75
|
+
FILTER(STR(?celex) = "${celex}")
|
|
76
|
+
?expression cdm:expression_belongs_to_work ?work .
|
|
77
|
+
?expression cdm:expression_uses_language <http://publications.europa.eu/resource/authority/language/ENG> .
|
|
78
|
+
OPTIONAL {
|
|
79
|
+
?pdfManif cdm:manifestation_manifests_expression ?expression .
|
|
80
|
+
?pdfManif cdm:manifestation_type ?pmtype .
|
|
81
|
+
FILTER(CONTAINS(STR(?pmtype), "pdf"))
|
|
82
|
+
}
|
|
83
|
+
OPTIONAL {
|
|
84
|
+
?htmlManif cdm:manifestation_manifests_expression ?expression .
|
|
85
|
+
?htmlManif cdm:manifestation_type ?hmtype .
|
|
86
|
+
FILTER(STR(?hmtype) = "xhtml" || STR(?hmtype) = "html")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
LIMIT 1`;
|
|
90
|
+
const url = new URL("https://publications.europa.eu/webapi/rdf/sparql");
|
|
91
|
+
url.searchParams.set("query", query);
|
|
92
|
+
try {
|
|
93
|
+
const resp = await fetchWithTimeout(url.toString(), {
|
|
94
|
+
headers: {
|
|
95
|
+
Accept: "application/sparql-results+json",
|
|
96
|
+
"User-Agent": "cjeu-mcp/1.0",
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
if (!resp.ok)
|
|
100
|
+
return { pdfUrl: "", htmlUrl: "" };
|
|
101
|
+
const data = await resp.json();
|
|
102
|
+
const row = data.results.bindings[0];
|
|
103
|
+
if (!row)
|
|
104
|
+
return { pdfUrl: "", htmlUrl: "" };
|
|
105
|
+
const pdf = row.pdfManif?.value ?? "";
|
|
106
|
+
const html = row.htmlManif?.value ?? "";
|
|
107
|
+
return {
|
|
108
|
+
pdfUrl: pdf ? pdf + "/DOC_1" : "",
|
|
109
|
+
htmlUrl: html ? html + "/DOC_1" : "",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { pdfUrl: "", htmlUrl: "" };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Extract clean legal text from an HTML document using cheerio.
|
|
118
|
+
* Strips navigation, headers, footers, metadata — keeps only the legal content.
|
|
119
|
+
*/
|
|
120
|
+
function extractLegalText(html) {
|
|
121
|
+
const $ = cheerio.load(html);
|
|
122
|
+
// Remove non-content elements
|
|
123
|
+
$("head, script, style, link, meta, nav, header, footer").remove();
|
|
124
|
+
$('[class*="banner"], [class*="cookie"], [id*="nav"], [id*="header"], [id*="footer"]').remove();
|
|
125
|
+
$('[class*="breadcrumb"], [class*="sidebar"], [class*="menu"]').remove();
|
|
126
|
+
// Get text from body
|
|
127
|
+
let text = $("body").text();
|
|
128
|
+
// Clean up whitespace: collapse multiple newlines/spaces
|
|
129
|
+
text = text
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((line) => line.trim())
|
|
132
|
+
.filter((line) => line.length > 0)
|
|
133
|
+
.join("\n");
|
|
134
|
+
// Collapse runs of 3+ newlines to 2
|
|
135
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
136
|
+
return text.trim();
|
|
137
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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 { z } from "zod";
|
|
5
|
+
import { parseCaseNumber } from "./celex.js";
|
|
6
|
+
import { queryCellar, queryFirstInstance, searchByKeyword, } from "./sparql.js";
|
|
7
|
+
import { fetchDocumentText, getPdfUrls, lookupManifestations, } from "./cellar.js";
|
|
8
|
+
function log(msg) {
|
|
9
|
+
process.stderr.write(`[cjeu-mcp] ${msg}\n`);
|
|
10
|
+
}
|
|
11
|
+
const server = new McpServer({
|
|
12
|
+
name: "cjeu-mcp",
|
|
13
|
+
version: "1.0.0",
|
|
14
|
+
});
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Tool 1: search_case
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
server.tool("search_case", "Search for all documents (judgments, orders, AG opinions) related to a CJEU case number", { caseNumber: z.string().describe("CJEU case number, e.g. C-131/12, T-750/21") }, async ({ caseNumber }) => {
|
|
19
|
+
const parsed = parseCaseNumber(caseNumber);
|
|
20
|
+
if (!parsed) {
|
|
21
|
+
return {
|
|
22
|
+
content: [
|
|
23
|
+
{
|
|
24
|
+
type: "text",
|
|
25
|
+
text: `Could not parse case number: "${caseNumber}". Use format like C-131/12 or T-29/10.`,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
isError: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const { prefix, numberPadded, fullYear, formatted } = parsed;
|
|
32
|
+
const yearStr = String(fullYear);
|
|
33
|
+
log(`Searching for case ${formatted} (CELEX pattern: 6${yearStr}*${numberPadded})`);
|
|
34
|
+
try {
|
|
35
|
+
const docs = await queryCellar(yearStr, numberPadded);
|
|
36
|
+
// For C- cases, look for linked first-instance T- case
|
|
37
|
+
let firstInstanceCase = null;
|
|
38
|
+
if (prefix === "C") {
|
|
39
|
+
try {
|
|
40
|
+
const fiCelexNumbers = await queryFirstInstance(yearStr, numberPadded);
|
|
41
|
+
if (fiCelexNumbers.length > 0) {
|
|
42
|
+
// Extract case number from CELEX (e.g. 62012TJ0131 -> T-131/12)
|
|
43
|
+
firstInstanceCase = fiCelexNumbers.join(", ");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
log(`First instance query failed: ${e}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (docs.length === 0) {
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "text",
|
|
55
|
+
text: `No documents found for case ${formatted}.`,
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const result = {
|
|
61
|
+
caseNumber: formatted,
|
|
62
|
+
source: "EU Cellar SPARQL",
|
|
63
|
+
documents: docs.map((d) => ({
|
|
64
|
+
type: d.typeLabel,
|
|
65
|
+
date: d.date,
|
|
66
|
+
celex: d.celex,
|
|
67
|
+
title: d.title,
|
|
68
|
+
pdfAvailable: d.pdfAvailable,
|
|
69
|
+
htmlAvailable: d.htmlAvailable,
|
|
70
|
+
})),
|
|
71
|
+
firstInstanceCase,
|
|
72
|
+
total: docs.length,
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
log(`search_case error: ${e}`);
|
|
80
|
+
return {
|
|
81
|
+
content: [
|
|
82
|
+
{ type: "text", text: `Error searching for case ${formatted}: ${e}` },
|
|
83
|
+
],
|
|
84
|
+
isError: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Tool 2: get_document
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
server.tool("get_document", "Get a CJEU document by CELEX number — either as readable text (for analysis) or as PDF download URLs", {
|
|
92
|
+
celex: z.string().describe("CELEX number of the document, e.g. 62012CJ0131"),
|
|
93
|
+
format: z
|
|
94
|
+
.enum(["text", "pdf_url"])
|
|
95
|
+
.default("text")
|
|
96
|
+
.describe("'text' returns the full legal text; 'pdf_url' returns download links"),
|
|
97
|
+
}, async ({ celex, format }) => {
|
|
98
|
+
log(`get_document: ${celex} format=${format}`);
|
|
99
|
+
if (format === "pdf_url") {
|
|
100
|
+
// Look up manifestation URLs
|
|
101
|
+
const { pdfUrl } = await lookupManifestations(celex);
|
|
102
|
+
const urls = getPdfUrls(celex, pdfUrl || undefined);
|
|
103
|
+
const result = {
|
|
104
|
+
celex,
|
|
105
|
+
pdfUrls: urls,
|
|
106
|
+
note: "The first URL (Cellar manifestation) is the most reliable. EUR-Lex may require a browser due to WAF protection.",
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// format === "text"
|
|
113
|
+
try {
|
|
114
|
+
// Look up HTML manifestation URL for this CELEX
|
|
115
|
+
const { htmlUrl } = await lookupManifestations(celex);
|
|
116
|
+
const text = await fetchDocumentText(celex, htmlUrl || undefined);
|
|
117
|
+
if (!text) {
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: "text",
|
|
122
|
+
text: `Could not retrieve text for CELEX ${celex}. The document may not be available electronically. Try format="pdf_url" for download links.`,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
isError: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// Truncate if extremely long (>100k chars) to avoid overwhelming context
|
|
129
|
+
const maxLen = 100_000;
|
|
130
|
+
const truncated = text.length > maxLen;
|
|
131
|
+
const output = truncated ? text.slice(0, maxLen) + "\n\n[... truncated, document too long ...]" : text;
|
|
132
|
+
return {
|
|
133
|
+
content: [
|
|
134
|
+
{
|
|
135
|
+
type: "text",
|
|
136
|
+
text: `CELEX: ${celex}\n\n${output}`,
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
log(`get_document error: ${e}`);
|
|
143
|
+
return {
|
|
144
|
+
content: [
|
|
145
|
+
{ type: "text", text: `Error retrieving document ${celex}: ${e}` },
|
|
146
|
+
],
|
|
147
|
+
isError: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Tool 3: search_by_keyword
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
server.tool("search_by_keyword", "Search CJEU case law by keyword in title/subject matter, with optional year and court filters", {
|
|
155
|
+
query: z.string().describe("Search keywords, e.g. 'data protection', 'state aid'"),
|
|
156
|
+
year: z.number().optional().describe("Optional year filter, e.g. 2020"),
|
|
157
|
+
court: z
|
|
158
|
+
.enum(["CJ", "GC", "both"])
|
|
159
|
+
.default("both")
|
|
160
|
+
.describe("CJ = Court of Justice, GC = General Court, both = all courts"),
|
|
161
|
+
}, async ({ query, year, court }) => {
|
|
162
|
+
log(`search_by_keyword: "${query}" year=${year ?? "any"} court=${court}`);
|
|
163
|
+
try {
|
|
164
|
+
const docs = await searchByKeyword(query, year, court);
|
|
165
|
+
if (docs.length === 0) {
|
|
166
|
+
return {
|
|
167
|
+
content: [
|
|
168
|
+
{
|
|
169
|
+
type: "text",
|
|
170
|
+
text: `No cases found matching "${query}"${year ? ` in ${year}` : ""}.`,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const result = {
|
|
176
|
+
query,
|
|
177
|
+
year: year ?? null,
|
|
178
|
+
court,
|
|
179
|
+
results: docs.map((d) => ({
|
|
180
|
+
type: d.typeLabel,
|
|
181
|
+
date: d.date,
|
|
182
|
+
celex: d.celex,
|
|
183
|
+
title: d.title,
|
|
184
|
+
pdfAvailable: d.pdfAvailable,
|
|
185
|
+
htmlAvailable: d.htmlAvailable,
|
|
186
|
+
})),
|
|
187
|
+
total: docs.length,
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
log(`search_by_keyword error: ${e}`);
|
|
195
|
+
return {
|
|
196
|
+
content: [
|
|
197
|
+
{
|
|
198
|
+
type: "text",
|
|
199
|
+
text: `Error searching for "${query}": ${e}`,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
isError: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Start server
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
async function main() {
|
|
210
|
+
const transport = new StdioServerTransport();
|
|
211
|
+
await server.connect(transport);
|
|
212
|
+
log("CJEU MCP server started");
|
|
213
|
+
}
|
|
214
|
+
main().catch((e) => {
|
|
215
|
+
process.stderr.write(`Fatal error: ${e}\n`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
});
|
package/dist/sparql.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** SPARQL query logic — ported from app.py */
|
|
2
|
+
export interface CellarDocument {
|
|
3
|
+
work: string;
|
|
4
|
+
typeLabel: string;
|
|
5
|
+
title: string;
|
|
6
|
+
date: string;
|
|
7
|
+
celex: string;
|
|
8
|
+
expression: string;
|
|
9
|
+
pdfUrl: string;
|
|
10
|
+
htmlUrl: string;
|
|
11
|
+
pdfAvailable: boolean;
|
|
12
|
+
htmlAvailable: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function queryCellar(yearStr: string, numberPadded: string): Promise<CellarDocument[]>;
|
|
15
|
+
export declare function queryCellarByCelex(celex: string): Promise<CellarDocument[]>;
|
|
16
|
+
export declare function queryFirstInstance(yearStr: string, numberPadded: string): Promise<string[]>;
|
|
17
|
+
export declare function searchByKeyword(keywords: string, year?: number, court?: string): Promise<CellarDocument[]>;
|
package/dist/sparql.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/** SPARQL query logic — ported from app.py */
|
|
2
|
+
import { typeUriToLabel } from "./celex.js";
|
|
3
|
+
const SPARQL_ENDPOINT = "https://publications.europa.eu/webapi/rdf/sparql";
|
|
4
|
+
function val(row, key) {
|
|
5
|
+
return row[key]?.value ?? "";
|
|
6
|
+
}
|
|
7
|
+
async function runSparql(query) {
|
|
8
|
+
const url = new URL(SPARQL_ENDPOINT);
|
|
9
|
+
url.searchParams.set("query", query);
|
|
10
|
+
const resp = await fetch(url.toString(), {
|
|
11
|
+
headers: {
|
|
12
|
+
Accept: "application/sparql-results+json",
|
|
13
|
+
"User-Agent": "cjeu-mcp/1.0",
|
|
14
|
+
},
|
|
15
|
+
signal: AbortSignal.timeout(60_000),
|
|
16
|
+
});
|
|
17
|
+
if (!resp.ok) {
|
|
18
|
+
throw new Error(`SPARQL query failed: ${resp.status} ${resp.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
const data = await resp.json();
|
|
21
|
+
return data.results.bindings;
|
|
22
|
+
}
|
|
23
|
+
function rowsToDocs(rows) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const docs = [];
|
|
26
|
+
for (const row of rows) {
|
|
27
|
+
const celex = val(row, "celex");
|
|
28
|
+
if (!celex || seen.has(celex))
|
|
29
|
+
continue;
|
|
30
|
+
seen.add(celex);
|
|
31
|
+
const pdfManif = val(row, "pdfManif");
|
|
32
|
+
const htmlManif = val(row, "htmlManif");
|
|
33
|
+
docs.push({
|
|
34
|
+
work: val(row, "work"),
|
|
35
|
+
typeLabel: typeUriToLabel(val(row, "type")),
|
|
36
|
+
title: val(row, "title"),
|
|
37
|
+
date: val(row, "date"),
|
|
38
|
+
celex,
|
|
39
|
+
expression: val(row, "expression"),
|
|
40
|
+
pdfUrl: pdfManif ? pdfManif + "/DOC_1" : "",
|
|
41
|
+
htmlUrl: htmlManif ? htmlManif + "/DOC_1" : "",
|
|
42
|
+
pdfAvailable: !!pdfManif,
|
|
43
|
+
htmlAvailable: !!htmlManif,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return docs;
|
|
47
|
+
}
|
|
48
|
+
// Key Cellar ontology notes (discovered empirically):
|
|
49
|
+
// - expression_belongs_to_work (NOT work_has_expression)
|
|
50
|
+
// - manifestation_manifests_expression (NOT expression_has_manifestation)
|
|
51
|
+
// - CONTAINS(STR(...)) required because values are xsd:string
|
|
52
|
+
// - STR(?hmtype) = "html" required for same reason
|
|
53
|
+
export async function queryCellar(yearStr, numberPadded) {
|
|
54
|
+
const query = `
|
|
55
|
+
PREFIX cdm: <http://publications.europa.eu/ontology/cdm#>
|
|
56
|
+
|
|
57
|
+
SELECT DISTINCT ?work ?type ?title ?date ?celex ?expression ?pdfManif ?htmlManif
|
|
58
|
+
WHERE {
|
|
59
|
+
?work cdm:resource_legal_id_celex ?celex .
|
|
60
|
+
?work cdm:work_has_resource-type ?type .
|
|
61
|
+
?work cdm:work_date_document ?date .
|
|
62
|
+
|
|
63
|
+
FILTER(CONTAINS(STR(?celex), "${yearStr}") && CONTAINS(STR(?celex), "${numberPadded}"))
|
|
64
|
+
|
|
65
|
+
FILTER(?type IN (
|
|
66
|
+
<http://publications.europa.eu/resource/authority/resource-type/JUDG>,
|
|
67
|
+
<http://publications.europa.eu/resource/authority/resource-type/ORDER>,
|
|
68
|
+
<http://publications.europa.eu/resource/authority/resource-type/OPIN_AG>,
|
|
69
|
+
<http://publications.europa.eu/resource/authority/resource-type/VIEW_AG>
|
|
70
|
+
))
|
|
71
|
+
|
|
72
|
+
?expression cdm:expression_belongs_to_work ?work .
|
|
73
|
+
?expression cdm:expression_uses_language <http://publications.europa.eu/resource/authority/language/ENG> .
|
|
74
|
+
|
|
75
|
+
OPTIONAL { ?expression cdm:expression_title ?title . }
|
|
76
|
+
OPTIONAL {
|
|
77
|
+
?pdfManif cdm:manifestation_manifests_expression ?expression .
|
|
78
|
+
?pdfManif cdm:manifestation_type ?pmtype .
|
|
79
|
+
FILTER(CONTAINS(STR(?pmtype), "pdf"))
|
|
80
|
+
}
|
|
81
|
+
OPTIONAL {
|
|
82
|
+
?htmlManif cdm:manifestation_manifests_expression ?expression .
|
|
83
|
+
?htmlManif cdm:manifestation_type ?hmtype .
|
|
84
|
+
FILTER(STR(?hmtype) = "xhtml" || STR(?hmtype) = "html")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ORDER BY ?type ?date`;
|
|
88
|
+
return rowsToDocs(await runSparql(query));
|
|
89
|
+
}
|
|
90
|
+
export async function queryCellarByCelex(celex) {
|
|
91
|
+
const query = `
|
|
92
|
+
PREFIX cdm: <http://publications.europa.eu/ontology/cdm#>
|
|
93
|
+
|
|
94
|
+
SELECT DISTINCT ?work ?type ?title ?date ?celex ?expression ?pdfManif ?htmlManif
|
|
95
|
+
WHERE {
|
|
96
|
+
?work cdm:resource_legal_id_celex ?celex .
|
|
97
|
+
FILTER(STR(?celex) = "${celex}")
|
|
98
|
+
?work cdm:work_has_resource-type ?type .
|
|
99
|
+
?work cdm:work_date_document ?date .
|
|
100
|
+
?expression cdm:expression_belongs_to_work ?work .
|
|
101
|
+
?expression cdm:expression_uses_language <http://publications.europa.eu/resource/authority/language/ENG> .
|
|
102
|
+
OPTIONAL { ?expression cdm:expression_title ?title . }
|
|
103
|
+
OPTIONAL {
|
|
104
|
+
?pdfManif cdm:manifestation_manifests_expression ?expression .
|
|
105
|
+
?pdfManif cdm:manifestation_type ?pmtype .
|
|
106
|
+
FILTER(CONTAINS(STR(?pmtype), "pdf"))
|
|
107
|
+
}
|
|
108
|
+
OPTIONAL {
|
|
109
|
+
?htmlManif cdm:manifestation_manifests_expression ?expression .
|
|
110
|
+
?htmlManif cdm:manifestation_type ?hmtype .
|
|
111
|
+
FILTER(STR(?hmtype) = "xhtml" || STR(?hmtype) = "html")
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
LIMIT 5`;
|
|
115
|
+
return rowsToDocs(await runSparql(query));
|
|
116
|
+
}
|
|
117
|
+
export async function queryFirstInstance(yearStr, numberPadded) {
|
|
118
|
+
const query = `
|
|
119
|
+
PREFIX cdm: <http://publications.europa.eu/ontology/cdm#>
|
|
120
|
+
|
|
121
|
+
SELECT DISTINCT ?fiCelex
|
|
122
|
+
WHERE {
|
|
123
|
+
?work cdm:resource_legal_id_celex ?celex .
|
|
124
|
+
FILTER(CONTAINS(STR(?celex), "${yearStr}") && CONTAINS(STR(?celex), "${numberPadded}"))
|
|
125
|
+
{ ?work cdm:case-law_confirms ?firstInstance }
|
|
126
|
+
UNION
|
|
127
|
+
{ ?work cdm:case-law_annuls ?firstInstance }
|
|
128
|
+
UNION
|
|
129
|
+
{ ?work cdm:case-law_confirms_partially ?firstInstance }
|
|
130
|
+
?firstInstance cdm:resource_legal_id_celex ?fiCelex .
|
|
131
|
+
}`;
|
|
132
|
+
const rows = await runSparql(query);
|
|
133
|
+
const celexSet = new Set();
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
const c = val(row, "fiCelex");
|
|
136
|
+
if (c)
|
|
137
|
+
celexSet.add(c);
|
|
138
|
+
}
|
|
139
|
+
return [...celexSet];
|
|
140
|
+
}
|
|
141
|
+
export async function searchByKeyword(keywords, year, court) {
|
|
142
|
+
const yearFilter = year
|
|
143
|
+
? `FILTER(CONTAINS(STR(?date), "${year}"))`
|
|
144
|
+
: "";
|
|
145
|
+
let courtFilter = "";
|
|
146
|
+
if (court === "CJ") {
|
|
147
|
+
courtFilter = `FILTER(CONTAINS(STR(?celex), "CJ") || CONTAINS(STR(?celex), "CC"))`;
|
|
148
|
+
}
|
|
149
|
+
else if (court === "GC") {
|
|
150
|
+
courtFilter = `FILTER(CONTAINS(STR(?celex), "TJ") || CONTAINS(STR(?celex), "TF"))`;
|
|
151
|
+
}
|
|
152
|
+
// Escape special SPARQL regex characters in keywords
|
|
153
|
+
const escaped = keywords.replace(/([.*+?^${}()|[\]\\])/g, "\\\\$1");
|
|
154
|
+
const query = `
|
|
155
|
+
PREFIX cdm: <http://publications.europa.eu/ontology/cdm#>
|
|
156
|
+
|
|
157
|
+
SELECT DISTINCT ?work ?type ?title ?date ?celex ?expression ?pdfManif ?htmlManif
|
|
158
|
+
WHERE {
|
|
159
|
+
?work cdm:resource_legal_id_celex ?celex .
|
|
160
|
+
?work cdm:work_has_resource-type ?type .
|
|
161
|
+
?work cdm:work_date_document ?date .
|
|
162
|
+
|
|
163
|
+
FILTER(?type IN (
|
|
164
|
+
<http://publications.europa.eu/resource/authority/resource-type/JUDG>,
|
|
165
|
+
<http://publications.europa.eu/resource/authority/resource-type/ORDER>,
|
|
166
|
+
<http://publications.europa.eu/resource/authority/resource-type/OPIN_AG>,
|
|
167
|
+
<http://publications.europa.eu/resource/authority/resource-type/VIEW_AG>
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
?expression cdm:expression_belongs_to_work ?work .
|
|
171
|
+
?expression cdm:expression_uses_language <http://publications.europa.eu/resource/authority/language/ENG> .
|
|
172
|
+
?expression cdm:expression_title ?title .
|
|
173
|
+
FILTER(REGEX(STR(?title), "${escaped}", "i"))
|
|
174
|
+
|
|
175
|
+
${yearFilter}
|
|
176
|
+
${courtFilter}
|
|
177
|
+
|
|
178
|
+
OPTIONAL {
|
|
179
|
+
?pdfManif cdm:manifestation_manifests_expression ?expression .
|
|
180
|
+
?pdfManif cdm:manifestation_type ?pmtype .
|
|
181
|
+
FILTER(CONTAINS(STR(?pmtype), "pdf"))
|
|
182
|
+
}
|
|
183
|
+
OPTIONAL {
|
|
184
|
+
?htmlManif cdm:manifestation_manifests_expression ?expression .
|
|
185
|
+
?htmlManif cdm:manifestation_type ?hmtype .
|
|
186
|
+
FILTER(STR(?hmtype) = "xhtml" || STR(?hmtype) = "html")
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
ORDER BY DESC(?date)
|
|
190
|
+
LIMIT 20`;
|
|
191
|
+
return rowsToDocs(await runSparql(query));
|
|
192
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cjeu-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for accessing EU Court of Justice case law — search cases, read judgments, AG opinions, and orders",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cjeu-mcp": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["mcp", "cjeu", "eu-law", "court-of-justice", "legal", "case-law"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": ["dist"],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
23
|
+
"cheerio": "^1.0.0",
|
|
24
|
+
"zod": "^3.24.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.7.0",
|
|
28
|
+
"@types/node": "^22.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|