> **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)** # ๐Ÿ›๏ธ swiss-courts-mcp ![Version](https://img.shields.io/badge/version-0.5.0-blue) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![MCP](https://img.shields.io/badge/MCP-Model%20Context%20Protocol-purple)](https://modelcontextprotocol.io/) [![No Auth Required](https://img.shields.io/badge/auth-none%20required-brightgreen)](https://github.com/malkreide/swiss-courts-mcp) ![CI](https://github.com/malkreide/swiss-courts-mcp/actions/workflows/ci.yml/badge.svg) > MCP Server for Swiss court decisions โ€” Federal Supreme Court (BGer), Federal Administrative Court (BVGer), Federal Criminal Court (BStGer), and all 26 cantonal courts via entscheidsuche.ch [Deutsche Version](README.de.md)

Demo: Claude searches Swiss court decisions via MCP tool call

--- ## Overview Access Swiss court decisions from all judicial levels through a single MCP interface. Combines full-text search with structured filters for canton, court level, date range, and law references. **๐ŸŽฏ Anchor demo query:** *"Find Federal Supreme Court case law on data protection (Art. 25 DSG) since 2020 โ€” and if entscheidsuche.ch is down, still answer from the offline dump, clearly flagged."* | Source | Coverage | Data | |--------|----------|------| | [entscheidsuche.ch](https://entscheidsuche.ch) (live, default) | Federal + 26 cantons | Court decisions since ~2000 | | [SCD dump](https://doi.org/10.5281/zenodo.14867950) (offline fallback) | **Federal Supreme Court only, 2007โ€“2024** | Metadata/regesten, **no full text** | **Synergy with [fedlex-mcp](https://github.com/malkreide/fedlex-mcp):** Legislation (SR) + case law = complete legal research. **Availability:** entscheidsuche.ch is non-profit infrastructure without an SLA. When it is unreachable, the server transparently falls back to a cached public dump (see [Offline fallback](#offline-fallback)). Every response declares its origin (`source: "live" | "dump"`), and dump answers carry a `coverage_note` โ€” the fallback is **partial, not equivalent**. --- ## Features - Full-text search across all Swiss court decisions - Multi-stage law reference search with regex parser and Elasticsearch boost scoring - Dedicated Federal Supreme Court search with chamber filter - Canton and court level filtering - Recent decisions feed - Court taxonomy listing - Decision statistics with aggregations - Trilingual support (German, French, Italian) - **Offline fallback** to a cached public dump when entscheidsuche.ch is unreachable โ€” with explicit provenance on every response - No API key required --- ## Prerequisites - Python 3.11 or higher - An MCP-compatible client (Claude Desktop, Cursor, Windsurf, etc.) --- ## Installation ```bash pip install swiss-courts-mcp ``` Or install from source: ```bash git clone https://github.com/malkreide/swiss-courts-mcp.git cd swiss-courts-mcp pip install -e ".[dev]" ``` --- ## Quickstart ```bash # Run directly swiss-courts-mcp # Or via Python module python -m swiss_courts_mcp ``` --- ## Configuration ### Claude Desktop Add to your `claude_desktop_config.json`: ```json { "mcpServers": { "swiss-courts": { "command": "python", "args": ["-m", "swiss_courts_mcp"] } } } ``` ### Cloud Deployment (HTTP transport) The HTTP transport is **off by default**. The default bind host is `127.0.0.1` (loopback only) โ€” `0.0.0.0` must be opted into explicitly (the Dockerfile does this). Running HTTP without authentication logs a warning; only do so behind an authenticating reverse proxy. ```bash # Local HTTP (loopback), no auth โ€” development only swiss-courts-mcp --http --port 8000 # Container (binds 0.0.0.0, auth enabled) โ€” see Dockerfile docker build -t swiss-courts-mcp . docker run -p 8000:8000 -e MCP_AUTH_SECRET="$(openssl rand -hex 32)" swiss-courts-mcp ``` Relevant environment variables (see [`.env.example`](.env.example)): | Variable | Default | Purpose | |---|---|---| | `MCP_HOST` | `127.0.0.1` | Bind host. Set to `0.0.0.0` only in containers. | | `MCP_PORT` | `8000` | Bind port. | | `MCP_ALLOW_PUBLIC_BIND` | `false` | Suppress the `0.0.0.0` warning (containers). | | `MCP_STATELESS_HTTP` | `true` | Stateless HTTP โ†’ horizontal scaling without sticky sessions. | | `MCP_AUTH_ENABLED` | `false` | Enable bearer-token auth for HTTP. | | `MCP_AUTH_SECRET` | โ€” | HS256 signing key (dev). | | `MCP_OAUTH_JWKS_URL` | โ€” | JWKS URL for RS256 validation (production). | | `MCP_OAUTH_AUDIENCE` | โ€” | **Required with auth.** Resource identifier the IdP binds tokens to (`aud`). | | `MCP_OAUTH_ISSUER` | โ€” | **Required with auth.** Issuer of the IdP that mints the tokens (`iss`), compared character for character. | | `MCP_RESOURCE_URL` | bind address | **Public URL of this server** โ€” the RFC 9728 resource identifier. Set it on any non-loopback bind. | | `MCP_REQUIRED_SCOPES` | โ€” | Comma-separated required scopes. | | `MCP_CORS_ORIGINS` | โ€” | Comma-separated allowed origins (no wildcard in prod). | Authentication validates the user identity from the JWT `sub` claim only; see [ADR 0001](docs/adr/0001-http-auth.md). **`MCP_OAUTH_AUDIENCE` is mandatory once auth is on.** The `aud` claim is what binds a token to *this* server. Without it the verifier did not check the audience at all and accepted any correctly signed token from the same issuer โ€” including one minted for a different service (confused deputy). The server now refuses to start in auth mode without it. **`MCP_OAUTH_ISSUER` is mandatory too, for the same reason on the other side of the token.** The `iss` claim binds a token to *the* IdP this server trusts. This matters as soon as several tenants share one JWKS URL โ€” the normal case with a hosted IdP: the signature is then valid for all of them, and only `iss` separates them. Measured with the variable unset, audience correct: a token with `iss: https://another-tenant.example` was **accepted**, and so was a token with no `iss` at all. The value is compared literally per RFC 8414/9207, trailing slash included, and is therefore *not* normalised โ€” unlike `MCP_RESOURCE_URL`, where a trailing slash is trimmed. It is also what makes discovery work. `authorization_servers[0]` in the RFC 9728 document is the value an SDK client adopts as its authorization server. Without the variable the server named *itself* there โ€” and under that address `/.well-known/oauth-authorization-server`, `/.well-known/openid-configuration`, `/authorize`, `/token` and `/register` all answer 404, because this is a resource server, not an authorization server. **`MCP_RESOURCE_URL` is what an OAuth client needs.** Per RFC 9728 the server publishes a resource identifier at `/.well-known/oauth-protected-resource` and names it in the `WWW-Authenticate` header of every 401 โ€” that is where a client looks to find out where to get a token. Without the variable the server publishes its *bind* address; measured with a container bind, that was `{"resource": "http://0.0.0.0:8000", โ€ฆ}`, an address no client can dial. Set it on any non-loopback bind; the server warns at startup when it is missing. The SDK's own `validate_token_resource` stays **off** for a measured reason: it compares the token's resource indicator literally against `resource_server_url`, which here is the *bind* address. A token whose audience is not that exact URL is rejected with 401 โ€” so switching it on would lock out every real client while the audience check above is what actually protects the server. ### Offline fallback (env) | Variable | Default | Purpose | |---|---|---| | `SWISS_COURTS_FALLBACK_ENABLED` | `true` | Master switch. `0` disables the dump fallback (live-only). | | `SWISS_COURTS_FORCE_DUMP` | `false` | Force the dump path (skip live) โ€” for pre-warming the cache or offline testing. | | `SWISS_COURTS_CACHE_DIR` | `platformdirs` cache | Override the cache directory for the downloaded dump. | | `SWISS_COURTS_DUMP_RECORD` | `14867950` | Zenodo record id of the SCD dump to use. | Pre-warm the cache (downloads the ~120 MB SCD CSV once, so the first real outage does not pay the download cost): ```bash SWISS_COURTS_FORCE_DUMP=1 python -m swiss_courts_mcp # then issue one search ``` --- ## MCP Protocol Versions The server serves **two protocol eras** over the same endpoint; the client's first request decides which one it gets: | Era | Revision | Constant in `server.py` | Handshake | |-----|----------|-------------------------|-----------| | Legacy | `2025-11-25` | `HANDSHAKE_PROTOCOL_VERSION` | `initialize`, session ID | | Modern | `2026-07-28` | `MODERN_PROTOCOL_VERSION` | none โ€” per-request `_meta` envelope | Both revisions are pinned and both have a drift guard against the installed SDK, so a protocol bump stays a conscious change (constant + CHANGELOG + this section). SDK updates land monthly via Dependabot. **Spec `2026-07-28` is the era the SDK's own client picks.** `mcp.Client` probes `server/discover` and only falls back to the handshake if that is refused; against this server it does not fall back. Measured through the assembled ASGI stack in `tests/test_modern_era.py`, not inferred from constant names. What differs in the modern era: - **No `initialize`, no session ID.** Every POST is self-contained and carries `params._meta` with the protocol version and the client capabilities, plus the routing headers `MCP-Protocol-Version`, `Mcp-Method` and (for `tools/call`, `prompts/get`, `resources/read`) `Mcp-Name`. - **`server/discover`** replaces the handshake for capability discovery, and `subscriptions/listen` replaces the change notifications. - **`ping`, `logging/setLevel` and the `resources/subscribe` pair are gone.** In this era the server answers them with `-32601`; in the legacy era they remain. - **`ttlMs` / `cacheScope`** ride on the listing methods (SEP-2549) โ€” see `CACHE_HINTS`. - **`serverInfo` is stamped into the `_meta` of every response**, because there is no handshake to carry it once. It names the build version, the display title and the project URL; the purpose text stays in `instructions`, which travels once with `server/discover`. An `initialize` asking for `2026-07-28` still gets `2025-11-25` back: the handshake is not the way into the modern era, and that answer says nothing about whether the server speaks it. ## Project Phase **Phase 1 โ€” read-only** (see [ROADMAP.md](ROADMAP.md)). All tools are `readOnlyHint: true`; there are no writing or destructive operations. A move to Phase 2 (write) requires a clean re-audit and the gates listed in the roadmap. --- ## Available Tools ### Court Decision Search | Tool | Description | |------|-------------| | `search_court_decisions` | Full-text search across all court decisions with canton, court level, and date filters | | `get_court_decision` | Retrieve a single decision by its unique signature | | `search_bger_decisions` | Search Federal Supreme Court decisions with optional chamber filter | | `search_by_law_reference` | Find decisions citing a specific law article (e.g., "Art. 8 BV") | ### Court Information | Tool | Description | |------|-------------| | `list_courts` | List all indexed courts, optionally filtered by canton | | `get_recent_decisions` | Latest decisions, filterable by canton and court level | | `get_decision_statistics` | Statistics on indexed decisions by canton and year | | `get_fallback_status` | Offline-dump cache state, coverage, version, pre-warming (read-only) | ### Tool Annotations All eight tools share the same hints โ€” they are read-only, idempotent, non-destructive, and reach an external system: | Annotation | Value | |---|---| | `readOnlyHint` | `true` | | `destructiveHint` | `false` | | `idempotentHint` | `true` | | `openWorldHint` | `true` | A `rechtsrecherche` **prompt** is also provided (a second MCP primitive alongside tools). ### Example Use Cases | Use Case | Tool Chain | |----------|------------| | Research case law on data protection | `search_court_decisions("Datenschutz")` | | Find practice on a constitutional right | `search_by_law_reference("Art. 8 BV")` | | Latest Federal Supreme Court rulings | `search_bger_decisions("Arbeitsrecht", date_from="2024-01-01")` | | Combined: Law text + case law | `fedlex_search_laws("DSG")` then `search_by_law_reference("Art. 25 DSG")` | [โ†’ More use cases by audience โ†’](EXAMPLES.md) --- ## Architecture ``` โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ MCP Client (LLM) โ”‚ โ”‚ Claude / Cursor / Windsurf โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ MCP Protocol โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ swiss-courts-mcp โ”‚ โ”‚ 8 tools ยท Pydantic validation โ”‚ โ”‚ Elasticsearch query builder โ”‚ โ”‚ Provenance envelope: source = live | dump โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ‘  live (default) โ”‚ โ‘ก fallback โ”‚ HTTPS POST/GET โ”‚ on bot-block / 5xx / 429 / โ”‚ โ”‚ timeout, or SWISS_COURTS_FORCE_DUMP=1 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ entscheidsuche.ch โ”‚ โ”‚ SCD dump โ€” Zenodo 14867950 (CC BY) โ”‚ โ”‚ Elasticsearch backend โ”‚ โ”‚ lazy download โ†’ platformdirs cache โ”‚ โ”‚ Federal + 26 cantons โ”‚ โ”‚ โ†’ local SQLite search โ”‚ โ”‚ no auth ยท no SLA โ”‚ โ”‚ BGer only ยท 2007โ€“2024 ยท no full text โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` Live-first, always: the offline dump only engages on an availability failure (bot-block, HTTP 5xx/429, timeout) or when forced. It is a behaviour of the existing tools, not a separate search tool โ€” why this source and not the full-text one is in [ADR 0002](docs/adr/0002-offline-fallback.md); what it does and does not cover is under [Known Limitations](#known-limitations). Inspect the cache at any time with `get_fallback_status`. --- ## Safety & Limits | Aspect | Details | |--------|---------| | **Access** | Read-only (`readOnlyHint: true`) โ€” the server cannot modify or delete any data | | **Personal data** | No personal data โ€” all decisions are public court rulings | | **Rate limits** | Built-in per-query caps (max 50 results per search, 50 aggregation buckets) | | **Timeout** | 30 seconds per API call | | **Data source auth** | No API keys required โ€” entscheidsuche.ch is publicly accessible | | **HTTP transport auth** | Optional bearer-token auth (JWT, `sub`-claim identity); see [ADR 0001](docs/adr/0001-http-auth.md) | | **Egress** | Code-layer allow-lists (`entscheidsuche.ch` for live; `zenodo.org` for the offline dump), HTTPS-enforced; see [egress policy](docs/network-egress.md) | | **Error masking** | Internal exceptions are logged server-side only; clients receive friendly messages | | **Secrets** | No secrets in code/logs; `.env` git-ignored, Gitleaks on PRs; see [secret management](docs/secret-management.md) | | **Licenses** | Court decisions are public domain under Swiss law ([BGG Art. 27](https://www.fedlex.admin.ch/eli/cc/2006/218/de#art_27)) | | **Terms of Service** | Subject to [entscheidsuche.ch](https://entscheidsuche.ch) usage terms โ€” please be kind to the server | --- ## Known Limitations - Search is limited to decisions indexed by entscheidsuche.ch (not all decisions are publicly available) - Full-text document content is not returned โ€” only metadata, title, and abstract - Statistics depend on Elasticsearch aggregation support of the backend - The court taxonomy structure from `Facetten_alle.json` may vary **Offline fallback (partial coverage โ€” read this):** the fallback is a safety net for availability, **not an equivalent mirror** of the live source: - **Court scope:** Federal Supreme Court only (BGer/BGE). Bundesverwaltungsgericht, Bundesstrafgericht and **all 26 cantonal courts are not covered.** - **Time span:** 2007 โ€“ December 2024 (the SCD dump's range). Decisions outside this window are not in the dump. - **Content:** metadata/regesten only โ€” **no full text** offline. - **Update latency:** the SCD dump is refreshed roughly quarterly on Zenodo, so the offline data lags the live index. `get_fallback_status` reports the cached version and can check Zenodo for a newer one. - **Law-reference search** offline only matches references named in the decision's subject/regest (`topic`/`issue`) โ€” there is no offline cited-law index. - **`get_court_decision` is best-effort offline:** SCD case ids (`docref`, e.g. `1C_517/2016`) differ from entscheidsuche signatures, so some lookups are honestly reported as non-resolvable. - Responses always disclose their origin via `source` (`live`/`dump`) and a `coverage_note`; the server never silently narrows coverage โ€” an uncovered query gets an explicit "not covered" answer, never a silent empty result. --- ## Testing Unit tests mock all HTTP with `respx`. Run from the project root. The five gates CI runs โ€” `check_gate_docs.py` holds this list against `ci.yml`, so it cannot quietly fall behind: ```bash PYTHONPATH=src pytest tests/ -m "not live" python scripts/check_ruff_pin.py ruff check src/ tests/ scripts/ ruff format --check src/ tests/ scripts/ python scripts/check_version_sync.py python scripts/check_gate_docs.py ``` The live tests are not a gate โ€” they hit the real source and run on a schedule ([`live.yml`](.github/workflows/live.yml)), not on pull requests: ```bash PYTHONPATH=src pytest tests/ -v -m live ``` Editing `live.yml` is a special case: GitHub only honours `schedule` on the default branch, so changes take effect after the merge โ€” trigger it by hand (`workflow_dispatch`) to test them before that. The offline-fallback tests mock the Zenodo download with `respx` and use a small committed fixture โ€” the ~120 MB dump is never downloaded in CI. --- ## Changelog See [CHANGELOG.md](CHANGELOG.md). --- ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). --- ## Security See [SECURITY.md](SECURITY.md) for the security posture and how to report a vulnerability. --- ## License [MIT](LICENSE) --- ## Author Hayal Oezkan ยท [malkreide](https://github.com/malkreide) --- ## Credits & Related Projects - [entscheidsuche.ch](https://entscheidsuche.ch) โ€” Swiss court decision search engine (live source) - **Swiss Federal Supreme Court Dataset (SCD)** โ€” offline fallback source, **CC BY 4.0**: Geering, F. & Merane, J. (2025). *Swiss Federal Supreme Court Dataset (SCD)*, Version 2024-3. Zenodo. https://doi.org/10.5281/zenodo.14867950 - [fedlex-mcp](https://github.com/malkreide/fedlex-mcp) โ€” MCP Server for Swiss federal law (legislation synergy) - [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) โ€” MCP Server for Zurich open data - [Model Context Protocol](https://modelcontextprotocol.io/) โ€” Open protocol for AI tool integration ## 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": { "swiss-courts-mcp": { "command": "uvx", "args": [ "swiss-courts-mcp" ] } } } ```