openlex-mcp
> π¨π **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**
# βοΈ openlex-mcp

[](https://opensource.org/licenses/MIT)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io/)
[](https://github.com/malkreide/openlex-mcp)
> MCP Server for Canton Zurich legislation (ZH-Lex) β full-text search, article extraction, and education law tools for ~970 cantonal laws
[π©πͺ Deutsche Version](README.de.md)
<p align="center">
<img src="assets/demo.png" alt="Demo: Claude searches Zurich education law via MCP tool call" width="720">
</p>
---
## Overview
`openlex-mcp` provides AI-native access to the entire legal collection of Canton Zurich (ZΓΌrcher Gesetzessammlung). It combines full-text data from HuggingFace with live metadata from the official zh.ch website, storing everything in a local SQLite database with FTS5 full-text indexing for sub-50ms search performance.
| Source | Data | Access |
|--------|------|--------|
| **HuggingFace** | 974 ZH laws β full text (PDF extracts) | Cached locally as SQLite + FTS5 |
| **zh.ch ZH-Lex** | Current metadata, PDF links, validity status | Live HTTP requests |
Built for the Schulamt (school department) of the City of Zurich, but covers all areas of cantonal law β from tax law to building regulations.
**Anchor demo query:** *"What does the Volksschulgesetz say about parental involvement? Show me Art. 55 VSG and find all articles that mention 'Elternrat'."*
---
## Features
- βοΈ **8 tools** covering search, retrieval, article extraction, and cache management
- π **FTS5 full-text search** across ~970 cantonal laws with BM25 ranking
- π **Article extraction** β parse individual articles (Art. / Β§) with paragraph detection
- π« **Education law shortcuts** β specialized search for LS 412.x series (Volksschulgesetz, Lehrpersonalverordnung, etc.)
- π **Live metadata** from zh.ch for current validity status and PDF links
- πΎ **Hybrid architecture** β cached full-text (HuggingFace) + live metadata (zh.ch)
- π **No API key required** β all data under open licenses (CC-BY-SA 4.0)
- βοΈ **Dual transport** β stdio (Claude Desktop) + Streamable HTTP (cloud)
---
## Development Phase
**Current phase: Phase 1 β Read-Only.** All tools are read-only (`readOnlyHint: true`); no writes to external systems. See [ROADMAP.md](ROADMAP.md) for the phase plan and transition gates before any write or multi-agent capability is added.
---
## Prerequisites
- Python 3.11+
- [uv](https://github.com/astral-sh/uv) (recommended) or pip
- Internet connection (for initial data download and live metadata)
---
## Installation
```bash
# Clone the repository
git clone https://github.com/malkreide/openlex-mcp.git
cd openlex-mcp
# Install
pip install -e .
# or with uv:
uv pip install -e .
```
---
## Quickstart
```bash
# stdio (for Claude Desktop)
python -m openlex_mcp.server
# Streamable HTTP β binds to 127.0.0.1:8000 by default (localhost only)
python -m openlex_mcp.server --http --port 8000
```
### Network binding
By default the HTTP transport binds to **`127.0.0.1`** (localhost only). The host
and port are configurable via the `MCP_HOST` / `MCP_PORT` environment variables
(or the `--host` / `--port` CLI flags, which take precedence).
**Never** bind to `0.0.0.0` outside a container β it exposes the server to your
local network (NeighborJack risk). For containerized/cloud deployments set
`MCP_HOST=0.0.0.0` explicitly; when that happens outside a detected container the
server logs a warning.
Try it immediately in Claude Desktop:
> *"What is the Volksschulgesetz (VSG)?"*
> *"Find all Zurich laws about data protection"*
> *"Show me Art. 1 of the Volksschulgesetz"*
> *"Which education laws mention 'Schulleitung'?"*
---
## Configuration
### Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"openlex": {
"command": "python",
"args": ["-m", "openlex_mcp.server"]
}
}
}
```
Or with the installed entry point:
```json
{
"mcpServers": {
"openlex": {
"command": "openlex-mcp"
}
}
}
```
**Config file locations:**
- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`
### Cloud Deployment (SSE for browser access)
For use via **claude.ai in the browser** (e.g. on managed workstations without local software):
**Render.com (recommended):**
1. Push/fork the repository to GitHub
2. On [render.com](https://render.com): New Web Service β connect GitHub repo
3. Set start command: `python -m openlex_mcp.server --http --port 8000`
4. Set environment variable `MCP_HOST=0.0.0.0` so the container is reachable
(the code default is `127.0.0.1`; Render sets the `RENDER` env var, so no
NeighborJack warning is logged)
5. Set `MCP_CORS_ORIGINS=https://claude.ai` so the browser can read the
`Mcp-Session-Id` header (comma-separated list; **no wildcard** β defaults to
empty, i.e. no cross-origin access)
6. In claude.ai under Settings β MCP Servers, add: `https://your-app.onrender.com/sse`
> π‘ *"stdio for the developer laptop, SSE for the browser."*
---
## Available Tools
### Search & Browse
| Tool | Description |
|------|-------------|
| `openlex__zhlaw_search_laws` | Full-text search across all ~970 ZH laws (FTS5 + BM25 ranking) |
| `openlex__zhlaw_get_law` | Retrieve a law by LS number (e.g. `412.100`) or abbreviation (e.g. `VSG`) |
| `openlex__zhlaw_list_laws` | List and filter laws by legal area prefix |
| `openlex__zhlaw_find_education_laws` | Specialized search in education law (LS 412.x series) |
### Article Extraction
| Tool | Description |
|------|-------------|
| `openlex__zhlaw_get_article` | Extract a specific article from a law (e.g. Art. 28 VSG) |
| `openlex__zhlaw_search_articles` | Search within all articles of a specific law |
### Metadata & Cache
| Tool | Description |
|------|-------------|
| `openlex__zhlaw_get_law_metadata` | Get live metadata from zh.ch (PDF links, validity status) |
| `openlex__zhlaw_update_cache` | Refresh the local data cache from HuggingFace |
### Key Legal Area Prefixes (LS Numbers)
| Prefix | Legal Area | Example |
|--------|-----------|---------|
| `131` | Constitution and popular rights | Kantonsverfassung |
| `170` | Administrative procedure | Datenschutzgesetz |
| `331` | Tax law | Steuergesetz |
| `412` | Education and schools | Volksschulgesetz (VSG) |
| `700` | Spatial planning and building | Planungs- und Baugesetz |
| `810` | Health | Gesundheitsgesetz |
### Example Use Cases
| Query | Tool |
|-------|------|
| *"What is the Volksschulgesetz?"* | `openlex__zhlaw_get_law` |
| *"Find laws about data protection"* | `openlex__zhlaw_search_laws` |
| *"Show me Art. 55 VSG"* | `openlex__zhlaw_get_article` |
| *"Which education laws mention Schulleitung?"* | `openlex__zhlaw_find_education_laws` |
| *"Find all articles about Elternrat in the VSG"* | `openlex__zhlaw_search_articles` |
| *"Is LS 412.100 still in force?"* | `openlex__zhlaw_get_law_metadata` |
---
## Architecture
```
βββββββββββββββββββ ββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββ
β Claude / AI ββββββΆβ OpenLex MCP ββββββΆβ HuggingFace β
β (MCP Host) βββββββ (MCP Server) βββββββ rcds/swiss_legislation β
βββββββββββββββββββ β β β (974 ZH laws, cached) β
β 8 Tools β ββββββββββββββββββββββββββββ€
β SQLite + FTS5 Cache ββββββΆβ zh.ch ZH-Lex β
β Stdio | HTTP βββββββ (live metadata + PDFs) β
β β ββββββββββββββββββββββββββββ€
β No authentication required β β LexFind.ch β
ββββββββββββββββββββββββββββββββ β (links only) β
ββββββββββββββββββββββββββββ
```
### Data Source Characteristics
| Source | Protocol | Coverage | Auth | License |
|--------|----------|----------|------|---------|
| HuggingFace `rcds/swiss_legislation` | Datasets API | 974 ZH laws (full text) | None | CC-BY-SA 4.0 |
| zh.ch ZH-Lex | HTTP/HTML | Current metadata, PDFs | None | Public |
| LexFind.ch | HTTP | Cross-cantonal links | None | Public |
### Design Decision: Tools-only (no MCP Resources)
All 8 endpoints are exposed as **Tools** rather than MCP Resources. Rationale:
- Every lookup is **parametric** β queries, abbreviations, article numbers vary per call. Static Resources (one URI per document) don't capture this naturally.
- The corpus is **974 laws Γ many articles** β registering each as a Resource URI would create an impractically large resource list.
- MCP Resource templates (`zhlex://laws/{sr_number}`) are a future consideration for Phase 2 if clients benefit from resource-level caching or subscriptions.
### Scaling Constraints
The Streamable-HTTP transport keeps session state **in-process** (FastMCP default). This has two implications:
- **Single-instance only** β horizontal scaling (multiple replicas) breaks active sessions because there is no shared session store (Redis, Durable Objects, etc.).
- **No sticky-session LB needed today** β a single-replica Render deployment naturally routes all requests to one process.
Before scaling beyond one instance: either add a shared session store **or** configure your edge load balancer to route on the `Mcp-Session-Id` header with a stick-table and an appropriate TTL.
---
## MCP Protocol Version
| Item | Value |
|------|-------|
| **Served via the `initialize` handshake** | `2024-11-05` β¦ **`2025-11-25`** β the handshake ceiling |
| **Served via the per-request envelope** | **`2026-07-28`** |
| **Who picks** | The client's first request, once per connection. A request carrying the `2026-07-28` `_meta` envelope opens a modern connection; anything else opens a handshake connection. |
| **SDK** | `mcp[cli]>=2.0.0,<3` |
| **Cache hints** | `tools/list` and `server/discover`: `ttlMs` 300000, `cacheScope` `public` |
| **Pinned in** | `src/openlex_mcp/server.py` β `MCP_PROTOCOL_VERSION` constant |
### Update policy
1. When `mcp` is upgraded (via Dependabot PR), verify the protocol version in the SDK release notes.
2. If the protocol version changes, update `MCP_PROTOCOL_VERSION` in `server.py`, regenerate `docs/tool-hashes.json` (`PYTHONPATH=src python scripts/gen_tool_hashes.py --write`), and note the change in `CHANGELOG.md`.
3. Run `pytest tests/ -m "not live"` to confirm compatibility before merging.
---
## Project Structure
```
openlex-mcp/
βββ src/openlex_mcp/
β βββ __init__.py # Package
β βββ __main__.py # Entry point for python -m
β βββ server.py # 8 MCP tool definitions (FastMCP) + Settings
β βββ responses.py # Typed structured response envelopes (SDK-002)
β βββ logging_config.py # structlog JSON logging setup (OBS-003)
β βββ net.py # SSRF/egress-hardened outbound HTTP
β βββ api_client.py # zh.ch HTTP client + metadata extraction
β βββ data_cache.py # SQLite + FTS5 cache management
β βββ law_parser.py # Article extraction from law texts
βββ tests/ # 89 unit tests (parser, cache, net, toolsβ¦)
βββ scripts/gen_tool_hashes.py # Tool-definition hash snapshot (SEC-022)
βββ docs/ # network-egress, secret-management, tool-hashes
βββ .github/workflows/ci.yml # GitHub Actions (Python 3.11/3.12/3.13)
βββ .github/dependabot.yml # Weekly dependency PRs (ARCH-012)
βββ Dockerfile # Hardened multi-stage build (SEC-007/SCALE-004)
βββ compose.yml # Resource limits for local testing (SCALE-006)
βββ pyproject.toml
βββ claude_desktop_config.json # Example config for Claude Desktop
βββ CHANGELOG.md
βββ ROADMAP.md # Phase plan + accepted-risk register
βββ CONTRIBUTING.md # Contribution guide (English)
βββ CONTRIBUTING.de.md # Contribution guide (German)
βββ SECURITY.md # Security policy (English)
βββ SECURITY.de.md # Security policy (German)
βββ LICENSE
βββ README.md # This file (English)
βββ README.de.md # German version
```
### Tool output format
All tools return a **structured response envelope** (not Markdown text), so MCP
clients receive `structuredContent` they can parse directly:
```jsonc
{
"source": "Kanton ZΓΌrich Rechtssammlung β HuggingFace β¦ & zh.ch",
"provenance": "cache", // cache | live | parser | cache+parser | none
"result_type": "law_summaries", // law_summaries | law_detail | articles | metadata | cache_status
"count": 2,
"message": null, // human-readable guidance for empty/edge results
"results": [ /* typed items */ ]
}
```
---
## Known Limitations
- **HuggingFace dataset:** The `html_content` field is unreliable (cross-contaminated between laws); the server uses `pdf_content` instead, which is correct but has PDF extraction artefacts (hyphenation, layout artefacts)
- **Article parser:** PDF text extraction sometimes merges article boundaries; complex nested articles may not parse perfectly
- **Initial load:** First start requires ~25s to download and index 974 laws from HuggingFace (~38 MB SQLite database)
- **zh.ch metadata:** No official API; metadata extraction relies on HTML patterns that may change
- **Offline mode:** Full-text search works offline after initial load; live metadata requires internet
- **The corpus is frozen at 2023-01-01.** This is the limitation that matters most for a legal server, and it was the one not stated. The newest version in the entire dataset carries `version_active_since = 2023-01-01`; the HuggingFace dataset itself was last touched 2024-10-10. The 24-hour cache TTL and `provenance="cache"` describe where an answer came from, not how old the laws in it are. Every response now carries `corpus_as_of` and `corpus_note` alongside `provenance`.
- **Repeals after the cut-off are invisible.** All 974 entries carry `is_active = True` and not one has a `version_inactive_since`. That is not a server bug β the source lists only the statutes in force at snapshot time. The consequence is what matters: a law repealed since then still appears to be in force. Consult the ZH-Lex permalink for the operative text.
- **`zhlaw_update_cache` does not make the laws newer.** It re-downloads the same frozen dataset. Its docstring previously read "only call when law search results seem outdated", which suggested exactly the effect it does not have.
---
## Safety & Limits
| Aspect | Details |
|--------|---------|
| **Access** | Read-only (`readOnlyHint: true`) β the server cannot modify or delete any data |
| **Personal data** | No personal data β all sources are aggregated, public legal texts |
| **Rate limits** | Built-in per-query caps (max 50 search results, 5000 chars content preview) |
| **Timeout** | 30 seconds per HTTP call to zh.ch |
| **Egress** | Outbound requests are restricted to an allow-list (`www.zh.ch` over HTTPS, plus the HTTP-only legacy permalink host `www.zhlex.zh.ch`), with SSRF IP-blocking and DNS-pinning β see [docs/network-egress.md](docs/network-egress.md) |
| **Authentication** | No API keys required β HuggingFace dataset is public, zh.ch is open |
| **Security posture (Lethal Trifecta)** | Score **1 / 3**: public data only (no private/sensitive data) β Β· GET-only egress to `*.zh.ch` β no POST, no webhooks, no email β Β· no code execution β. Structurally safe by design. |
| **Session handling** | `Mcp-Session-Id` generated and managed by the MCP SDK (cryptographically secure UUIDs). No user-identity binding β `auth_model=none` is correct for public read-only data. If authentication is ever added, bind sessions to the validated OAuth `sub` claim before deployment. |
| **Secrets** | No secrets held β all data sources are public. See [docs/secret-management.md](docs/secret-management.md). |
| **Licenses** | Law data: CC-BY-SA 4.0 ([rcds/swiss_legislation](https://huggingface.co/datasets/rcds/swiss_legislation)); zh.ch metadata: public |
| **Terms of Service** | Subject to ToS of [HuggingFace](https://huggingface.co/terms-of-service) and [Canton Zurich](https://www.zh.ch/de/rechtliche-hinweise.html) |
| **Disclaimer** | This server provides legal texts for informational purposes only β it does not constitute legal advice |
To report a vulnerability, see the [Security Policy](SECURITY.md).
---
## Testing
```bash
# Unit + contract tests (no network) β this is what CI runs
PYTHONPATH=src pytest tests/ -m "not live"
# Live tests against zh.ch and HuggingFace
PYTHONPATH=src pytest tests/ -m "live"
# Re-measure the corpus date and the live hosts
PYTHONPATH=src python scripts/record_fixtures.py
```
**150 tests** β 142 offline, 8 live. Eight tools, eight live tests: the best
coverage in this portfolio, which is why the finding here is not about
mechanics but about a confusion between two questions. `provenance="cache"`
answers *where* an answer came from; `corpus_as_of` answers *how old the laws
in it are*. Only the first was ever answered, and the second is the one a user
means when they ask "is this current?".
### A measurement limit, deliberately not resolved by editing a test
`test_live_get_law_metadata` fails in the recording environment: `zhlex.zh.ch`
is not reachable from it. **Nothing follows from that.** Public DNS resolves
the host (NOERROR, 194.247.8.174) and an NXDOMAIN control shows the query
discriminates β so the limit is the environment's, not the source's.
The test was therefore left untouched. A test you see red because your own
network cannot get out is not a test to rewrite; rewriting it would leave you
measuring your own environment instead of the source. `PROVENANCE.md` records
this as open.
---
## Changelog
See [CHANGELOG.md](CHANGELOG.md)
---
## Roadmap
See [ROADMAP.md](ROADMAP.md)
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## Security
See [SECURITY.md](SECURITY.md)
---
## License
MIT License β see [LICENSE](LICENSE)
---
## Author
Hayal Oezkan Β· [malkreide](https://github.com/malkreide)
---
## Credits & Related Projects
- **Data:** [rcds/swiss_legislation](https://huggingface.co/datasets/rcds/swiss_legislation) β HuggingFace dataset (CC-BY-SA 4.0)
- **ZH-Lex:** [zh.ch Gesetzessammlung](https://www.zh.ch/de/politik-staat/gesetze-beschluesse/gesetzessammlung.html) β Official Canton Zurich legal collection
- **LexFind:** [lexfind.ch](https://www.lexfind.ch/) β Cross-cantonal legislation database
- **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) β Anthropic / Linux Foundation
- **Related:** [swiss-courts-mcp](https://github.com/malkreide/swiss-courts-mcp) β Law text + case law = complete legal research
- **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) β Law text + city council decisions = full context
- **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide)
<!-- mcp-name: io.github.malkreide/openlex-mcp -->
<!-- BEGIN GENERATED: install -->
## Installation
Run via [`uv`](https://docs.astral.sh/uv/)'s `uvx` β no clone or manual install needed. Add to your MCP client config (`mcpServers` for Claude Desktop, Cursor and Windsurf; use a top-level `servers` key for VS Code in `.vscode/mcp.json`):
```json
{
"mcpServers": {
"openlex-mcp": {
"command": "uvx",
"args": [
"openlex-mcp"
]
}
}
}
```
<!-- END GENERATED: install -->
TDQS
Scored across 8 tools
Each tool has a clearly distinct purpose: full-text search across all laws, retrieval by number/abbreviation, article extraction, listing, specialized education search, article search within a law, live metadata, and cache update. The use case guidance in descriptions eliminates ambiguity.
All tool names follow a consistent pattern: openlex__zhlaw_<verb>_<noun> (e.g., search_laws, get_law, get_article). The verb-noun structure is uniform across all tools, using standard verbs like search, get, list, find, update.
8 tools is well-scoped for Zurich cantonal law: covers search, retrieval, listing, specialized search, metadata, and cache management. Neither too few nor too many, each tool earns its place.
The tool set covers the full lifecycle of legal retrieval: searching across laws, finding specific articles, retrieving full text, listing laws by area, getting live metadata, and managing cache. No obvious gaps for a read-only legal database.