{
  "markdown": "> 🇨🇭 **Part of the [Swiss Public Data MCP Portfolio](https://github.com/malkreide)**\n\n# 📰 amtsblatt-mcp\n\n![Version](https://img.shields.io/badge/version-0.22.1-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/amtsblatt-mcp)\n![CI](https://github.com/malkreide/amtsblatt-mcp/actions/workflows/ci.yml/badge.svg)\n\n> MCP server for **amtsblattportal.ch** — the Swiss official gazette portal\n> (SHAB + 27 cantonal gazettes). Public procurement and official notices,\n> **person-data rubrics excluded by design**.\n\n[🇩🇪 Deutsche Version](README.de.md)\n\n## Overview\n\nThe Amtsblattportal publishes roughly **2.79 million** official notices: public\nprocurement, cantonal and communal announcements, enactments, spatial planning\n— and also bankruptcies, debt collection, inheritance calls and civil-status\nrecords naming natural persons.\n\nThis server exposes only the first group. Rubrics carrying systematic\nnatural-person data are **not queryable**, and no tool accepts a person's name,\nbirth date or address. That is a deliberate data-protection decision, explained\nin [Data Protection & Scope](#data-protection--scope).\n\n**Anchor demo query:** *\"Which public tenders did canton Ticino publish this month?\"*\n\n### Demo\n\n![Demo: Claude using gazette_search_procurement and gazette_get_publication](docs/assets/demo.svg)\n→ `gazette_search_procurement(canton=\"TI\", only_language=True, language=\"it\")` → `gazette_get_publication(id=…)`\n\nFor procurement in any other canton — including Zürich, Bern and Basel-Stadt —\nuse [`swiss-procurement-mcp`](https://github.com/malkreide/swiss-procurement-mcp);\nsee [Boundary with `swiss-procurement-mcp`](#boundary-with-swiss-procurement-mcp).\n\n## Features\n\n- **Fail-closed green allow-list** — 49 released rubrics out of 152; everything\n  else is blocked by default, including rubrics the upstream adds later\n- **Explanatory refusals** — a blocked rubric returns *why*, never a silent\n  empty result and never a workaround hint\n- **Procurement-aware** — knows that only AR and TI still publish tenders here,\n  that BS wound down during 2024 and BL/VS are historical archives, that `OB-ZG`\n  was never filled after the simap switch, and that ZH routes everything through\n  simap.ch — so it explains instead of returning nothing. Activity is\n  [measured, not read off the rubric label](docs/procurement-coverage.md)\n- **Deadline arithmetic** in Europe/Zurich, the legally relevant timezone\n- **Honest multilingual counts** — the portal publishes one record per language\n  with a *different* publication number each; identical editions are collapsed,\n  translated ones are reported via `language_mix` rather than guessed at, and\n  `only_language=True` gives a single-language view\n- **Defensive XML parsing** — the schema is per-sub-rubric; no rubric-specific\n  path is hard-coded, and entity-escaped HTML bodies are unescaped and stripped\n- **Egress allow-list**, retry with backoff, structured JSON logging\n- **Markdown or JSON output** with per-response attribution + `provenance`\n\n## Prerequisites\n\n- Python 3.11+\n- **No API key.** The read API of amtsblattportal.ch is freely accessible.\n\n## Installation\n\n```bash\npip install amtsblatt-mcp\n# or, without installing:\nuvx amtsblatt-mcp\n```\n\nFrom source:\n\n```bash\ngit clone https://github.com/malkreide/amtsblatt-mcp\ncd amtsblatt-mcp\npip install -e \".[dev]\"\n```\n\n## Configuration\n\n### Claude Desktop\n\n```json\n{\n  \"mcpServers\": {\n    \"amtsblatt\": {\n      \"command\": \"uvx\",\n      \"args\": [\"amtsblatt-mcp\"]\n    }\n  }\n}\n```\n\n### Cloud deployment (streamable-http)\n\n```bash\nexport MCP_TRANSPORT=streamable-http\nexport MCP_API_KEY=\"$(openssl rand -hex 32)\"   # mandatory — fails loud if unset\nexport PORT=8000\namtsblatt-mcp\n```\n\nThe endpoint is **`/mcp`**.\n\n> **Migrating from SSE.** Until 0.18.0 this server spoke SSE only, on\n> `/sse` + `/messages`. MCP spec `2026-07-28` reclassifies HTTP+SSE as\n> deprecated with a twelve-month removal window and removes protocol-level\n> sessions, so streamable-http is now the default. `MCP_TRANSPORT=sse` still\n> works and still carries the full bearer-auth, rate-limit and CORS stack — it\n> logs a warning at startup naming the deadline. **Update the client URL when\n> you switch**: the path change is the part that breaks silently.\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `MCP_TRANSPORT` | `stdio` | `stdio`, `streamable-http` (alias `http`), or the deprecated `sse` |\n| `MCP_HOST` | `127.0.0.1` | HTTP bind address. Defaults to loopback; set `0.0.0.0` to expose on all interfaces (the Docker image does this deliberately). |\n| `MCP_STATELESS` | _(unset)_ | `1` runs streamable-http with no session tracking at all. Removes session hijacking and session affinity as questions rather than answering them (`SEC-009`, `SCALE-002`). Opt-in, because a stateless server cannot resume an interrupted stream or push server-initiated notifications. Ignored on `sse`, which has no stateless mode. |\n| `MCP_CORS_ORIGINS` | _(unset)_ | Comma-separated origins allowed to call the endpoint from a browser. Unset means no cross-origin browser access at all — stdio and non-browser clients are unaffected. `Mcp-Session-Id` is exposed and accepted for the listed origins, so a browser client can hold a session. `*` is honoured but logs a warning and disables credentials, because browsers reject a wildcard origin together with credentials. |\n| `MCP_API_KEY` | — | Bearer token; **required** on every HTTP transport |\n| `MCP_RATE_LIMIT` / `MCP_RATE_WINDOW` | `60` / `60` | Sliding-window rate limit |\n| `RUBRICS_TTL` | `86400` | Taxonomy cache TTL (seconds) |\n| `LOG_LEVEL` | `INFO` | `DEBUG` \\| `INFO` \\| `WARNING` \\| `ERROR`. Structured JSON, one object per line, always on **stderr** — stdout carries the MCP protocol on a stdio transport. |\n\n### Logging\n\nBuilt on [structlog](https://www.structlog.org/). Every event emitted during a\ntool call carries that call's `correlation_id`, bound via `contextvars` — so a\nretry or an egress denial logged deep in the HTTP path can be joined to the\nrequest that caused it, without threading context through every signature.\n\n| Level | Emitted when |\n|---|---|\n| `DEBUG` | a tool call was entered (`tool_call_started`) — tells you whether a hung call ever started |\n| `INFO` | a tool call finished cleanly, with latency |\n| `WARNING` | upstream retry, egress denied, auth failure, rate limit |\n| `ERROR` | a tool call raised |\n\nRecords carry the exception *type* only — never its message and never an\nupstream response body.\n\n```json\n{\"event\":\"tool_call_started\",\"tool\":\"gazette_search_procurement\",\"correlation_id\":\"23221af26ae640c7\",\"level\":\"debug\",\"timestamp\":\"2026-07-27T22:20:07.494276Z\"}\n{\"status\":\"ok\",\"latency_ms\":312,\"event\":\"tool_call\",\"tool\":\"gazette_search_procurement\",\"correlation_id\":\"23221af26ae640c7\",\"level\":\"info\",\"timestamp\":\"2026-07-27T22:20:07.806Z\"}\n```\n\nShip these to your SIEM and alert on `auth_failed`, `rate_limited`,\n`egress_denied`, `green_gate_violation` and `blocked_publication_requested` —\nthe last two mean something tried to reach a rubric this server does not serve.\n\n## Available Tools\n\n| Tool | Signature | Notes |\n|---|---|---|\n| `gazette_search_publications` | `(keyword?, rubric?, sub_rubric?, canton?, date_start?, date_end?, limit=20, page=0, language='de', only_language=False)` | Green rubrics enforced. Without `rubric`, all green rubrics are injected — a keyword-only query can never reach a blocked one. |\n| `gazette_search_detailed` | same filters **+ `top_n=3`** | **Aggregated.** Search *and* full text for the top `top_n` hits in one call, fetched in parallel. Same green gate on every expanded document; blocked ones are withheld and counted, never rendered. |\n| `gazette_search_procurement` | `(keyword?, canton?, date_start?, date_end?, include_inactive=False, limit=20, page=0, language='de', only_language=False)` | `OB-*` rubrics plus the gazette-native sub-rubrics `AR-VS40`, `AR-OW40`, `BA-SH40`. A canton with neither gets a simap.ch explainer and **no HTTP call**. No CPV — the source has none. |\n| `gazette_get_publication` | `(id, response_format='markdown')` | Full official text from XML. Re-checks the rubric after fetching; content from a blocked rubric is discarded. |\n| `gazette_list_rubrics` | `(language='de', rubric_class='green', response_format='markdown')` | `rubric_class='all'` shows the full taxonomy with traffic-light classes and reasons — listed ≠ queryable. |\n| `gazette_source_status` | `(response_format='markdown')` | Reachability, latency, cache age, scope metrics. |\n\nAll tools are `readOnlyHint=True`.\n\n### Example use cases\n\n| Question | Tool chain |\n|---|---|\n| Tenders in Ticino this quarter | `gazette_search_procurement(canton=\"TI\", only_language=True, language=\"it\")` |\n| Procurement simap.ch does **not** have | `gazette_search_procurement(canton=\"VS\")` — 150 Valais awards, none on simap |\n| Tenders in any other canton | → use [`swiss-procurement-mcp`](https://github.com/malkreide/swiss-procurement-mcp) |\n| What is even queryable here? | `gazette_list_rubrics()` |\n| Why can't I search bankruptcies? | `gazette_list_rubrics(rubric_class=\"all\")` |\n| Zoning changes in Zurich | `gazette_search_publications(rubric=\"RP-ZH\")` |\n| Full text of a notice | `gazette_get_publication(id=\"fbf0ff9e-…\")` |\n| Everything published about one company | → use [`register-mcp`](https://github.com/malkreide/register-mcp) |\n\n## Data Protection & Scope\n\nThe Amtsblattportal systematically publishes personal data of **natural**\npersons. Those publications are public — but making them *systematically\nqueryable by name* through an AI agent is a repurposing the publication never\nintended, and a profiling instrument under the revised Swiss FADP (revDSG).\n\nFour rules follow, and they are enforced in code, not in documentation:\n\n1. **Allow-list, never block-list.** Not explicitly green ⇒ not queryable.\n   New upstream rubrics are closed by default.\n2. **No person-based search entry** in any tool signature.\n3. **No persistence.** Publications have statutory deletion periods; a cache\n   outliving them would actively undermine them. Only the *taxonomy* is cached.\n4. **Blocked ⇒ explained.** Never a silent empty result, never a hint at\n   circumvention.\n\n### What is excluded\n\n🔴 Konkurse (`KK`), Schuldbetreibungen (`SB`), Schuldenrufe (`LS`, `SR`),\nNachlass (`NA`), Erbschaft/Testament/Ableben (`ES`, `TE-*`, `VA-*`),\nFamilie & Zivilstand (`FZ-*`, `BV-*`, `BU-*`), gerichtliche Vorladungen\n(`UV`, `GB-*`, `GE-*`, `SJ-BE`), Baugesuche (`BP-*`), Grundbuch (`GR-*`),\nMeldungskatalog GR (`AA-GR`).\n\n🟡 Deferred: Steuerwesen, Anzeigen, Bewilligungen, Bildungs- und Kirchenwesen\nand the general catch-all rubrics.\n\nThe full audit trail — including three documented extensions to the source\nspecification — is in [`docs/rubric-classification.md`](docs/rubric-classification.md).\n\n**How much each decision covers is measured, not estimated.**\n[`docs/coverage-matrix.md`](docs/coverage-matrix.md) enumerates the source's own\nrubric axis and marks this server's reach into it: **84.2 % of 2 804 063\npublications are reachable, 12.6 % blocked by decision, 3.3 % still\nunclassified**. The insolvency group alone is 321 704 publications — present in\nthe source, out of reach here on purpose. Without that figure, \"out of scope\"\nand \"not in the source\" read the same in a review, and this repository made\nexactly that mistake once (see `ARCH-003` in [`SECURITY.md`](SECURITY.md)).\n\n### The boundary with `register-mcp`\n\nFor publications about a specific **company**, use\n[`register-mcp`](https://github.com/malkreide/register-mcp). It keeps full\nrubric access — including a firm's own bankruptcy — but only ever keyed on a\ncompany **UID**. A firm's insolvency is corporate data, not natural-person\nprofiling, and UID scoping makes name-based enumeration impossible.\n\n`amtsblatt-mcp` has the opposite shape: broad search, narrow rubrics. It does\nnot expose the upstream `uids` parameter at all.\n\n### Boundary with `swiss-procurement-mcp`\n\n**simap.ch is the primary source for Swiss public procurement** — all 26 cantons\nplus the Confederation, with CPV and BKP codes, awards and publication history.\nUse [`swiss-procurement-mcp`](https://github.com/malkreide/swiss-procurement-mcp)\nfor procurement questions.\n\n**amtsblattportal.ch is the primary source for official notices** — commercial\nregister, spatial planning, enactments, cantonal and communal announcements.\nThat is what this server is for; procurement is 6 of its 49 released rubrics.\n\nProcurement here is largely a **second publication** of the same tenders, and\nthat is now measured rather than assumed. A publication's XML carries\n`<simapPublicationNumber>` when it originates on simap.ch, which joins the two\ncorpora exactly. Over the full 2026 `OB-TI` corpus, **503 of 546 records (92.1%)\ncarry one**; three of the six `OB-*` rubrics say as much in their own labels\n(`OB-BL` — \"über Simap importiert (I N A K T I V)\").\n\nThe exception is small and sharply bounded: `AR-VS40` (Valais, 150 awards),\n`AR-OW40` (Obwalden, 7), `BA-SH40` (Schaffhausen, 2) and the Ticino sub-rubric\n`OB-TI65` (\"Avvisi di gara **non CIAP**\") carry **no** simap reference at all.\nThat is the one part of this portal's procurement coverage `swiss-procurement-mcp`\ncannot reach, and `gazette_search_procurement` serves it for cantons VS, OW and\nSH even though they have no active `OB-*` rubric. Numbers and method in\n[`docs/simap-overlap.md`](docs/simap-overlap.md).\n\nThe two servers stay separate on purpose: different sources, different reuse\nterms, and a fail-closed rubric gate that only means something while it covers\n*every* tool in the server. See\n[`docs/procurement-coverage.md`](docs/procurement-coverage.md) for the numbers.\n\n## Maturity & phase\n\n**Phase 1 — read-only.** All six tools are read-only; there is no write path and\nnone is planned. See [ROADMAP.md](ROADMAP.md) for the phase-specific backlog,\nwhat is deliberately not planned, and what a phase transition would require.\n\nThe scope restriction that matters most here is not the phase but the **green\nallow-list** — rubrics carrying systematic natural-person data are not\nqueryable, enforced in code and re-checked after every fetch. That does not\nchange with phase. See [Data Protection & Scope](#data-protection--scope).\n\nSDK and dependency updates arrive as [Dependabot](.github/dependabot.yml) PRs,\nso a breaking protocol or SDK change is reviewed deliberately rather than\ndrifting in silently.\n\n---\n\n## Architecture\n\n```\n   Claude / MCP client\n            │\n      amtsblatt-mcp\n            │\n   ┌────────┴────────┐\n   │  green gate     │  ← rubrics.py: fail-closed allow-list\n   └────────┬────────┘     (checked at the tool AND at the query builder)\n            │\n   ┌────────┴────────┐\n   │  param allow-   │  ← Silent Ignore guard\n   │  list + quirks  │  ← Silent Empty guard (taxonomy validation)\n   └────────┬────────┘  ← plausibility guard (corpus-size check)\n            │\n   ┌────────┴────────┐\n   │ egress allow-   │\n   │ list (httpx)    │\n   └────────┬────────┘\n            │\n  amtsblattportal.ch/api/v1\n   /publications · /publications/{id}/xml · /rubrics · /tenants\n```\n\n**Architecture A (live-API-only).** The endpoints answer stably without\nauthentication, so no bulk dump is maintained.\n\n### Verified upstream quirks (live-checked 2026-07-20)\n\n| Quirk | Behaviour | Defence |\n|---|---|---|\n| **Silent Ignore** | An unknown parameter *name* returns HTTP 200 and the **full corpus**. `canton=ZH` (singular typo) silently drops the filter. | Query params built exclusively from `ALLOWED_GAZETTE_PARAMS`; plausibility guard rejects results > 2 000 000. |\n| **Silent Empty** | An unknown rubric *value* returns HTTP 200 with `total: 0` — indistinguishable from a real no-hit. | Every code validated against the taxonomy **before** the call. |\n| **Metadata only** | The list endpoint and `GET /publications/{id}` both return `content: null`. | Full text only via `/publications/{id}/xml`. |\n| **Sorting ignored** | `pageRequest.sortOrders` is accepted with 200 but has no effect; `sortOrders` comes back `[]`. | Sorted client-side. |\n| **Missing `publicationStates`** | Returns **401**, not 400 — it does *not* mean credentials are required. | Always injected; the 401 message says so. |\n| **No page-size cap** | `pageRequest.size=2000` returns 2000 items. | Client-side cap of 100. |\n| **Inconsistent plurals** | `rubrics`/`cantons`/`subRubrics` are plural, `keyword`/`tenant` singular. | Exact spellings encoded, not a pluralisation rule. |\n\n## Known Limitations\n\n- **Uneven cantonal coverage.** Only 16 of 29 mandates expose their own rubric\n  taxonomy; AG, FR, GE, GL, JU, LU, NE, UR are still incomplete.\n- **Deletion periods.** Publications drop out of the API over time — hence\n  pass-through only.\n- **Procurement boundary.** Most cantons, including **Zürich**, route tenders\n  through simap.ch, outside this portal. There is no `OB-ZH`, and no CPV\n  classification exists here. What this portal has and simap does not is listed\n  in [`docs/simap-overlap.md`](docs/simap-overlap.md); `gazette_get_publication` reports\n  `simap_publication_number` so a mirror is distinguishable from an original.\n- **Procurement coverage, measured** (`publicationStates=PUBLISHED`, 2026-07-27,\n  records per calendar year — reproduce with\n  `python scripts/measure_procurement_coverage.py`):\n\n  | Rubric | 2022 | 2023 | 2024 | 2025 | 2026 | Latest | Status |\n  |---|---|---|---|---|---|---|---|\n  | `OB-TI` | 517 | 491 | 625 | 607 | 546 | 2026-07-27 | active |\n  | `OB-AR` | 95 | 85 | 79 | 56 | 40 | 2026-05-22 | active |\n  | `OB-BS` | 1 149 | 1 058 | 319 | 15 | **2** | 2026-05-20 | wound down during 2024 |\n  | `OB-VS` | 0 | 1 052 | 1 | 0 | 0 | 2024-01-05 | archive — simap import until end of 2023 |\n  | `OB-BL` | 0 | 74 | 0 | 0 | 0 | 2023-03-30 | archive — labelled «I N A K T I V» |\n  | `OB-ZG` | 0 | 0 | 0 | 0 | 0 | — | never filled |\n\n  **Only TI and AR still publish actively.** `OB-BS` is the instructive case:\n  its label is a plain \"Öffentliches Beschaffungswesen\" with no inactive marker,\n  so only the volume reveals the migration — which is why `active` is measured,\n  never read. Use `include_inactive=True` to reach the BS, BL and VS archives.\n  Details in [`docs/procurement-coverage.md`](docs/procurement-coverage.md).\n- **No push.** Polling only; no subscription or webhook mechanism.\n- **Legally binding text** is the signed PDF, not this API.\n\n## MCP Protocol Version\n\n| | |\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| **Pinned in** | `MCP_PROTOCOL_VERSION` in [`_app.py`](src/amtsblatt_mcp/_app.py), re-exported from `server.py` |\n| **SDK** | `mcp[cli]>=2.0.0,<3` |\n| **Cache hints** | `tools/list` and `server/discover`: `ttlMs` 300000, `cacheScope` `public` |\n\nThe MCP Python SDK negotiates the protocol version in the session layer and\noffers no constructor parameter for it, so the version cannot be pinned by\nconfiguration. It is pinned as a declared constant and enforced by detection:\n\n- **At runtime**, a mismatch logs a `protocol_version_drift` event at `WARNING`.\n  The server keeps working.\n- **In CI**, `tests/test_protocol_version.py` fails.\n\nAn SDK bump should break *our* build, not the runtime of someone who upgraded\n`mcp` in their own environment.\n\n### Update policy\n\n- Dependabot opens SDK update PRs monthly (`.github/dependabot.yml`).\n- When an update moves the protocol version, the CI test fails. The fix is\n  **not** to edit the constant blindly: read the spec changelog, verify the\n  server still behaves — especially the green allow-list invariants — then bump\n  the constant, this section and `CHANGELOG.md` in one commit.\n- Protocol-version bumps are called out explicitly in `CHANGELOG.md`, not folded\n  into a dependency-bump line.\n\n### Cache hints\n\nSpec `2026-07-28` gives every cacheable result a `ttlMs` and a `cacheScope`.\nThe SDK defaults both to «immediately stale, never shared», so a server that\npasses no `cache_hints` is not neutral — it asks every client to re-list on\nevery connection. This server's tool list is registered at import and identical\nfor every caller, so it is announced as cacheable for five minutes and\nshareable across authorization contexts (`CACHE_HINTS` in `_app.py`).\n\n`public` rests on that second property, not on convenience: the green\nallow-list is enforced per request inside the tools, never by hiding a tool from\na caller. The day a tool list becomes caller-dependent, the scope has to become\n`private` in the same commit.\n\n---\n\n## Primitives: tools only\n\nThis server exposes **tools** and neither resources nor prompts. A decision, not\nan omission (ARCH-008).\n\n**Why not resources.** Resources address identifiable, listable content the\nclient can enumerate and cache. This corpus is 2.79 million publications that\ngrows daily, and — more importantly — **not all of it is servable**. Rubrics\ncarrying systematic personal data are excluded by design, and that exclusion is\nenforced at two points: a pre-request green gate on the filters, and a\npost-fetch gate on the returned document.\n\nA resource URI would put a publication id in the client's hands as an\nenumerable address. Since ids are opaque, the rubric behind one cannot be known\nuntil the document is fetched — which is exactly why the post-fetch gate exists.\nExposing publications as resources would mean either enumerating ids we have not\ngated yet, or gating at fetch time anyway, at which point the resource\nabstraction buys nothing and costs a second content path to keep the guarantee\non. This repo has already learned that lesson once: the aggregated tool needed\nthe gate extracted into a shared helper precisely because a second path to\ncontent is where such guarantees quietly stop holding.\n\nOne candidate was checked concretely:\n\n| Candidate | Why it stays a tool |\n|---|---|\n| `gazette_list_rubrics` | Genuinely resource-shaped — a finite, slow-changing taxonomy, already cached with a TTL. But its whole purpose is to communicate that *listed ≠ queryable*: it renders traffic-light classes and the reason each blocked rubric is blocked. As a resource that framing would be a document the model may or may not read; as a tool it is an answer to a question the model asked. |\n\n**Why not prompts.** Question templates would duplicate guidance the tool\ndocstrings already carry, in a second place that can drift out of sync with the\nallow-list. Given that the docstrings are what tell the model which rubrics are\nreachable, one source is safer than two.\n\n### Return shapes: rendered text, not models\n\nTools return `str` — Markdown by default, JSON via `response_format='json'` —\nrather than Pydantic models. This is a **documented deviation** from SDK-002,\nmade deliberately rather than by neglect.\n\nThe rendered output is not a serialisation of an internal object; it is composed\nfor the reader. It carries the provenance line, the scope statement\n(`green_rubrics_only`), the deduplication warning when language variants were\nmerged, and the explanation a blocked rubric returns *instead of* data. Those\nare the parts that keep the model from drawing wrong conclusions, and they are\nprose, not fields.\n\nReturning a model would either drop them or smuggle them back in as string\nfields, which is the same thing with more ceremony. The `json` format already\ncovers the machine-readable case for callers that want it.\n\n**What would change this:** a caller that needs to compute over results rather\nthan read them. At that point the right move is typed models on the JSON path\nspecifically, not a wholesale change of what every tool returns.\n\n---\n\n## Testing\n\n```bash\npip install -e \".[dev]\"\nPYTHONPATH=src pytest tests/ -m \"not live\"   # 75 tests, no network\nPYTHONPATH=src pytest tests/ -m live         # hits the real API\nruff check src/ tests/ scripts/\nruff format --check src/ tests/ scripts/\npython scripts/check_version_sync.py\n```\n\nThese are the same gates CI runs, over the same directories. The `dev` extra\npins ruff to the version CI installs, so a local run and a CI run agree.\n\nThe suite covers the mandatory portfolio set: green-rubric search with source\nURL, **blocked rubric → explanation with zero HTTP calls**, canton filtering,\nEurope/Zurich deadline arithmetic against a fixed \"today\", pagination across a\npage boundary, language deduplication, boolean normalisation, and API-unreachable\nhandling. Fixtures are shortened real responses, consistently anonymised — no\nreal personal data.\n\n## Project Structure\n\n```\namtsblatt-mcp/\n├── src/amtsblatt_mcp/\n│   ├── rubrics.py       # Fail-closed green allow-list — the scope decision\n│   ├── server.py        # MCPServer, 5 tools, quirk guards, XML parsing\n│   ├── _log.py          # Structured JSON logging + per-tool call events\n│   ├── _middleware.py   # Bearer auth + sliding-window rate limit (SSE only)\n│   └── _otel.py         # Optional OpenTelemetry wiring\n├── tests/\n│   ├── test_allowlist.py    # Data-protection invariants (own CI job)\n│   ├── test_search.py       # Search, procurement, pagination, dedup, errors\n│   ├── test_publication.py  # XML parsing, deadlines, egress allow-list\n│   └── fixtures.py          # Anonymised real responses\n├── docs/\n│   ├── rubric-classification.md   # Why each of the 152 rubrics is open/closed\n│   ├── procurement-coverage.md    # Measured OB-* volume; why `active` is measured\n│   ├── coverage-matrix.md         # Measured reach: which part of the corpus the tools cover\n│   └── simap-overlap.md           # Mirror vs. original, joined on simapPublicationNumber\n├── scripts/\n│   ├── measure_procurement_coverage.py\n│   └── measure_coverage_matrix.py\n├── Dockerfile · compose.yaml      # Hardened, non-root, read-only container\n└── server.json                    # MCP registry manifest\n```\n\n## Changelog\n\nSee [CHANGELOG.md](CHANGELOG.md).\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md). Changes to\n[`src/amtsblatt_mcp/rubrics.py`](src/amtsblatt_mcp/rubrics.py) require an\nexplicit rationale in the PR description: releasing a rubric is a\ndata-protection decision, not a feature.\n\n## Security\n\nSee [SECURITY.md](SECURITY.md) for reporting and operator hardening notes.\n\n## License\n\nMIT — see [LICENSE](LICENSE). Data-source notice: [NOTICE.md](NOTICE.md).\n\nData source: **amtsblattportal.ch**, operated by SECO / State Secretariat for\nEconomic Affairs on behalf of the Swiss Confederation. Freely usable, but\nwithout warranty of completeness or accuracy. Only the signed PDF of a\npublication is legally binding.\n\n## Author\n\nHayal Oezkan · [malkreide](https://github.com/malkreide)\n\n## Credits & Related Projects\n\nPart of the **Swiss Public Data MCP Portfolio**:\n\n- [`register-mcp`](https://github.com/malkreide/register-mcp) — Zefix commercial\n  register with a company-UID join to the gazettes\n\n<!-- mcp-name: io.github.malkreide/amtsblatt-mcp -->\n\n",
  "bytes": 27491,
  "sha": "c7b0e3d18f982ef6dc13f6c612ebb30fa0fdc3ca0c82a8978eb38c2bc8410b5b",
  "repo_slug": "malkreide/amtsblatt-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_malkreide_amtsblatt_mcp_4da4d1fa/readme"
}