German law is technically public. Every federal statute sits on gesetze-im-internet.de, maintained by the Federal Ministry of Justice.
But “public” doesn’t mean “accessible.” Finding relevant law means knowing which of 6,800+ legal codes to search, which section applies, and how to parse dense legal German. Legal professionals spend years building this mental map.
AI assistants could help—if they had structured access to the data. That’s what legal-mcp does: it gives AI semantic search over German federal law via the Model Context Protocol.
The Problem: German Law Is a Labyrinth
The Data Source
Gesetze-im-internet.de is Germany’s official legal database. It contains every federal law, ordinance, and regulation—6,852 legal codes as of this writing. The data is structured XML, downloadable as ZIP archives, following a DTD called gii-norm.dtd.
This sounds usable. It isn’t.
Why It’s Hard to Use
The official site offers only exact string matching. No semantic search. This creates a fundamental mismatch between how people think about legal questions and how the system expects them to search.
Consider: you want to know about returning a defective product. Is that in the BGB (Civil Code)? The HGB (Commercial Code)? The VVG (Insurance Contract Act)? Even if you know it’s the BGB, do you search § 437 (Buyer’s Rights), § 439 (Subsequent Performance), or § 323 (Withdrawal from Contract)?
The answer is all of them, in conjunction. German legal texts are dense with cross-references: ”§ 433 BGB in Verbindung mit § 275 BGB.” The structure assumes you already know the structure.
Then there’s Rechtsdeutsch—legal German. It’s precise but archaic, using vocabulary that doesn’t match everyday language. Searching for “return defective product” won’t find “Nacherfüllung” (subsequent performance), even though that’s exactly what you need.
The Real Challenge
Keyword search fails for legal text because legal precision and natural language operate on different axes.
What people ask: “What are my rights if a product is broken?”
What the law says: “Der Käufer kann als Nacherfüllung nach seiner Wahl die Beseitigung des Mangels oder die Lieferung einer mangelfreien Sache verlangen.”
No shared keywords. Same concept. This is a vector search problem.
The Solution: MCP + Vector Search
What MCP Does
Model Context Protocol is Anthropic’s standard for giving AI structured tool access. Instead of dumping law into prompts (context limits make this impossible anyway), you give AI tools to search on demand.
Legal-mcp exposes four tools:
@mcp.tool()
async def search_legal_texts(
query: str = Field(description="The search query text"),
code: str = Field(description="Legal code identifier (e.g., 'bgb', 'stgb')"),
limit: int = Field(default=5, ge=1, le=20),
cutoff: float = Field(default=0.7, ge=0.0, le=2.0),
) -> List[LegalTextResult]:
"""
Perform semantic search on German legal texts.
Searches through legal codes using semantic similarity to find relevant
sections based on the query text. Lower similarity scores indicate better matches.
"""The others: get_legal_section for exact retrieval, get_available_codes for discovery, and import_legal_code for adding new codes to the database.
Why Vector Search Matters
Vector embeddings capture semantic meaning. The embedding model (Qwen3-Embedding-4B, 2560 dimensions, running locally via Ollama) converts text into high-dimensional vectors where similar concepts cluster together.
“What are my rights if a product is broken?” produces a vector that’s geometrically close to vectors for warranty and defect provisions—even without shared words.
This transforms legal search from “hope you know the exact terminology” to “describe what you’re looking for.”
The Architecture
flowchart TB
mcp["MCP Server<br/>(FastMCP, port 8001)<br/>Tools for AI assistants"]
store["Store API<br/>(FastAPI, port 8000)<br/>Search, import, manage legal texts"]
pg["Postgres<br/>pgvector"]
ollama["Ollama<br/>embed"]
scraper["Scraper<br/>XML parse"]
mcp -->|HTTP| store
store --> pg
store --> ollama
store --> scraper
Three deployable components. The MCP server is a thin HTTP client that exposes tools. The Store API contains all business logic—search, import, embedding coordination. PostgreSQL with pgvector stores the legal texts and their embeddings.
The separation matters. MCP servers should be stateless tool interfaces. The heavy lifting happens elsewhere.
Parsing German Legal XML
The gii-norm.dtd format is comprehensive. It’s not just paragraphs—it’s nested structures with specific legal semantics:
@dataclass
class Metadaten:
"""Represents metadata for a legal norm"""
jurabk: List[str] # Legal abbreviation(s)
amtabk: Optional[str] = None # Official abbreviation
ausfertigung_datum: Optional[str] = None # Promulgation date
fundstelle: List[Fundstelle] = field(default_factory=list)
kurzue: Optional[str] = None # Short title
langue: Optional[str] = None # Long title
gliederungseinheit: Optional[Gliederungseinheit] = None
enbez: Optional[str] = None # Section designation
titel: Optional[str] = None
standangabe: List[Standangabe] = field(default_factory=list)
@dataclass
class FormattedText:
"""Represents formatted text with structure preserved"""
content: str
paragraphs: List[str] = field(default_factory=list)
tables: List[Table] = field(default_factory=list)
footnote_refs: List[str] = field(default_factory=list)Multiple title formats (short, long, official). Footnotes that are legally significant. Tables in legislation (yes, really—tax brackets, fee schedules). Status information tracking amendments and repeals.
The parser handles about 20 distinct element types across 800 lines of code. Most of that complexity is invisible to end users, but it’s necessary to extract clean, searchable text from the source.
Extracting Structure
German legal texts use a consistent notation: § 1, § 2, and so on. Subsections are marked with parenthetical numbers in the text itself:
def _extract_sub_section(self, section: str) -> str:
# if section number is present, the str begins with (n)
if section.startswith("("):
return section.split("(")[1].split(")")[0]
return ""Simple, but it preserves the granularity that makes legal search useful. You can retrieve § 433 BGB (the whole section on purchase contracts) or § 433 BGB Abs. 1 (just the seller’s obligations).
The Embedding Pipeline
Batch Processing
Legal codes vary wildly in size. The Grundgesetz (Constitution) has 146 articles. The BGB (Civil Code) has over 2,300 sections. The Sozialgesetzbuch (Social Code) is split across twelve books.
Embedding everything at once hits request size limits. The solution is straightforward: process in batches.
async def generate_embeddings(self, texts: List[str]) -> Sequence[Sequence[float]]:
all_embeddings: List[Sequence[float]] = []
batch_size = self.settings.ollama_batch_size # default: 50
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
logger.info(
f"Generating embeddings for batch {i // batch_size + 1}/"
f"{(len(texts) + batch_size - 1) // batch_size} ({len(batch)} texts)"
)
response = await self.client.embed(
model=self.model,
input=batch,
)
all_embeddings.extend(response.embeddings)
return all_embeddingsDefault batch size is 50. Configurable up to 500 for systems with more memory. The tradeoff is straightforward: larger batches are faster but risk 413 errors from nginx or Ollama’s request handling.
Why Local Inference
Using Ollama for embeddings means no API costs and no rate limits. Legal text volumes are substantial—importing the 267 codes relevant for public administration involves embedding tens of thousands of sections.
The tradeoff: you need a GPU for reasonable performance. On an M1 Mac, embedding the BGB takes about 10 minutes. On a system with a dedicated GPU, it’s under a minute.
Search in Practice
pgvector stores embeddings as native PostgreSQL columns. Search becomes a query:
async def semantic_search(
self,
query_embedding: Sequence[float],
code: str,
limit: int = 10,
cutoff: Optional[float] = None,
) -> List[Tuple[LegalTextDB, float]]:
"""
Uses cosine distance to find the most similar legal texts.
Results are filtered by code and ordered by similarity (closest first).
pgvector's <=> operator returns cosine distance where:
- 0 means identical vectors
- smaller values mean more similar
- 2 means completely opposite vectors
"""
distance_expr = LegalTextDB.text_vector.cosine_distance(query_embedding)
query = (
select(LegalTextDB, distance_expr.label("distance"))
.filter(LegalTextDB.code == code)
.order_by("distance")
.limit(limit)
)
if cutoff is not None:
query = query.filter(distance_expr <= cutoff)
result = await self.session.execute(query)
return [(row[0], float(row[1])) for row in result.all()]The cutoff parameter lets users control precision vs. recall. Lower values (0.3-0.5) return only very similar results. Higher values (0.7-1.0) cast a wider net.
Once imported, search is fast. pgvector supports IVFFlat indexing for approximate nearest neighbor search, scaling to millions of vectors without degradation.
What This Enables
Example Query Flow
- User asks Claude: “What are my warranty rights under German law?”
- Claude calls
search_legal_texts(query="warranty rights defect product", code="bgb") - MCP server embeds the query, searches vectors, returns relevant sections
- Claude synthesizes a response citing § 437, § 439, § 323 BGB
The AI doesn’t need to know German legal structure. It asks in natural language and gets structured results it can reason about.
Use Cases
Legal research assistance. “What does German law say about…” questions that would otherwise require knowing which code to search.
Compliance checking. “Does this business practice comply with…” queries that need to find relevant regulations.
Public administration. The repository includes a curated list of 267 legal codes relevant for government work—procurement law, administrative procedure, data protection, and so on.
Trade-offs and Limitations
What This Isn’t
It’s not legal advice. The system retrieves text; it doesn’t interpret it. Legal interpretation requires understanding context, case law, and scholarly commentary that isn’t in the raw statutes.
It’s not comprehensive. Federal law only—no state law, no case law, no EU regulations. Those would require different data sources and parsers.
It’s not real-time. You import codes as snapshots. When laws change, you re-import.
Performance Considerations
Initial import is slow. Embedding a large legal code like the BGB involves thousands of API calls to the embedding model. Plan for this during setup, not during queries.
Search is fast once indexed. The pgvector IVFFlat index handles similarity search in milliseconds, even with thousands of sections.
Local inference trades money for hardware. No API costs, but you need a capable GPU for reasonable import times.
Why Open Source
Legal information should be accessible. The data is public; the tooling to use it should be too.
Others can extend it. State law, case law, other countries’ legal systems—the pattern (MCP + vector search + domain data) is reusable.
The specific implementation matters less than the approach: structure domain knowledge so AI can search it semantically, then expose that capability through a standard protocol.
The Pattern
German law is public but not accessible. Semantic search + MCP makes it usable by AI.
The same pattern applies anywhere domain expertise gates access to public information. Medical literature. Patent databases. Building codes. Any corpus where keyword search fails because the vocabulary of experts differs from the vocabulary of questions.
The repository is at github.com/ayunis-core/ayunis-legal-mcp.