Install
$ agentstack add mcp-malkreide-amtsblatt-mcp ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
> 🇨🇭 Part of the Swiss Public Data MCP Portfolio
📰 amtsblatt-mcp
[](https://opensource.org/licenses/MIT) [](https://www.python.org/downloads/) [](https://modelcontextprotocol.io/) [](https://github.com/malkreide/amtsblatt-mcp)
> MCP server for amtsblattportal.ch — the Swiss official gazette portal > (SHAB + 27 cantonal gazettes). Public procurement and official notices, > person-data rubrics excluded by design.
[🇩🇪 Deutsche Version](README.de.md)
Overview
The Amtsblattportal publishes roughly 2.79 million official notices: public procurement, cantonal and communal announcements, enactments, spatial planning — and also bankruptcies, debt collection, inheritance calls and civil-status records naming natural persons.
This server exposes only the first group. Rubrics carrying systematic natural-person data are not queryable, and no tool accepts a person's name, birth date or address. That is a deliberate data-protection decision, explained in [Data Protection & Scope](#data-protection--scope).
Anchor demo query: "Which public tenders did canton Ticino publish this month?"
Demo
→ gazette_search_procurement(canton="TI", only_language=True, language="it") → gazette_get_publication(id=…)
For procurement in any other canton — including Zürich, Bern and Basel-Stadt — use swiss-procurement-mcp; see [Boundary with swiss-procurement-mcp](#boundary-with-swiss-procurement-mcp).
Features
- Fail-closed green allow-list — 49 released rubrics out of 152; everything
else is blocked by default, including rubrics the upstream adds later
- Explanatory refusals — a blocked rubric returns why, never a silent
empty result and never a workaround hint
- Procurement-aware — knows that only AR and TI still publish tenders here,
that BS wound down during 2024 and BL/VS are historical archives, that OB-ZG was never filled after the simap switch, and that ZH routes everything through simap.ch — so it explains instead of returning nothing. Activity is [measured, not read off the rubric label](docs/procurement-coverage.md)
- Deadline arithmetic in Europe/Zurich, the legally relevant timezone
- Honest multilingual counts — the portal publishes one record per language
with a different publication number each; identical editions are collapsed, translated ones are reported via language_mix rather than guessed at, and only_language=True gives a single-language view
- Defensive XML parsing — the schema is per-sub-rubric; no rubric-specific
path is hard-coded, and entity-escaped HTML bodies are unescaped and stripped
- Egress allow-list, retry with backoff, structured JSON logging
- Markdown or JSON output with per-response attribution +
provenance
Prerequisites
- Python 3.11+
- No API key. The read API of amtsblattportal.ch is freely accessible.
Installation
pip install amtsblatt-mcp
# or, without installing:
uvx amtsblatt-mcp
From source:
git clone https://github.com/malkreide/amtsblatt-mcp
cd amtsblatt-mcp
pip install -e ".[dev]"
Configuration
Claude Desktop
{
"mcpServers": {
"amtsblatt": {
"command": "uvx",
"args": ["amtsblatt-mcp"]
}
}
}
Cloud deployment (streamable-http)
export MCP_TRANSPORT=streamable-http
export MCP_API_KEY="$(openssl rand -hex 32)" # mandatory — fails loud if unset
export PORT=8000
amtsblatt-mcp
The endpoint is /mcp.
> Migrating from SSE. Until 0.18.0 this server spoke SSE only, on > /sse + /messages. MCP spec 2026-07-28 reclassifies HTTP+SSE as > deprecated with a twelve-month removal window and removes protocol-level > sessions, so streamable-http is now the default. MCP_TRANSPORT=sse still > works and still carries the full bearer-auth, rate-limit and CORS stack — it > logs a warning at startup naming the deadline. Update the client URL when > you switch: the path change is the part that breaks silently.
| Variable | Default | Purpose | |---|---|---| | MCP_TRANSPORT | stdio | stdio, streamable-http (alias http), or the deprecated sse | | 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). | | 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. | | 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. | | MCP_API_KEY | — | Bearer token; required on every HTTP transport | | MCP_RATE_LIMIT / MCP_RATE_WINDOW | 60 / 60 | Sliding-window rate limit | | RUBRICS_TTL | 86400 | Taxonomy cache TTL (seconds) | | 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. |
Logging
Built on structlog. Every event emitted during a tool call carries that call's correlation_id, bound via contextvars — so a retry or an egress denial logged deep in the HTTP path can be joined to the request that caused it, without threading context through every signature.
| Level | Emitted when | |---|---| | DEBUG | a tool call was entered (tool_call_started) — tells you whether a hung call ever started | | INFO | a tool call finished cleanly, with latency | | WARNING | upstream retry, egress denied, auth failure, rate limit | | ERROR | a tool call raised |
Records carry the exception type only — never its message and never an upstream response body.
{"event":"tool_call_started","tool":"gazette_search_procurement","correlation_id":"23221af26ae640c7","level":"debug","timestamp":"2026-07-27T22:20:07.494276Z"}
{"status":"ok","latency_ms":312,"event":"tool_call","tool":"gazette_search_procurement","correlation_id":"23221af26ae640c7","level":"info","timestamp":"2026-07-27T22:20:07.806Z"}
Ship these to your SIEM and alert on auth_failed, rate_limited, egress_denied, green_gate_violation and blocked_publication_requested — the last two mean something tried to reach a rubric this server does not serve.
Available Tools
| Tool | Signature | Notes | |---|---|---| | 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. | | 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. | | 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. | | 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. | | 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. | | gazette_source_status | (response_format='markdown') | Reachability, latency, cache age, scope metrics. |
All tools are readOnlyHint=True.
Example use cases
| Question | Tool chain | |---|---| | Tenders in Ticino this quarter | gazette_search_procurement(canton="TI", only_language=True, language="it") | | Procurement simap.ch does not have | gazette_search_procurement(canton="VS") — 150 Valais awards, none on simap | | Tenders in any other canton | → use swiss-procurement-mcp | | What is even queryable here? | gazette_list_rubrics() | | Why can't I search bankruptcies? | gazette_list_rubrics(rubric_class="all") | | Zoning changes in Zurich | gazette_search_publications(rubric="RP-ZH") | | Full text of a notice | gazette_get_publication(id="fbf0ff9e-…") | | Everything published about one company | → use register-mcp |
Data Protection & Scope
The Amtsblattportal systematically publishes personal data of natural persons. Those publications are public — but making them systematically queryable by name through an AI agent is a repurposing the publication never intended, and a profiling instrument under the revised Swiss FADP (revDSG).
Four rules follow, and they are enforced in code, not in documentation:
- Allow-list, never block-list. Not explicitly green ⇒ not queryable.
New upstream rubrics are closed by default.
- No person-based search entry in any tool signature.
- No persistence. Publications have statutory deletion periods; a cache
outliving them would actively undermine them. Only the taxonomy is cached.
- Blocked ⇒ explained. Never a silent empty result, never a hint at
circumvention.
What is excluded
🔴 Konkurse (KK), Schuldbetreibungen (SB), Schuldenrufe (LS, SR), Nachlass (NA), Erbschaft/Testament/Ableben (ES, TE-*, VA-*), Familie & Zivilstand (FZ-*, BV-*, BU-*), gerichtliche Vorladungen (UV, GB-*, GE-*, SJ-BE), Baugesuche (BP-*), Grundbuch (GR-*), Meldungskatalog GR (AA-GR).
🟡 Deferred: Steuerwesen, Anzeigen, Bewilligungen, Bildungs- und Kirchenwesen and the general catch-all rubrics.
The full audit trail — including three documented extensions to the source specification — is in [docs/rubric-classification.md](docs/rubric-classification.md).
How much each decision covers is measured, not estimated. [docs/coverage-matrix.md](docs/coverage-matrix.md) enumerates the source's own rubric axis and marks this server's reach into it: 84.2 % of 2 804 063 publications are reachable, 12.6 % blocked by decision, 3.3 % still unclassified. The insolvency group alone is 321 704 publications — present in the source, out of reach here on purpose. Without that figure, "out of scope" and "not in the source" read the same in a review, and this repository made exactly that mistake once (see ARCH-003 in [SECURITY.md](SECURITY.md)).
The boundary with register-mcp
For publications about a specific company, use register-mcp. It keeps full rubric access — including a firm's own bankruptcy — but only ever keyed on a company UID. A firm's insolvency is corporate data, not natural-person profiling, and UID scoping makes name-based enumeration impossible.
amtsblatt-mcp has the opposite shape: broad search, narrow rubrics. It does not expose the upstream uids parameter at all.
Boundary with swiss-procurement-mcp
simap.ch is the primary source for Swiss public procurement — all 26 cantons plus the Confederation, with CPV and BKP codes, awards and publication history. Use swiss-procurement-mcp for procurement questions.
amtsblattportal.ch is the primary source for official notices — commercial register, spatial planning, enactments, cantonal and communal announcements. That is what this server is for; procurement is 6 of its 49 released rubrics.
Procurement here is largely a second publication of the same tenders, and that is now measured rather than assumed. A publication's XML carries ` when it originates on simap.ch, which joins the two corpora exactly. Over the full 2026 OB-TI corpus, **503 of 546 records (92.1%) carry one**; three of the six OB-* rubrics say as much in their own labels (OB-BL` — "über Simap importiert (I N A K T I V)").
The exception is small and sharply bounded: AR-VS40 (Valais, 150 awards), AR-OW40 (Obwalden, 7), BA-SH40 (Schaffhausen, 2) and the Ticino sub-rubric OB-TI65 ("Avvisi di gara non CIAP") carry no simap reference at all. That is the one part of this portal's procurement coverage swiss-procurement-mcp cannot reach, and gazette_search_procurement serves it for cantons VS, OW and SH even though they have no active OB-* rubric. Numbers and method in [docs/simap-overlap.md](docs/simap-overlap.md).
The two servers stay separate on purpose: different sources, different reuse terms, and a fail-closed rubric gate that only means something while it covers every tool in the server. See [docs/procurement-coverage.md](docs/procurement-coverage.md) for the numbers.
Maturity & phase
Phase 1 — read-only. All six tools are read-only; there is no write path and none is planned. See [ROADMAP.md](ROADMAP.md) for the phase-specific backlog, what is deliberately not planned, and what a phase transition would require.
The scope restriction that matters most here is not the phase but the green allow-list — rubrics carrying systematic natural-person data are not queryable, enforced in code and re-checked after every fetch. That does not change with phase. See [Data Protection & Scope](#data-protection--scope).
SDK and dependency updates arrive as [Dependabot](.github/dependabot.yml) PRs, so a breaking protocol or SDK change is reviewed deliberately rather than drifting in silently.
Architecture
Claude / MCP client
│
amtsblatt-mcp
│
┌────────┴────────┐
│ green gate │ ← rubrics.py: fail-closed allow-list
└────────┬────────┘ (checked at the tool AND at the query builder)
│
┌────────┴────────┐
│ param allow- │ ← Silent Ignore guard
│ list + quirks │ ← Silent Empty guard (taxonomy validation)
└────────┬────────┘ ← plausibility guard (corpus-size check)
│
┌────────┴────────┐
│ egress allow- │
│ list (httpx) │
└────────┬────────┘
│
amtsblattportal.ch/api/v1
/publications · /publications/{id}/xml · /rubrics · /tenants
Architecture A (live-API-only). The endpoints answer stably without authentication, so no bulk dump is maintained.
Verified upstream quirks (live-checked 2026-07-20)
| Quirk | Behaviour | Defence | |---|---|---| | 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. | | 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. | | Metadata only | The list endpoint and GET /publications/{id} both return content: null. | Full text only via /publications/{id}/xml. | | Sorting ignored | pageRequest.sortOrders is accepted with 200 but has no effect; sortOrders comes back []. | Sorted client-side. | | Missing publicationStates | Returns 401, not 400 — it does not mean cre
…
Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: malkreide
- Source: malkreide/amtsblatt-mcp
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.