> πŸ‡¨πŸ‡­ **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)** # register-mcp ![Version](https://img.shields.io/badge/version-0.6.1-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/register-mcp) ![CI](https://github.com/malkreide/register-mcp/actions/workflows/ci.yml/badge.svg) > MCP Server for the Swiss Federal Commercial Register (Zefix/Handelsregister), with a **company-UID join** to the official gazettes (SHAB + cantonal AmtsblΓ€tter) [πŸ‡©πŸ‡ͺ Deutsche Version](README.de.md) --- ## Overview `register-mcp` provides AI-native access to **two** Swiss federal data sources, joined on the UID, all without authentication: | Source | Data | API | |--------|------|-----| | **Zefix (Handelsregister)** | Swiss companies, legal forms, registered-office data | ZefixREST v1 | | **Amtsblattportal** | Everything published **about a specific company** (by its UID): HR mutations, calls to creditors, bankruptcy | amtsblattportal.ch v1 | The two sources share one key β€” the **UID**. The value is in the join: **Zefix tells you whether a company exists; the gazette tells you what has been published about it.** The gazette access here is deliberately **company-scoped only** β€” keyed on a company UID or a specific publication id. There is **no free-text / person-name gazette search** in this server; that would be a profiling tool over the gazette's person-data rubrics (bankruptcy, debt-collection, inheritance). Broad Amtsblatt platform search (procurement, cantonal notices, full-text) is proposed as a separate `amtsblatt-mcp` β€” see [`docs/amtsblatt-mcp-proposal.md`](docs/amtsblatt-mcp-proposal.md) and the **Data Protection & Scope** section below. Designed for Swiss public administration use cases: vendor verification, contract partner due diligence, and supplier onboarding β€” all via natural language queries. **Anchor demo query:** *"Before we sign a framework agreement with Lehrmittelverlag ZΓΌrich AG: is the company active in the commercial register, what is its UID and stated purpose β€” and, via that UID, what has the official gazette published about it (HR mutations, calls to creditors, any bankruptcy)?"* That single question walks the whole tool chain across both sources: ``` zefix_search_company β†’ zefix_verify_company β†’ gazette_company_publications(uid=…) β†’ gazette_get_publication(id=…) ``` --- ## Features - πŸ›οΈ **9 tools** across two sources β€” company search & verification (Zefix) + the company-scoped gazette join (SHAB/cantonal) - πŸ”— **`gazette_company_publications`** β€” the UID join: everything published about a company - πŸ›‘οΈ **Data-protection-safe by construction** β€” the only gazette entry points are UID- or id-scoped; no person-name search entry exists (see *Data Protection & Scope*) - πŸ” **`zefix_verify_company`** β€” quick active/dissolved status check - 🌐 **Bilingual output** (Markdown / JSON) with per-source attribution + `provenance` - πŸ”“ **No API key required** β€” open data from zefix.admin.ch and amtsblattportal.ch - ☁️ **Dual transport** β€” stdio (Claude Desktop) + SSE (cloud) --- ## Prerequisites - Python 3.11+ - [uv](https://github.com/astral-sh/uv) (recommended) or pip --- ## Installation ```bash # Clone the repository git clone https://github.com/malkreide/register-mcp.git cd register-mcp # Install pip install -e . # or with uv: uv pip install -e . ``` Or with `uvx` (no permanent installation): ```bash uvx register-mcp ``` --- ## Quickstart ```bash # stdio (for Claude Desktop) python -m register_mcp.server # SSE (cloud deployment) β€” MCP_API_KEY is REQUIRED MCP_API_KEY=$(openssl rand -hex 32) MCP_TRANSPORT=sse PORT=8000 \ python -m register_mcp.server ``` ### SSE / Cloud Deployment When running with `MCP_TRANSPORT=sse`, the server enforces: - **Bearer-token auth** β€” set `MCP_API_KEY` to a secret string. Clients must send `Authorization: Bearer ` on every request. Missing or wrong β†’ HTTP 401. The server refuses to start without `MCP_API_KEY` set. - **Rate limiting** β€” sliding window per bearer-token hash. Defaults: 60 req / 60 s. Tunable via `MCP_RATE_LIMIT` and `MCP_RATE_WINDOW`. Exceeding the limit returns HTTP 429 with `Retry-After`. - **Structured JSON logging** β€” every tool call emits one line to stderr with `tool`, `status`, `latency_ms`. Auth failures and rate-limit events are logged at WARNING level. Configure verbosity with `LOG_LEVEL` (default `INFO`). - **Reference-data cache** β€” Zefix legal-forms are cached for 24h (`LEGAL_FORMS_TTL` seconds) to avoid an extra upstream call per tool invocation. - **Egress allow-list** β€” outbound HTTP is restricted to `www.zefix.admin.ch` and `amtsblattportal.ch` via an `httpx` request hook that also fires on redirects. A `Location` header pointing elsewhere raises `EgressDenied` and is never followed. Override with `MCP_ALLOWED_HOSTS=host1,host2` (comma-separated, lower-case). > ⚠️ **Upgrade note (0.2.x β†’ 0.3.0):** `amtsblattportal.ch` was added to the > **default** allow-list when the gazette tools shipped. If your deployment > **pins** `MCP_ALLOWED_HOSTS`, that value overrides the default entirely β€” > add `amtsblattportal.ch` to it, or every `gazette_*` call will raise > `EgressDenied`. - **Optional OpenTelemetry tracing** β€” install with `pip install register-mcp[otel]` and set `OTEL_EXPORTER_OTLP_ENDPOINT` (e.g. `http://otel-collector:4318/v1/traces`). Without the extra or without the env var the server stays silent β€” no hard dependency on the OTel SDK. For multi-instance deployments, place a real gateway (Cloudflare, Railway internal networking, an API-Gateway with Redis-backed rate limiting) in front of the in-memory limiter, which is per-process by design. ### Container deployment A minimal multi-stage `Dockerfile` ships with the repo. The image runs as a non-root `mcp` user; dependencies are resolved from `uv.lock` (`uv sync --frozen`), so the build is reproducible. ```bash docker build -t register-mcp:local . docker run --rm -p 8000:8000 \ -e MCP_TRANSPORT=sse \ -e MCP_API_KEY="$(openssl rand -hex 32)" \ register-mcp:local ``` For local iteration there is a `compose.yaml` with `read_only`, `cap_drop: ALL` and `no-new-privileges`: ```bash MCP_API_KEY=$(openssl rand -hex 32) docker compose up --build ``` See [SECURITY.md](SECURITY.md) for hardening notes (egress restriction, key rotation, SIEM forwarding). Try it immediately in Claude Desktop: > *"Is Lehrmittelverlag ZΓΌrich AG active in the commercial register?"* > *"Look up the company with UID CHE-108.954.978"* > *"List all Swiss legal forms"* --- ## Configuration ### Claude Desktop Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): ```json { "mcpServers": { "register": { "command": "python", "args": ["-m", "register_mcp.server"] } } } ``` Or with `uvx`: ```json { "mcpServers": { "register": { "command": "uvx", "args": ["register-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 register_mcp.server --http --port 8000` 4. 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 **Zefix β€” commercial register (6):** | Tool | Description | |------|-------------| | `zefix_search_companies` | Search companies by name, canton, legal form | | `zefix_get_company` | Full company profile by internal EHRAID | | `zefix_get_company_by_uid` | Company lookup by UID (CHE-xxx.xxx.xxx) | | `zefix_verify_company` | Quick active/dissolved status check | | `zefix_list_legal_forms` | All Swiss legal forms with IDs | | `zefix_list_municipalities` | Swiss municipalities with BFS IDs | **Amtsblattportal β€” the company-scoped gazette join (3):** | Tool | Description | |------|-------------| | `gazette_company_publications` | **The UID join.** All gazette publications for a company **UID**, newest first, optional (validated) rubric/time filters | | `gazette_get_publication` | Single publication incl. XML full text, defensively parsed (by publication id) | | `gazette_source_status` | Reachability of both sources + cache ages (rubrics, legal forms) | The prefix is `gazette_`, not `shab_`, because the source covers SHAB **and** the cantonal gazettes. Every entry point is UID- or id-scoped β€” see **Data Protection & Scope**. Broad, non-company gazette search (procurement, cantonal full-text) is scoped to the separate [`amtsblatt-mcp`](docs/amtsblatt-mcp-proposal.md). ### Example Use Cases | Query | Tool | |-------|------| | *"Is Lehrmittelverlag ZΓΌrich AG active?"* | `zefix_verify_company` | | *"Look up CHE-108.954.978"* | `zefix_get_company_by_uid` | | *"Find companies named Migros in canton ZH"* | `zefix_search_companies` | | *"What has been published about CHE-116.115.052?"* | `gazette_company_publications` | | *"Show the full official text of that HR deletion notice"* | `gazette_get_publication` | | *"Are both data sources reachable right now?"* | `gazette_source_status` | --- ## Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Άβ”‚ Zefix (Handelsregister) β”‚ β”‚ β”‚ www.zefix.admin.ch β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β” β”‚ ZefixREST/api/v1 β”‚ β”‚ Claude / AI │────▢│ register-mcp β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ (MCP Host) │◀────│ (MCP Server) β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ 9 Tools (zefix_ + gazette_) β”œβ”€β”€β–Άβ”‚ Amtsblattportal β”‚ β”‚ Stdio | SSE β”‚ β”‚ amtsblattportal.ch/api/v1 β”‚ β”‚ Egress allow-list β”‚ β”‚ SHAB + cantonal gazettes β”‚ β”‚ No authentication required β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ join key: UID (CHE-XXX.XXX.XXX) ``` ### Data Source Characteristics | Source | Protocol | Coverage | Auth | |--------|----------|----------|------| | Zefix | REST/JSON | Swiss companies, legal forms, registered offices | None | | Amtsblattportal | REST/JSON (list) + XML (full text) | SHAB + cantonal gazettes, 2.79M publications | None | | ZefixPublicREST (planned) | REST/JSON | Signatories, capital, full history | Basic Auth (free) | | UID Register (planned) | SOAP | MwSt, NOGA codes, cross-validation | Public (20 req/min) | ### The UID join β€” Zefix ↔ Amtsblatt The two sources share exactly one key: the **UID** (`CHE-XXX.XXX.XXX`). That is what turns them from two data sets into one workflow. ``` zefix_get_company_by_uid(uid) # Zefix: does the company exist? status, purpose, legal form β”‚ UID β–Ό gazette_company_publications(uid) # Gazette: everything published about it (HR, KK, SB, LS, …) β”‚ publication id β–Ό gazette_get_publication(id) # Full official text from the per-rubric XML ``` Two properties of the source shape this path (both verified in [`docs/probe-shab.md`](docs/probe-shab.md)): - The **bulk list carries no company UID** (`meta.uid` is `null`). The company UID lives only in the **single-publication fetch** β€” `meta.uid` in the single JSON, or `` in the XML (which also carries the full text). So the join runs *list β†’ per-hit single fetch β†’ match against the Zefix UID*. - `gazette_company_publications` filters the corpus by `uids=` directly, so in practice you get the company's publications in one call without walking every record. ### Procurement lives in the separate `amtsblatt-mcp` Public procurement (Submissionen) is **not** a federal SHAB rubric and is **not** covered by this server. It exists only as a **cantonal** `OB-` rubric, only a few cantons publish it in this portal, and most β€” including **ZΓΌrich** β€” route tenders through **[simap.ch](https://www.simap.ch/)**, a separate platform. Procurement, cantonal notices, and broad full-text search are scoped to the proposed [`amtsblatt-mcp`](docs/amtsblatt-mcp-proposal.md) server, which applies a fail-closed **green-rubric allow-list**. See that proposal for the full `OB-*` coverage map and the rubric traffic-light table. > **`SB` β‰  Submissionen.** `SB` is *Schuldbetreibungen* (debt collection), a > person-data-heavy rubric this server never exposes as a search entry. --- ## Data Protection & Scope This section is **not** a footnote β€” it is the reason the server is shaped the way it is. The Amtsblattportal systematically publishes rubrics containing personal data of **natural** persons: bankruptcies (`KK`), debt-collection (`SB`), calls to creditors (`LS`/`SR`), inheritance/estate calls (`ES`, `TE-*`), and building applications with owner names. Those publications are public β€” but making them *systematically queryable by name* through an AI agent is a repurposing the publication never intended, and under the revised Swiss Federal Act on Data Protection (**revDSG**) a "show me every debt-collection entry for person X" tool is a profiling instrument. Deliberate design choices follow: - **No person-based search entry.** No tool takes a natural person's name, birth date or address. The only gazette entry points are keyed on a **company UID** (`gazette_company_publications`) or an opaque **publication id** (`gazette_get_publication`). A firm's own bankruptcy *is* returned via its UID β€” that is corporate data about a legal person, not name-based profiling. - **No free-text gazette search here.** `keyword` and `cantons` are not even on the internal query-parameter allow-list, so no future code change can smuggle a corpus-wide keyword search in. Broad search lives in `amtsblatt-mcp` behind a fail-closed green allow-list (procurement, HR, official notices only). - **No persistence of publication content.** The server is a pass-through; only the rubric taxonomy and Zefix legal-forms list are cached in memory (24 h). Official publications carry statutory deletion periods β€” a store that outlived them would actively undermine those periods. - **Fail closed.** Rubric codes are validated against the live taxonomy before any call; an unknown code is refused, not silently widened. The broad-platform counterpart, its green/yellow/red rubric classification and its fail-closed design are specified in [`docs/amtsblatt-mcp-proposal.md`](docs/amtsblatt-mcp-proposal.md). --- ## Architecture decision **ARCH A β€” live-API-only**, consistent with the existing Zefix integration (decided 2026-07-18). The Amtsblattportal is queried live on every call. All endpoints respond in 0.2–2.0 s, and the use case β€” targeted company and topic research β€” does not need a local bulk copy. A bulk dump would mean mirroring 2.79M records, with an ongoing sync burden and staleness risk, for no benefit to the join-on-UID workflow. The taxonomy (`/rubrics`) and the Zefix legal-forms list are the only data cached, each for 24h in memory, because they change at most a few times a year and every filtered call needs them. --- ## Phased Implementation | Phase | API | Auth | Status | |-------|-----|------|--------| | **Phase 1** | `ZefixREST/api/v1` | None | **Current** | | **Phase 2** | `ZefixPublicREST/api/v1` | Basic Auth (free, email zefix@bj.admin.ch) | Planned | | **Phase 3** | UID-Register SOAP | Public (20 req/min) | Planned | Phase 2 will add: signatory details, share capital, full historical entries. Phase 3 will add: MwSt status, NOGA industry codes, cross-register validation. --- ## Project Structure ``` register-mcp/ β”œβ”€β”€ src/register_mcp/ β”‚ β”œβ”€β”€ __init__.py # Package β”‚ └── server.py # 9 tools (Zefix + company-scoped gazette join) β”œβ”€β”€ tests/ β”‚ β”œβ”€β”€ test_server.py # Zefix unit + integration tests (mocked HTTP) β”‚ β”œβ”€β”€ test_gazette.py # Gazette tools + the three quirks (mocked HTTP) β”‚ └── test_egress.py # Egress allow-list β”œβ”€β”€ docs/ β”‚ β”œβ”€β”€ probe-shab.md # Phase-1 live probe of amtsblattportal.ch β”‚ β”œβ”€β”€ amtsblatt-mcp-proposal.md# Spec for the separate broad-platform server β”‚ └── demo/ # vhs demo script + standalone CLI demo β”œβ”€β”€ .github/workflows/ci.yml # GitHub Actions (Python 3.11/3.12/3.13) β”œβ”€β”€ pyproject.toml β”œβ”€β”€ CHANGELOG.md β”œβ”€β”€ CONTRIBUTING.md β”œβ”€β”€ LICENSE β”œβ”€β”€ README.md # This file (English) └── README.de.md # German version ``` --- ## Known Limitations - Search by canton without a name filter may return API errors (Zefix API limitation) - Phase 1 Zefix API may be rate-limited under heavy load; retry after a short delay - ZefixPublicREST (new API) requires registration: email zefix@bj.admin.ch ### Amtsblattportal β€” verified behaviour (live-checked 2026-07-18) | Call | HTTP | Status | Records | Note | |---|---|---|---|---| | `/publications?publicationStates=PUBLISHED` | 200 | OK | 2,790,323 | baseline (full corpus) β€” never queried unfiltered | | `?uids=CHE-116.115.052` | 200 | **OK** | 4 | **the join β€” core (and only) gazette entry** | | `?uids=…&rubrics=HR` | 200 | OK | – | optional, validated rubric narrowing on the join | | `/publications/{id}/xml` | 200 | OK | – | full text, rubric-specific schema | | `/rubrics` | 200 | OK | – | taxonomy (for code validation) | | `?rubrics=ZZZZ` (invalid) | **200** | **Silent Empty** | 0, `total: null` | Quirk 2 | | `?uid=…` (wrong param name) | **200** | **Silent Ignore** | **2,790,323** | Quirk 1 | > Free-text (`keyword`) and broad `cantons` search are **not** performed by this > server β€” those probe results live in [`docs/probe-shab.md`](docs/probe-shab.md) > and inform the separate `amtsblatt-mcp`. ### Zefix β€” verified behaviour (live-checked 2026-08-15) Found by the weekly live suite, not by the unit tests β€” which stayed green throughout. | Call to `firm/search.json` | HTTP | Result | |---|---|---| | `{"name": "Migros", …}` | 200 | 35 hits | | a name with no hits | **404** | NORESULT envelope β€” *not* an empty 200 | | `{"uid": "109741634", …}` | **400** | Bad Request β€” there is no `uid` field | | `{"name": "CHE-999.999.999", "searchType": "CONTAINS"}` | 200 | **Β«CHEMAM - 999Β»**, UID CHE-113.593.998 | | a dissolved firm without `activeOnly: false` | 404 | NORESULT β€” as if it never existed | **Three shapes, one shipped bug each:** - **No hits answer with HTTP 404**, carrying the NORESULT envelope. Every call therefore goes through `_zefix_post_search`; a raw `raise_for_status()` makes the friendly branch unreachable. That is how `zefix_verify_company` shipped answering *"Eintrag nicht gefunden. Bitte EHRAID oder UID prΓΌfen"* to a **name** search, where neither an EHRAID nor a UID was in play. A fixture that puts the NORESULT body into a 200 makes exactly that dead branch look green. - **A hit list is not an answer.** UID lookup searches the *name* field with `searchType: CONTAINS`, so `CHE-999.999.999` returns a real company under a UID that is not its own. Defence: exact digit match or nothing β€” no `firms[0]` fallback. The former fallback produced a complete, plausible, formatted record about somebody else, indistinguishable from a correct answer. - **Without `activeOnly: false`, "dissolved" looks like "never existed".** Zefix returns only active entries by default; `zefix_verify_company` sets the flag deliberately. A firm with no UID comes back as a **string of blanks** (`uid: " "`, `uidFormatted: null`), not as `null`. **Three quirks are defended in code** (details in the [CHANGELOG](CHANGELOG.md) under *Known findings*): - **Quirk 1 β€” Silent Ignore (critical).** Unknown query parameters are dropped silently and return the full 2.79M corpus with HTTP 200. Defence: query strings are built exclusively from an `ALLOWED_GAZETTE_PARAMS` allow-list, and every filtered response is plausibility-checked β€” a `total` above 2,000,000 is rejected as *"filter ignored by upstream β€” result not trustworthy"*. - **Quirk 2 β€” Silent Empty.** An invalid rubric code returns HTTP 200 with an empty result. Defence: the `/rubrics` taxonomy is cached 24h and every code is validated **before** any call, failing with the five closest valid codes. - **Quirk 3 β€” Two-step fetch.** The JSON list carries only `meta`; the content lives only in the per-rubric namespaced XML. Defence: namespace-agnostic defensive parsing (`meta` + `publicationText` mandatory, HR `company` when present, everything else in `additional_fields`). --- ## Safety & Limits ### Rate Limits | API | Limit | Notes | |-----|-------|-------| | ZefixREST (Phase 1) | Not officially documented | Throttling possible under heavy load β€” retry after 1–2 s | | ZefixPublicREST (Phase 2) | Not officially documented | Requires prior registration (free) | | UID-Register SOAP (Phase 3) | **20 req/min** | Hard limit, publicly documented | ### Data Privacy - **Read-only access** β€” all tools carry `readOnlyHint: True`; the server performs no write, delete, or mutation operations against any API - **No person-based search entry** β€” no tool accepts a natural person's name, birth date or address; gazette access is UID- or publication-id-scoped only (see **Data Protection & Scope**). This is a deliberate revDSG-driven design choice, not an accident of the API - **No persistence of publication content** β€” the server is a stateless pass-through; only the rubric taxonomy and Zefix legal-forms list are cached in memory (24 h), never publication bodies, so statutory deletion periods are respected - **Public register data only** β€” the Zefix Handelsregister is a public federal register (HRegV); gazette data returned is likewise legally public, retrieved per company UID - **No personal tracking** β€” the server does not transmit user identity, query history, or session data to the upstream sources ### Terms of Service & Data Sources - **Zefix API ToS:** Usage of the Zefix REST API is governed by the [zefix.admin.ch terms of use](https://www.zefix.admin.ch). The data is published under the [Open Government Data (OGD) Switzerland](https://opendata.swiss/) principles. - **SHAB:** Swiss Official Gazette of Commerce β€” published by the Federal Chancellery (BK). Public by law. - **Institutional use:** This server is designed for read-only queries in public administration workflows. Not suitable for mass harvesting or automated surveillance use cases. ### Security - No credentials are stored or transmitted (Phase 1) - Phase 2 credentials (`ZEFIX_USER`, `ZEFIX_PASSWORD`) are passed via environment variables only β€” never hardcoded - All HTTP calls use HTTPS exclusively - Tool inputs are validated via Pydantic v2 before any API call is made --- ## Demo ![register-mcp demo](assets/demo.png) > πŸ“½οΈ *Terminal GIF coming soon β€” see [`docs/demo/`](docs/demo/) to generate it locally with [vhs](https://github.com/charmbracelet/vhs)* **Example interaction:** ``` User: "Is Lehrmittelverlag ZΓΌrich AG active in the commercial register?" β†’ Tool: zefix_verify_company(name="Lehrmittelverlag ZΓΌrich AG") Claude: βœ… Lehrmittelverlag ZΓΌrich AG is ACTIVE in the Handelsregister. UID: CHE-404.020.972 | Canton: ZH | Legal form: AG Last SHAB mutation: 2023-07-27 ``` [β†’ More use cases by audience β†’](EXAMPLES.md) To generate the demo GIF locally: ```bash # Install vhs (macOS/Linux) brew install vhs # macOS # or: go install github.com/charmbracelet/vhs@latest # Generate vhs docs/demo/demo.tape # β†’ outputs docs/demo/demo.gif ``` --- ## MCP Protocol Version This server speaks **two protocol eras** over the same endpoint. The client's first request on a connection decides which one applies; a later claim from the other era is refused. | Era | Revision | Who reaches it | |---|---|---| | `initialize` handshake | `2024-11-05` … **`2025-11-25`** | What today's clients speak. The server answers with the revision asked for, or with the `2025-11-25` ceiling when the request asks for something newer. | | Per-request envelope | **`2026-07-28`** | A request carrying the `2026-07-28` `_meta` envelope opens a modern connection. | Both revisions are pinned in [`tests/test_protocol_version.py`](tests/test_protocol_version.py) and asserted against the installed SDK, so a Dependabot bump of `mcp` cannot move either one silently. This server builds no ASGI app to send an `initialize` through, so the gate asserts the SDK constants rather than a measured response β€” the weaker form, named rather than left unsaid. Note that the SDK's `LATEST_PROTOCOL_VERSION` is an alias for the **modern** era, not for the handshake era β€” pinning against it alone would leave the era that current clients actually negotiate free to drift. **Update policy.** When the gate fails, do not edit the constant blindly: read the spec changelog between the two revisions, verify the server still behaves, then move the constant, this section, `README.de.md` and [`CHANGELOG.md`](CHANGELOG.md) together. --- ## Testing ```bash # Unit tests (no API key required) PYTHONPATH=src pytest tests/ -m "not live" # Integration tests (live API calls) pytest tests/ -m "live" # Re-record the fixtures from the live sources (writes tests/fixtures/PROVENANCE.md) python scripts/record_fixtures.py ``` The unit-test payloads are **recorded, not invented**. Source, retrieval date, selection rule, **redaction** and SHA-256 per file are in [`tests/fixtures/PROVENANCE.md`](tests/fixtures/PROVENANCE.md). Two things are stated there rather than papered over. **Personal data:** the gazette carries debt-collection notices and Zefix carries the full SHAB text naming registered persons with their place of residence β€” the recorded payloads keep the shape and redact those values, with the complete list of redacted fields alongside. **Zefix needs no credentials:** until 2026-08-08 this repository recorded no Zefix fixtures because the recording script measured HTTP 401. The measurement was right about the wrong address β€” the script asked `ZefixPublicREST`, while the server speaks to `ZefixREST`, which answers with no authentication at all. ### The live suite `ci.yml` runs `-m "not live"`: a foreign 503 must not redden a stranger's pull request, because a suite that does gets switched off, and a switched-off suite checks nothing. The exclusion has a safety net β€” [`.github/workflows/live-tests.yml`](.github/workflows/live-tests.yml) runs weekly (`cron: "31 5 * * 1"`) plus `workflow_dispatch`. The verdict is read from the JUnit XML rather than the exit code, by [`scripts/classify_live_run.py`](scripts/classify_live_run.py), because a live run has three answers and not two: | State | Meaning | Issue | |---|---|---| | `clear` | the suite ran and was green | closes an open one | | `finding` | the suite ran and something fell | opens or updates one | | `unknown` | the suite did **not** run β€” failed install, timeout, renamed marker, everything skipped | left untouched | `tests - skipped == 0` is `unknown`, not `clear`: pytest exits 0 when every test was skipped, and a job that books that as green closes an issue on a comparison that never happened. One caveat when editing that workflow: the pull-request checks do **not** cover it β€” it has no `push` or `pull_request` trigger, so a green PR says nothing about it. Verify changes with a manual `workflow_dispatch` run on the branch *before* merging. --- ## Changelog See [CHANGELOG.md](CHANGELOG.md) --- ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) --- ## Security See [SECURITY.md](SECURITY.md) ([Deutsch](SECURITY.de.md)) for the security posture and how to report a vulnerability. --- ## License MIT License β€” see [LICENSE](LICENSE) --- ## Author Hayal Oezkan Β· [malkreide](https://github.com/malkreide) --- ## Credits & Related Projects - **Zefix:** [zefix.admin.ch](https://www.zefix.admin.ch/) β€” Swiss Federal Commercial Register (BJ/FOJ) - **Amtsblattportal:** [amtsblattportal.ch](https://amtsblattportal.ch/) β€” SHAB and cantonal gazettes (SECO / Swiss Confederation) - **Protocol:** [Model Context Protocol](https://modelcontextprotocol.io/) β€” Anthropic / Linux Foundation - **Related:** [fedlex-mcp](https://github.com/malkreide/fedlex-mcp) β€” Commercial register ordinance (HRegV) - **Related:** [zurich-opendata-mcp](https://github.com/malkreide/zurich-opendata-mcp) β€” Company seat + geodata - **Related:** [swiss-statistics-mcp](https://github.com/malkreide/swiss-statistics-mcp) β€” Industry statistics by NOGA - **Related:** [swiss-snb-mcp](https://github.com/malkreide/swiss-snb-mcp) β€” Economic indicators - **Portfolio:** [Swiss Public Data MCP Portfolio](https://github.com/malkreide) ## 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": { "register-mcp": { "command": "uvx", "args": [ "register-mcp" ] } } } ```