German federal and Land statutes plus court decisions for agents. Keyless, read-only, CC BY 4.0.
eu.nulegal/recht — Model Context Protocol (MCP) Server
The MCP server eu.nulegal/recht provides German federal and Land statutes plus court decisions for agents. It is keyless and read-only, and its content is licensed under CC BY 4.0.
🛠️ Key Features
German federal and Land statutes
Court decisions for agents
Keyless access
Read-only data access
CC BY 4.0 licensing
🚀 Use Cases
Supplying legal references to agents
Grounding agent behavior in statutes and court decisions
Providing read-only access to German legal sources
⚡ Developer Benefits
Works without a key (keyless)
Safe usage model via read-only access
Clearly scoped to German federal and Land law and court decisions
⚠️ Limitations
Limited to German federal and Land statutes and court decisions
Read-only operation (no updates or writes)
Captured live from the server via tools/list.
resolveIdentifiers
Ground a batch of German legal citations against the corpus. Call this BEFORE stating any citation you did not read here.
Takes the citation in the form you already hold it — including the court name, the dispositive word and the date a model normally writes around a docket. KEEP THEM IN: the court and the date are used to disambiguate. An Aktenzeichen is unique per court, not nationwide, and 21,021 dockets in this corpus are held by more than one decision, so 'OLG Bamberg, 4 U 120/24' resolves to Bamberg's decision where the bare '4 U 120/24' is ambiguous or lands on another court's. Where the string has to be rewritten to be read, the rewrite is reported back under `normalised_from` / `normalised_to`, never silently, and `disambiguated_by` says when it was YOUR court or date that picked the decision out. Where the court you named writes a suffix your citation dropped ('4 U 120/24 e'), the answer carries `docket_completed` with the full Aktenzeichen — cite that one.
Accepted kinds: norm citations ('§ 823 Abs. 1 BGB', '§§ 305-310 BGB', 'Art. 83 DSGVO'), Aktenzeichen ('2 C 9.22', '8 AZR 26/18'), ECLI ('ECLI:DE:BGH:2019:180619UVIIIZR247.18.0') and Fundstellen ('BVerfGE 65, 1'). Full prose citations work: 'BVerwG, Urteil vom 24.10.2023 - 2 C 9.22'.
It never returns a near match. A miss comes back as `not_in_corpus` (we hold nothing and know of nothing), `attested` / `known_missing` (the decision provably EXISTS — decisions we do hold cite it by Aktenzeichen, and they are listed as the evidence — but we do not have its text), `ambiguous` (with candidates) or `unparseable`. `attested` is not a failure: you may state that the decision exists, cite it, and say the text was not available to you. What you must not do is treat it as `not_in_corpus`.
A resolved norm carries `fundstelle`: the gazette citation of the authentic text, which is the citation a court accepts. Our own URL is a reading copy, and for Land law the gazette citation is the only source reference there is. Prefer it in anything you publish.
`text` on a resolved norm is a 300-character stub unless you pass `include: ["text"]`, and `text_truncated` says which it is. Never verify a quotation against the stub: it is the head of the provision, not the Absatz you cited.
When you supply a date or a court that does not match the decision the docket resolves to, the result carries `date_mismatch` / `court_mismatch` with the actual value. That is the hallucinated-citation case this tool exists for: cite what is actually there, not what you held — and a `court_mismatch` usually means this is not the decision you meant at all.
Parameters2
citations
array
required
The citations, verbatim as you hold them.
include
array
optional
Opt-in extra payload. 'text' returns a norm's FULL text instead of the 300-character stub — the stub is the same 300 characters whichever Absatz you cited, so never verify a quotation against it. 'leitsatz' returns a decision's whole Leitsatz instead of its preview. An unknown value is refused, not ignored.
Raw schema
{
"type": "object",
"properties": {
"citations": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "string",
"maxLength": 400
},
"description": "The citations, verbatim as you hold them.",
"examples": [
[
"§ 622 Abs. 2 BGB",
"BVerwG, Urteil vom 24.10.2023 - 2 C 9.22",
"BVerfGE 65, 1",
"Art. 83 DSGVO"
]
]
},
"include": {
"type": "array",
"items": {
"type": "string",
"enum": [
"text",
"leitsatz"
]
},
"description": "Opt-in extra payload. 'text' returns a norm's FULL text instead of the 300-character stub — the stub is the same 300 characters whichever Absatz you cited, so never verify a quotation against it. 'leitsatz' returns a decision's whole Leitsatz instead of its preview. An unknown value is refused, not ignored."
}
},
"required": [
"citations"
]
}
search
One query over BOTH corpora: federal and Land statutes (lexical, with concept pinning) and court decisions (semantic — natural-language questions work well here and are the better shape for case law).
Search both unless you have a reason not to. A term of art often does not appear in the statute that governs it: 'Verzugspauschale' matches no provision (§ 288 BGB says 'Pauschale in Höhe von 40 Euro') while 184 decisions use the word. scope='norms' alone will read as 'nothing here' in exactly those cases.
CROSS-LAND COMPARISON: a single query returns the parallel provisions of the Bund and of every covered Land side by side, each row jurisdiction-labelled, plus a `by_jurisdiction` roll-up. Ask 'Videoüberwachung öffentlich zugänglicher Räume' and you get BDSG § 4 next to the Land data-protection and police provisions. Full text is held for Bayern, Brandenburg, Nordrhein-Westfalen and Sachsen.
Decision hits come back already anchored at the best-matching Randnummer (…#rd_51), so you can quote a paragraph rather than a document, AND carry `doknr` — the key `listCasePassages`, `listCitedAuthorities` and `listCitingDecisions` take, so you can go straight from a search hit to that decision's passages or its authorities without resolving anything first. Query in German; write raw umlauts, they are handled.
Parameters4
q
string
required
German query. Keywords, a citation, or a full question.
scope
string
optional
'all' (default) searches both. Narrow only when you know which corpus answers.
limit
integer
optional
include_repealed
boolean
optional
Include repealed (aufgehobene) provisions. Off by default; turn it on when researching an older state of the law.
Raw schema
{
"type": "object",
"properties": {
"q": {
"type": "string",
"minLength": 2,
"maxLength": 2000,
"description": "German query. Keywords, a citation, or a full question.",
"examples": [
"Kündigungsfrist Arbeitsverhältnis 10 Jahre",
"Videoüberwachung öffentlich zugänglicher Räume",
"Welche Rechte hat ein Arbeitnehmer bei verspäteter Lohnzahlung"
]
},
"scope": {
"type": "string",
"enum": [
"all",
"norms",
"cases"
],
"default": "all",
"description": "'all' (default) searches both. Narrow only when you know which corpus answers."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 10
},
"include_repealed": {
"type": "boolean",
"default": false,
"description": "Include repealed (aufgehobene) provisions. Off by default; turn it on when researching an older state of the law."
}
},
"required": [
"q"
]
}
getNorm
The text of one provision, by default as clean Markdown — about a tenth the size of the reader page for the same provision, with no navigation, no scripts and no boilerplate.
`law` is the abbreviation as a citation writes it ('BGB', 'DSGVO', 'BDSG 2018', 'RVG'); `ref` is the bare number, with any letter suffix and no § or Art. ('622', '823', '3a', '83'). Aliases resolve, and CASE IS READ: 'LwG' is the federal Landwirtschaftsgesetz while 'LWG' is a Land statute (Bayern's Landeswahlgesetz, NRW's Landeswassergesetz), so write the abbreviation the way your citation writes it. A spelling that matches no law exactly still resolves case-insensitively, and a miss lists the other laws the abbreviation names under `other_laws`, each with a `law_key` you can call again with.
POINT IN TIME: `as_of=YYYY-MM-DD` returns the version stored for that date. Read `version_coverage` on every answer — the version archive begins 2019-06-10, and a date before that answers `outside_coverage` with the law's amendment register attached. That is a limit of our archive and says nothing about whether the provision existed.
Every answer carries `first_observed`, `valid_to`, `date_precision` and `amendment_note`. `first_observed` is the day we first saw the text, NOT the legal Inkrafttreten — do not compute a deadline from it without reading `date_precision` (day / week / launch; 'launch' means the date is a floor).
TRUST: `fundstelle` is the gazette citation of the authentic text — the citation a court accepts. `authoritative_source` names what our copy is (a consolidated, non-official reading version) and where the binding text lives. Quote the provision from `markdown`; the reader page at `url` carries per-Absatz anchors (#abs-N) if you want to deep-link a single Absatz.
Parameters4
law
string
required
Law abbreviation, or a `law_key` (`slug` in search results) when an abbreviation is ambiguous.
ref
string
required
Provision number without § or Art. A sub-unit ('Abs. 1', 'lit. f') is dropped: the whole provision is returned.
as_of
string
optional
Return the version stored for this date.
format
string
optional
'markdown' (default, compact, quotable) or 'json' for the structured payload.
Raw schema
{
"type": "object",
"properties": {
"law": {
"type": "string",
"description": "Law abbreviation, or a `law_key` (`slug` in search results) when an abbreviation is ambiguous.",
"examples": [
"BGB",
"DSGVO",
"RVG",
"BDSG 2018",
"SächsDSDG"
]
},
"ref": {
"type": "string",
"description": "Provision number without § or Art. A sub-unit ('Abs. 1', 'lit. f') is dropped: the whole provision is returned.",
"examples": [
"622",
"823",
"3a",
"83",
"28"
]
},
"as_of": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"description": "Return the version stored for this date."
},
"format": {
"type": "string",
"enum": [
"markdown",
"json"
],
"default": "markdown",
"description": "'markdown' (default, compact, quotable) or 'json' for the structured payload."
}
},
"required": [
"law",
"ref"
]
}
listNormVersions
Every stored version of one provision, newest first, so you can find out which dates `getNorm(as_of=…)` can actually answer before you ask.
Each entry carries `first_observed` (the day the text was first seen here — NOT the Inkrafttreten), `valid_to`, `date_precision` and the law-level `amendment_note`. `at_archive_floor: true` marks the version that was current when mirroring began: its date is a floor, not an amendment, and earlier amendments exist that are named in the law's Änderungsverlauf (linked as `amendment_history_url`) but whose text is not held.
There is no diff tool: fetch two versions with `getNorm(as_of=…)` and diff them yourself — a diff we computed would hide which side of it came from a floor date.
Incoming citation edges. Give EITHER `law` + `ref` (which decisions apply this statute provision) OR `case` (which decisions cite this decision) — exactly one of the two.
Results are ranked by citation weight, then court tier, then recency. Read the ranking honestly: for a provision with many EU decisions the first ten can be almost all CJEU, and the German courts appear only further down. If `total` exceeds what you read, page on with `offset` (`pagination.next_offset`) before concluding anything about national case law.
NEWEST FIRST: pass `sort: "recent"` when the question is about current case law ('fünf aktuelle Entscheidungen zu …'). Paging works the same way. This ordering is bounded — for a handful of procedural giants (§ 154 VwGO, § 708 ZPO) it cannot be computed inside the query budget, and then the answer comes back in WEIGHT order and says so in `sort_applied` and `sort_note`. Check `sort_applied` before you describe a list as the most recent decisions.
For a decision, each citer carries `citing_rn`: the Randnummer of the CITING decision's own text that holds the citation, as that court numbered it, and the URL is anchored to it.
COVERAGE: the graph is built over federal case law. A Land provision can answer `total: 0` because it is not indexed, not because no court has cited it — `coverage.complete_for_this_norm` tells you which, and for a Land provision you should fall back to `search` on the provision's wording.
Parameters6
law
string
optional
ref
string
optional
case
string
optional
A juris doknr, an ECLI, or this site's decision URL as `search` returns it.
limit
integer
optional
offset
integer
optional
Rows to skip, for reading past the first page.
sort
string
optional
'weight' (default) is citation weight, then court tier, then recency. 'recent' is newest decision first; where it cannot be computed the answer falls back to 'weight' and says so in `sort_applied`.
Raw schema
{
"type": "object",
"properties": {
"law": {
"type": "string",
"examples": [
"DSGVO",
"RVG",
"BGB"
]
},
"ref": {
"type": "string",
"examples": [
"83",
"3a",
"288"
]
},
"case": {
"type": "string",
"description": "A juris doknr, an ECLI, or this site's decision URL as `search` returns it.",
"examples": [
"ECLI:DE:BAG:2018:250918.U.8AZR26.18.0"
]
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 20
},
"offset": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Rows to skip, for reading past the first page."
},
"sort": {
"type": "string",
"enum": [
"weight",
"recent"
],
"default": "weight",
"description": "'weight' (default) is citation weight, then court tier, then recency. 'recent' is newest decision first; where it cannot be computed the answer falls back to 'weight' and says so in `sort_applied`."
}
}
}
listCitedAuthorities
Outgoing citation edges of one decision: the statute provisions it cites (with how often it cites each — that is the Normenkette, weighted) and the decisions it relies on.
`treatment` is null on every edge and stays null. Classifying an edge as gefolgt / abgegrenzt / aufgegeben is unbuilt work, and a wrong 'aufgegeben' in a brief is worse than no label at all. Read the citing passage yourself with `listCasePassages`.
A decision we can prove exists but do not hold answers `known_missing`, with the decisions that attest it — not a 404.
Parameters1
case
string
required
A juris doknr, an ECLI, or this site's decision URL as `search` returns it.
Raw schema
{
"type": "object",
"properties": {
"case": {
"type": "string",
"description": "A juris doknr, an ECLI, or this site's decision URL as `search` returns it."
}
},
"required": [
"case"
]
}
listCasePassages
The full text of one decision, split into its paragraphs, each with a permalink you can cite.
`rn` is the Randnummer the COURT printed, read out of the decision's own markup. It is never inferred from position: where a document prints no numbers, `rn` is null and stays null. `anchor_basis` is derived per decision — only 'native_numbering' means our anchor and the printed number provably coincide, so pin-cite a Randnummer only when you see that value.
`amtliche_seite` is null everywhere: our texts carry no page breaks, so a BVerfGE-style page pin cannot be produced honestly.
SIZE. Long decisions run to several hundred paragraphs, so the default page is 30. Three ways to move: `offset` pages, `limit` widens (max 400 — enough for a whole decision when you really want it), and `around` jumps. `pagination` appears whenever there is more than the page you were handed.
`around: 51` returns a window of `limit` passages CENTRED on Randnummer 51 — the right call when `listCitingDecisions` gave you a `citing_rn`, when a search hit came back anchored at …#rd_51, or when you want the passage around a pin cite and not the whole judgment. It takes the number the court printed, not a position, and a decision that prints no such number answers `not_in_corpus` rather than silently handing you a different passage. `around` and `offset` address the same list two different ways; give one.
Parameters4
case
string
required
A juris doknr, an ECLI, or this site's decision URL as `search` returns it.
offset
integer
optional
limit
integer
optional
around
integer | string
optional
A Randnummer as the court printed it. Returns a window of `limit` passages centred on it. Not combinable with `offset`.
Raw schema
{
"type": "object",
"properties": {
"case": {
"type": "string",
"description": "A juris doknr, an ECLI, or this site's decision URL as `search` returns it."
},
"offset": {
"type": "integer",
"minimum": 0,
"default": 0
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 400,
"default": 30
},
"around": {
"type": [
"integer",
"string"
],
"description": "A Randnummer as the court printed it. Returns a window of `limit` passages centred on it. Not combinable with `offset`.",
"examples": [
51,
"12"
]
}
},
"required": [
"case"
]
}
getChanges
Which provisions got a new text recently, newest first — the freshness feed, as JSON. Poll it with `since` set to the newest `observed` you have already processed.
`observed` is the day the new text was FIRST SEEN here, which is not necessarily the day it came into force. Say so if you report a date. The window is the last 120 days; the law's own Änderungsverlauf goes further back.
Parameters2
since
string
optional
Only changes observed on or after this date.
limit
integer
optional
Raw schema
{
"type": "object",
"properties": {
"since": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"description": "Only changes observed on or after this date."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 50
}
}
}
getCoverage
Corpus scope with its holes stated. Call this once when your answer depends on whether an absence is real.
Returns totals (laws, provisions, versions, decisions, courts, citation edges), the per-source windows, the count of Aktenzeichen we can prove exist and do not hold, and `limits`: the version-archive floor, the federal scope of the citation graph, what a version date actually means, and why source windows differ.
Use it to tell `outside_coverage` from `not_in_corpus`. They are different answers and this API never collapses them.
An MCP server for German law: federal statutes and regulations, the Landesrecht
of Bayern, Brandenburg, Nordrhein-Westfalen and Sachsen, and court decisions —
for AI agents that need to ground a citation, read a provision, or follow the
citation graph, and need to know when an answer is not complete.
This repository is the tool layer of the hosted server at
https://recht.nulegal.eu/v1/mcp. The hosted endpoint runs this code,
vendored at a pinned commit, over a private backend that reads the corpus
directly; recht-mcp runs the same code locally over the public REST API. The
corpus and the database stay on the service side — see
docs/DESIGN.md for exactly where the line runs.
The nine tools
Tool
What it does
resolveIdentifiers
Grounds up to 100 citations at once — § 823 Abs. 1 BGB, BVerwG, Urteil vom 24.10.2023 - 2 C 9.22, ECLIs, BVerfGE 65, 1. Keeps the court and the date as disambiguators, flags a wrong date or a different court, and never returns a near match.
search
One query over statutes (lexical) and case law (semantic), with a per-jurisdiction roll-up for cross-Land comparison.
getNorm
The text of one provision as Markdown (or JSON), optionally as of a date, with its gazette citation (fundstelle).
listNormVersions
Every stored version of a provision, with the archive floor marked.
listCitingDecisions
Decisions citing a provision or a decision, by citation weight or newest first, with the citing court's own Randnummer.
listCitedAuthorities
What one decision cites: provisions (the weighted Normenkette) and decisions.
listCasePassages
A decision as numbered passages; page through it or jump to a Randnummer.
getChanges
Provisions whose text changed recently — the freshness feed as JSON.
getCoverage
What the corpus holds, and the limits every answer should be read against.
Every tool is read-only. Each answer says what it is and what it is not:
version dates are first-observed dates, not Inkrafttreten; the norm-version
archive has a floor that every norm answer states; a zero from the citation
graph on a Land provision is labelled as "not indexed", not "never cited"; and
three kinds of miss — not_in_corpus, outside_coverage, known_missing —
are never collapsed into one. The full contract of each tool is in its
description (tools/list).
Use the hosted server (recommended)
No key, no signup. Point any MCP client that speaks Streamable HTTP at:
code
https://recht.nulegal.eu/v1/mcp
Claude Code:
code
claude mcp add --transport http nulegal-recht https://recht.nulegal.eu/v1/mcp
Requires Python 3.12+ and uv. The package has no
third-party runtime dependencies.
code
git clone https://github.com/nulegal-startup/recht-mcp
cd recht-mcp
uv run recht-mcp # stdio
uv run recht-mcp --http # Streamable HTTP on http://127.0.0.1:8765/mcp
Options: --http, --host (default 127.0.0.1), --port (default 8765),
--path (default /mcp), --base-url (or RECHT_MCP_BASE_URL; default
https://recht.nulegal.eu), --timeout, --log-level. The local HTTP server
binds to loopback and refuses browser requests from non-loopback origins.
The local server reads the same data the hosted one does, through the public
REST API, and gives the same answers with two documented exceptions (a
simplified citation grammar, and no other_laws hint on a provision miss) —
see docs/DESIGN.md.
Development
code
uv sync
uv run pytest -q # unit tests, offline
RECHT_MCP_LIVE=1 uv run pytest -q tests/test_live.py # against recht.nulegal.eu
The live tests call every tool through the local server and compare several
answers with the hosted endpoint's.
Data
The legal corpus served by the hosted endpoint — its structure and selection,
the links between provisions and decisions, the version history — is licensed
separately from this code, under
CC BY 4.0. Attribution is
required: „Quelle: nu:legal – recht.nulegal.eu“. The terms, including what is
reserved, are at https://recht.nulegal.eu/lizenz and
https://recht.nulegal.eu/nutzungsbedingungen.
The texts are non-official reading copies. The binding text of a statute is
the one in its official gazette; each norm answer names it (fundstelle,
authoritative_source).
Contributing and security
Issues are welcome; outside pull requests are not accepted yet — see
CONTRIBUTING.md. Report vulnerabilities privately as
described in SECURITY.md.
License
The code in this repository is licensed under the GNU Affero General Public
License, version 3 only (AGPL-3.0-only) — see LICENSE.
The legal corpus served by the hosted endpoint is licensed separately, under
CC BY 4.0 with attribution required (see Data).