{
  "markdown": "> 🇨🇭 **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**\n\n# ⚖️ openlex-mcp\n\n![Version](https://img.shields.io/badge/version-0.2.5-blue)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)\n[![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-purple)](https://modelcontextprotocol.io/)\n[![No Auth Required](https://img.shields.io/badge/auth-none%20required-brightgreen)](https://github.com/malkreide/openlex-mcp)\n\n> MCP Server for Canton Zurich legislation (ZH-Lex) — full-text search, article extraction, and education law tools for ~970 cantonal laws\n\n[🇩🇪 Deutsche Version](README.de.md)\n\n<p align=\"center\">\n  <img src=\"assets/demo.png\" alt=\"Demo: Claude searches Zurich education law via MCP tool call\" width=\"720\">\n</p>\n\n---\n\n## Overview\n\n`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.\n\n| Source | Data | Access |\n|--------|------|--------|\n| **HuggingFace** | 974 ZH laws — full text (PDF extracts) | Cached locally as SQLite + FTS5 |\n| **zh.ch ZH-Lex** | Current metadata, PDF links, validity status | Live HTTP requests |\n\nBuilt for the Schulamt (school department) of the City of Zurich, but covers all areas of cantonal law — from tax law to building regulations.\n\n**Anchor demo query:** *\"What does the Volksschulgesetz say about parental involvement? Show me Art. 55 VSG and find all articles that mention 'Elternrat'.\"*\n\n---\n\n## Features\n\n- ⚖️ **8 tools** covering search, retrieval, article extraction, and cache management\n- 🔍 **FTS5 full-text search** across ~970 cantonal laws with BM25 ranking\n- 📑 **Article extraction** — parse individual articles (Art. / §) with paragraph detection\n- 🏫 **Education law shortcuts** — specialized search for LS 412.x series (Volksschulgesetz, Lehrpersonalverordnung, etc.)\n- 🌐 **Live metadata** from zh.ch for current validity status and PDF links\n- 💾 **Hybrid architecture** — cached full-text (HuggingFace) + live metadata (zh.ch)\n- 🔓 **No API key required** — all data under open licenses (CC-BY-SA 4.0)\n- ☁️ **Dual transport** — stdio (Claude Desktop) + Streamable HTTP (cloud)\n\n---\n\n## Development Phase\n\n**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.\n\n---\n\n## Prerequisites\n\n- Python 3.11+\n- [uv](https://github.com/astral-sh/uv) (recommended) or pip\n- Internet connection (for initial data download and live metadata)\n\n---\n\n## Installation\n\n```bash\n# Clone the repository\ngit clone https://github.com/malkreide/openlex-mcp.git\ncd openlex-mcp\n\n# Install\npip install -e .\n# or with uv:\nuv pip install -e .\n```\n\n---\n\n## Quickstart\n\n```bash\n# stdio (for Claude Desktop)\npython -m openlex_mcp.server\n\n# Streamable HTTP — binds to 127.0.0.1:8000 by default (localhost only)\npython -m openlex_mcp.server --http --port 8000\n```\n\n### Network binding\n\nBy default the HTTP transport binds to **`127.0.0.1`** (localhost only). The host\nand port are configurable via the `MCP_HOST` / `MCP_PORT` environment variables\n(or the `--host` / `--port` CLI flags, which take precedence).\n\n**Never** bind to `0.0.0.0` outside a container — it exposes the server to your\nlocal network (NeighborJack risk). For containerized/cloud deployments set\n`MCP_HOST=0.0.0.0` explicitly; when that happens outside a detected container the\nserver logs a warning.\n\nTry it immediately in Claude Desktop:\n\n> *\"What is the Volksschulgesetz (VSG)?\"*\n> *\"Find all Zurich laws about data protection\"*\n> *\"Show me Art. 1 of the Volksschulgesetz\"*\n> *\"Which education laws mention 'Schulleitung'?\"*\n\n---\n\n## Configuration\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\\Claude\\claude_desktop_config.json` (Windows):\n\n```json\n{\n  \"mcpServers\": {\n    \"openlex\": {\n      \"command\": \"python\",\n      \"args\": [\"-m\", \"openlex_mcp.server\"]\n    }\n  }\n}\n```\n\nOr with the installed entry point:\n\n```json\n{\n  \"mcpServers\": {\n    \"openlex\": {\n      \"command\": \"openlex-mcp\"\n    }\n  }\n}\n```\n\n**Config file locations:**\n- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`\n- Windows: `%APPDATA%\\Claude\\claude_desktop_config.json`\n\n### Cloud Deployment (SSE for browser access)\n\nFor use via **claude.ai in the browser** (e.g. on managed workstations without local software):\n\n**Render.com (recommended):**\n1. Push/fork the repository to GitHub\n2. On [render.com](https://render.com): New Web Service → connect GitHub repo\n3. Set start command: `python -m openlex_mcp.server --http --port 8000`\n4. Set environment variable `MCP_HOST=0.0.0.0` so the container is reachable\n   (the code default is `127.0.0.1`; Render sets the `RENDER` env var, so no\n   NeighborJack warning is logged)\n5. Set `MCP_CORS_ORIGINS=https://claude.ai` so the browser can read the\n   `Mcp-Session-Id` header (comma-separated list; **no wildcard** — defaults to\n   empty, i.e. no cross-origin access)\n6. In claude.ai under Settings → MCP Servers, add: `https://your-app.onrender.com/sse`\n\n> 💡 *\"stdio for the developer laptop, SSE for the browser.\"*\n\n---\n\n## Available Tools\n\n### Search & Browse\n\n| Tool | Description |\n|------|-------------|\n| `openlex__zhlaw_search_laws` | Full-text search across all ~970 ZH laws (FTS5 + BM25 ranking) |\n| `openlex__zhlaw_get_law` | Retrieve a law by LS number (e.g. `412.100`) or abbreviation (e.g. `VSG`) |\n| `openlex__zhlaw_list_laws` | List and filter laws by legal area prefix |\n| `openlex__zhlaw_find_education_laws` | Specialized search in education law (LS 412.x series) |\n\n### Article Extraction\n\n| Tool | Description |\n|------|-------------|\n| `openlex__zhlaw_get_article` | Extract a specific article from a law (e.g. Art. 28 VSG) |\n| `openlex__zhlaw_search_articles` | Search within all articles of a specific law |\n\n### Metadata & Cache\n\n| Tool | Description |\n|------|-------------|\n| `openlex__zhlaw_get_law_metadata` | Get live metadata from zh.ch (PDF links, validity status) |\n| `openlex__zhlaw_update_cache` | Refresh the local data cache from HuggingFace |\n\n### Key Legal Area Prefixes (LS Numbers)\n\n| Prefix | Legal Area | Example |\n|--------|-----------|---------|\n| `131` | Constitution and popular rights | Kantonsverfassung |\n| `170` | Administrative procedure | Datenschutzgesetz |\n| `331` | Tax law | Steuergesetz |\n| `412` | Education and schools | Volksschulgesetz (VSG) |\n| `700` | Spatial planning and building | Planungs- und Baugesetz |\n| `810` | Health | Gesundheitsgesetz |\n\n### Example Use Cases\n\n| Query | Tool |\n|-------|------|\n| *\"What is the Volksschulgesetz?\"* | `openlex__zhlaw_get_law` |\n| *\"Find laws about data protection\"* | `openlex__zhlaw_search_laws` |\n| *\"Show me Art. 55 VSG\"* | `openlex__zhlaw_get_article` |\n| *\"Which education laws mention Schulleitung?\"* | `openlex__zhlaw_find_education_laws` |\n| *\"Find all articles about Elternrat in the VSG\"* | `openlex__zhlaw_search_articles` |\n| *\"Is LS 412.100 still in force?\"* | `openlex__zhlaw_get_law_metadata` |\n\n---\n\n## Architecture\n\n```\n┌─────────────────┐     ┌──────────────────────────────┐     ┌──────────────────────────┐\n│   Claude / AI   │────▶│  OpenLex MCP                 │────▶│  HuggingFace             │\n│   (MCP Host)    │◀────│  (MCP Server)                │◀────│  rcds/swiss_legislation   │\n└─────────────────┘     │                              │     │  (974 ZH laws, cached)   │\n                        │  8 Tools                     │     ├──────────────────────────┤\n                        │  SQLite + FTS5 Cache         │────▶│  zh.ch ZH-Lex            │\n                        │  Stdio | HTTP                │◀────│  (live metadata + PDFs)  │\n                        │                              │     ├──────────────────────────┤\n                        │  No authentication required  │     │  LexFind.ch              │\n                        └──────────────────────────────┘     │  (links only)            │\n                                                             └──────────────────────────┘\n```\n\n### Data Source Characteristics\n\n| Source | Protocol | Coverage | Auth | License |\n|--------|----------|----------|------|---------|\n| HuggingFace `rcds/swiss_legislation` | Datasets API | 974 ZH laws (full text) | None | CC-BY-SA 4.0 |\n| zh.ch ZH-Lex | HTTP/HTML | Current metadata, PDFs | None | Public |\n| LexFind.ch | HTTP | Cross-cantonal links | None | Public |\n\n### Design Decision: Tools-only (no MCP Resources)\n\nAll 8 endpoints are exposed as **Tools** rather than MCP Resources. Rationale:\n\n- Every lookup is **parametric** — queries, abbreviations, article numbers vary per call. Static Resources (one URI per document) don't capture this naturally.\n- The corpus is **974 laws × many articles** — registering each as a Resource URI would create an impractically large resource list.\n- MCP Resource templates (`zhlex://laws/{sr_number}`) are a future consideration for Phase 2 if clients benefit from resource-level caching or subscriptions.\n\n### Scaling Constraints\n\nThe Streamable-HTTP transport keeps session state **in-process** (FastMCP default). This has two implications:\n\n- **Single-instance only** — horizontal scaling (multiple replicas) breaks active sessions because there is no shared session store (Redis, Durable Objects, etc.).\n- **No sticky-session LB needed today** — a single-replica Render deployment naturally routes all requests to one process.\n\nBefore 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.\n\n---\n\n## MCP Protocol Version\n\n| Item | Value |\n|------|-------|\n| **Served via the `initialize` handshake** | `2024-11-05` … **`2025-11-25`** — the handshake ceiling |\n| **Served via the per-request envelope** | **`2026-07-28`** |\n| **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. |\n| **SDK** | `mcp[cli]>=2.0.0,<3` |\n| **Cache hints** | `tools/list` and `server/discover`: `ttlMs` 300000, `cacheScope` `public` |\n| **Pinned in** | `src/openlex_mcp/server.py` — `MCP_PROTOCOL_VERSION` constant |\n\n### Update policy\n\n1. When `mcp` is upgraded (via Dependabot PR), verify the protocol version in the SDK release notes.\n2. 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`.\n3. Run `pytest tests/ -m \"not live\"` to confirm compatibility before merging.\n\n---\n\n## Project Structure\n\n```\nopenlex-mcp/\n├── src/openlex_mcp/\n│   ├── __init__.py              # Package\n│   ├── __main__.py              # Entry point for python -m\n│   ├── server.py                # 8 MCP tool definitions (FastMCP) + Settings\n│   ├── responses.py             # Typed structured response envelopes (SDK-002)\n│   ├── logging_config.py        # structlog JSON logging setup (OBS-003)\n│   ├── net.py                   # SSRF/egress-hardened outbound HTTP\n│   ├── api_client.py            # zh.ch HTTP client + metadata extraction\n│   ├── data_cache.py            # SQLite + FTS5 cache management\n│   └── law_parser.py            # Article extraction from law texts\n├── tests/                       # 89 unit tests (parser, cache, net, tools…)\n├── scripts/gen_tool_hashes.py   # Tool-definition hash snapshot (SEC-022)\n├── docs/                        # network-egress, secret-management, tool-hashes\n├── .github/workflows/ci.yml     # GitHub Actions (Python 3.11/3.12/3.13)\n├── .github/dependabot.yml       # Weekly dependency PRs (ARCH-012)\n├── Dockerfile                   # Hardened multi-stage build (SEC-007/SCALE-004)\n├── compose.yml                  # Resource limits for local testing (SCALE-006)\n├── pyproject.toml\n├── claude_desktop_config.json   # Example config for Claude Desktop\n├── CHANGELOG.md\n├── ROADMAP.md                   # Phase plan + accepted-risk register\n├── CONTRIBUTING.md              # Contribution guide (English)\n├── CONTRIBUTING.de.md           # Contribution guide (German)\n├── SECURITY.md                  # Security policy (English)\n├── SECURITY.de.md               # Security policy (German)\n├── LICENSE\n├── README.md                    # This file (English)\n└── README.de.md                 # German version\n```\n\n### Tool output format\n\nAll tools return a **structured response envelope** (not Markdown text), so MCP\nclients receive `structuredContent` they can parse directly:\n\n```jsonc\n{\n  \"source\": \"Kanton Zürich Rechtssammlung — HuggingFace … & zh.ch\",\n  \"provenance\": \"cache\",          // cache | live | parser | cache+parser | none\n  \"result_type\": \"law_summaries\", // law_summaries | law_detail | articles | metadata | cache_status\n  \"count\": 2,\n  \"message\": null,                // human-readable guidance for empty/edge results\n  \"results\": [ /* typed items */ ]\n}\n```\n\n---\n\n## Known Limitations\n\n- **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)\n- **Article parser:** PDF text extraction sometimes merges article boundaries; complex nested articles may not parse perfectly\n- **Initial load:** First start requires ~25s to download and index 974 laws from HuggingFace (~38 MB SQLite database)\n- **zh.ch metadata:** No official API; metadata extraction relies on HTML patterns that may change\n- **Offline mode:** Full-text search works offline after initial load; live metadata requires internet\n- **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`.\n- **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.\n- **`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.\n\n---\n\n## Safety & Limits\n\n| Aspect | Details |\n|--------|---------|\n| **Access** | Read-only (`readOnlyHint: true`) — the server cannot modify or delete any data |\n| **Personal data** | No personal data — all sources are aggregated, public legal texts |\n| **Rate limits** | Built-in per-query caps (max 50 search results, 5000 chars content preview) |\n| **Timeout** | 30 seconds per HTTP call to zh.ch |\n| **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) |\n| **Authentication** | No API keys required — HuggingFace dataset is public, zh.ch is open |\n| **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. |\n| **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. |\n| **Secrets** | No secrets held — all data sources are public. See [docs/secret-management.md](docs/secret-management.md). |\n| **Licenses** | Law data: CC-BY-SA 4.0 ([rcds/swiss_legislation](https://huggingface.co/datasets/rcds/swiss_legislation)); zh.ch metadata: public |\n| **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) |\n| **Disclaimer** | This server provides legal texts for informational purposes only — it does not constitute legal advice |\n\nTo report a vulnerability, see the [Security Policy](SECURITY.md).\n\n---\n\n## Testing\n\n```bash\n# Unit + contract tests (no network) — this is what CI runs\nPYTHONPATH=src pytest tests/ -m \"not live\"\n\n# Live tests against zh.ch and HuggingFace\nPYTHONPATH=src pytest tests/ -m \"live\"\n\n# Re-measure the corpus date and the live hosts\nPYTHONPATH=src python scripts/record_fixtures.py\n```\n\n**150 tests** — 142 offline, 8 live. Eight tools, eight live tests: the best\ncoverage in this portfolio, which is why the finding here is not about\nmechanics but about a confusion between two questions. `provenance=\"cache\"`\nanswers *where* an answer came from; `corpus_as_of` answers *how old the laws\nin it are*. Only the first was ever answered, and the second is the one a user\nmeans when they ask \"is this current?\".\n\n### A measurement limit, deliberately not resolved by editing a test\n\n`test_live_get_law_metadata` fails in the recording environment: `zhlex.zh.ch`\nis not reachable from it. **Nothing follows from that.** Public DNS resolves\nthe host (NOERROR, 194.247.8.174) and an NXDOMAIN control shows the query\ndiscriminates — so the limit is the environment's, not the source's.\n\nThe test was therefore left untouched. A test you see red because your own\nnetwork cannot get out is not a test to rewrite; rewriting it would leave you\nmeasuring your own environment instead of the source. `PROVENANCE.md` records\nthis as open.\n\n---\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md)\n\n---\n\n## Roadmap\n\nSee [ROADMAP.md](ROADMAP.md)\n\n---\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md)\n\n---\n\n## Security\n\nSee [SECURITY.md](SECURITY.md)\n\n---\n\n## License\n\nMIT License — see [LICENSE](LICENSE)\n\n---\n\n## Author\n\nHayal Oezkan · [malkreide](https://github.com/malkreide)\n\n---\n\n## Credits & Related Projects\n\n- **Data:** [rcds/swiss_legislation](https://huggingface.co/datasets/rcds/swiss_legislation) — HuggingFace dataset (CC-BY-SA 4.0)\n- **ZH-Lex:** [zh.ch Gesetzessammlung](https://www.zh.ch/de/politik-staat/gesetze-beschluesse/gesetzessammlung.html) — Official Canton Zurich legal collection\n- **LexFind:** [lexfind.ch](https://www.lexfind.ch/) — Cross-cantonal legislation database\n- **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) — Anthropic / Linux Foundation\n- **Related:** [swiss-courts-mcp](https://github.com/malkreide/swiss-courts-mcp) — Law text + case law = complete legal research\n- **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) — Law text + city council decisions = full context\n- **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide)\n\n<!-- mcp-name: io.github.malkreide/openlex-mcp -->\n\n<!-- BEGIN GENERATED: install -->\n## Installation\n\nRun 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`):\n\n```json\n{\n  \"mcpServers\": {\n    \"openlex-mcp\": {\n      \"command\": \"uvx\",\n      \"args\": [\n        \"openlex-mcp\"\n      ]\n    }\n  }\n}\n```\n<!-- END GENERATED: install -->\n",
  "bytes": 20225,
  "sha": "4aba5c895d893347c8ce9afe40dfdbeaee36a88bc6020024ab6543b6e86e42e2",
  "repo_slug": "malkreide/openlex-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_malkreide_openlex_mcp_9e9518f3/readme"
}