Swiss court decisions via entscheidsuche.ch, including federal and cantonal courts
Runs on your machine from its package; the steps are below.
At most: only reads or looks up information.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted β your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
uvx swiss-courts-mcp
MCP client config
{
"mcpServers": {
"swiss-courts": {
"command": "uvx",
"args": [
"swiss-courts-mcp"
]
}
}
}Part of the Swiss Public Data MCP Portfolio
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
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 (live, default) | Federal + 26 cantons | Court decisions since ~2000 |
| SCD dump (offline fallback) | Federal Supreme Court only, 2007β2024 | Metadata/regesten, no full text |
Synergy with 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). Every response declares its origin (source: "live" | "dump"), and dump answers carry a coverage_note β the fallback is partial, not equivalent.
pip install swiss-courts-mcp
Or install from source:
git clone https://github.com/malkreide/swiss-courts-mcp.git
cd swiss-courts-mcp
pip install -e ".[dev]"
# Run directly
swiss-courts-mcp
# Or via Python module
python -m swiss_courts_mcp
Add to your claude_desktop_config.json:
{
"mcpServers": {
"swiss-courts": {
"command": "python",
"args": ["-m", "swiss_courts_mcp"]
}
}
}
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.
# 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):
| 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.
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.
| 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):
SWISS_COURTS_FORCE_DUMP=1 python -m swiss_courts_mcp # then issue one search
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:
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.
Phase 1 β read-only (see 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.
| 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") |
| 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) |
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).
| 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 β
βββββββββββββββββββββββββββββββββββββββ
β 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; what it does
and does not cover is under Known Limitations. Inspect
the cache at any time with get_fallback_status.
| 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 |
| Egress | Code-layer allow-lists (entscheidsuche.ch for live; zenodo.org for the offline dump), HTTPS-enforced; see egress policy |
| 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 |
| Licenses | Court decisions are public domain under Swiss law (BGG Art. 27) |
| Terms of Service | Subject to entscheidsuche.ch usage terms β please be kind to the server |
Facetten_alle.json may varyOffline fallback (partial coverage β read this): the fallback is a safety net for availability, not an equivalent mirror of the live source:
get_fallback_status reports the cached
version and can check Zenodo for a newer one.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.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.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:
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), not on pull requests:
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.
See CHANGELOG.md.
See CONTRIBUTING.md.
See SECURITY.md for the security posture and how to report a vulnerability.
Hayal Oezkan Β· malkreide
Run via 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):
{
"mcpServers": {
"swiss-courts-mcp": {
"command": "uvx",
"args": [
"swiss-courts-mcp"
]
}
}
}