MCP Data Source Probe — Standard-Vorgehen
Dieser Skill kodiert vier Disziplinen, die das Swiss Public Data MCP Portfolio vor den häufigsten MCP-Fehlern schützen: Tools gegen nicht-funktionierende Endpoints zu bauen, fragile Single-Path-Architekturen zu wählen, Resilienz-Basics zu vergessen — und, am schwersten zu bemerken, unvollständig zu liefern, ohne es zu merken.
Das Mantra in vier Zeilen:
- Live-Probe vor Design
- Dump-Fallback vor API-Abhängigkeit
- Retry vor Defaitismus
- Ground Truth vor Selbstvertrauen
Jeder neue *-mcp-Server durchläuft die drei Schritte unten in dieser Reihenfolge; die vierte Disziplin ist kein eigener Schritt, sondern verläuft quer durch Schritt 1 (1.2b, 1.2d, 1.3b, 1.4, 1.5) und Schritt 3 (3.6). Abweichungen erfordern eine explizite Begründung, die im README unter «Architektur-Entscheid» dokumentiert wird.
Schritt 1: Live-Probe (vor dem Coden) [Kern]
Ziel: Empirisch feststellen, was die Datenquelle tatsächlich liefert — nicht was die Dokumentation verspricht. Und zweitens: festhalten, was sie hat, das der geplante Server nicht anfassen wird. Beides ist hier billig und später teuer.
1.1 Dokumentation vollständig lesen
- OpenAPI-Spec, Datenmodell-PDFs, Merkblätter, Lizenz-Hinweise.
- Alle Endpoint-Patterns extrahieren (REST-Pfade, GraphQL-Schemas, SPARQL-Endpoints, Dump-URLs).
- Auth-Anforderungen prüfen: API-Key nötig? Rate-Limits? OAuth? Nur bei No-Auth weitermachen (Phase-1-Prinzip des Portfolios).
- Lizenz-Check: CC BY? CC BY-SA? OGD Schweiz? Proprietär? → bestimmt Attribution-Pflicht.
1.2 Endpoint-Matrix systematisch abarbeiten
Für jeden dokumentierten Endpoint mindestens fünf Probe-Calls:
| # | Probe | Zweck |
|---|---|---|
| 1 | Einfacher Basis-Call (z.B. Liste ohne Parameter) | Baseline |
| 2 | Das offiziell dokumentierte Beispiel | Doku-Treue |
| 3 | Mit Filter / Limit / Sortierung | Parameter-Validität |
| 4 | Fehlerfall (invalide ID, fehlender Parameter) | Error-Verhalten |
| 5 | Scope-Probe: Filter weggelassen vs. explizit maximal | Recall-Delta |
Probe 5 ist neu und der wichtigste der fünf. Sie beantwortet die Frage, die weder Probe 1 noch Probe 3 stellt: Deckt ein Call ohne Filter-Parameter wirklich alles ab? Siehe 1.2b.
Konkret mit curl und python3 -c:
BASE="https://example.ch/api/v1"
# 1. Baseline
curl -sL -w "\nHTTP %{http_code} | %{size_download}B | %{time_total}s\n" \
"$BASE/table/entity/list?limit=3" -o probe.json
python3 -c "import json; d=json.load(open('probe.json')); print('success:', d.get('success'), 'count:', d.get('count'))"
# 2. Das Doku-Beispiel
curl -sL "$BASE/table/entity/id/1" | python3 -m json.tool | head -30
# 3. Mit Filter
curl -sL "$BASE/table/entity/list?filter_status=active&limit=5" -o f.json
python3 -c "import json; d=json.load(open('f.json')); print('keys:', list((d.get('data') or [{}])[0].keys())[:15])"
# 4. Fehlerfall
curl -sL -w "HTTP %{http_code}\n" "$BASE/table/entity/id/999999"
1.2b Default-Matrix — was bedeutet Weglassen?
Ziel: Für jeden optionalen Parameter feststellen, was passiert, wenn der Server ihn nicht sendet. Die Antwort steht ausschliesslich in der Parameterbeschreibung der Spec — nicht im Response-Schema, nicht im Doku-Beispiel, und sie ist an einem funktionierenden Call nicht erkennbar.
Der Portfolio-Fall: termdat-mcp sendete ClassificationIds nur bei explizitem Aufruf. Die Spec dazu: «If no ID is given, a default set of classifications will be included (=VARIA)» — eine von 23 Klassifikationen, ausgerechnet die Restkategorie. Jede Default-Suche lief gegen ein Dreiundzwanzigstel des Bestands und meldete das als gewöhnliche Leermenge. Alle Tests waren grün. Gefunden hat es ein User mit dem Web-UI daneben.
Beschreibungen aller optionalen Parameter extrahieren:
curl -s "$SPEC_URL" -o spec.json
python3 - spec.json <<'PY'
import json, sys
spec = json.load(open(sys.argv[1], encoding="utf-8"))
for path, ops in spec.get("paths", {}).items():
for op in ops.values():
if not isinstance(op, dict):
continue
for prm in op.get("parameters", []):
if prm.get("required"):
continue
desc = (prm.get("description") or "").replace("\n", " ")
print(f"{path} | {prm['name']} | {desc[:160]}")
PY
Jede Beschreibung mit «default», «if no … is given», «standard set», «unless specified» ist ein Fund. Verdächtige Parameternamen quer durch die üblichen Quellen:
| Quelle | Parameter | Default bei Weglassen |
|---|---|---|
CKAN package_search |
rows |
10 Treffer, nicht alle |
WFS GetFeature |
count / maxFeatures |
serverseitiges Limit |
| SPARQL | FROM / Named Graphs |
nur der Default-Graph |
| Elasticsearch / Solr | size, fq, df |
10 Hits, eingeschränktes Default-Feld |
| GraphQL (Relay) | first |
schema-abhängig, oft klein |
| SQL-über-HTTP | LIMIT |
Gateway-Zeilenlimit |
Empirisch bestätigen — Delta ≠ 0 ist ein Befund:
# A: Parameter weggelassen
curl -s "$BASE/search?q=Testbegriff" | python3 -c "import json,sys; print('A:', len(json.load(sys.stdin)))"
# B: Parameter explizit maximal (alle IDs, alle Felder, hohes Limit)
curl -s "$BASE/search?q=Testbegriff&classIds=1&classIds=2&..." | python3 -c "import json,sys; print('B:', len(json.load(sys.stdin)))"
Ausgabe ist eine Zeile pro Parameter in der Befund-Tabelle:
| Parameter | Weglassen bedeutet | Beleg | Server muss senden? |
|---|---|---|---|
ClassificationIds |
⚠️ nur VARIA (1 von 23) |
Spec + Delta 0→3 | ✅ voller Satz |
MaxEntryCount |
⚠️ 25 statt alles | Spec-Beschreibung | ✅ immer explizit |
OutLanguageCode |
✅ rein additiv | live verifiziert | n/a |
Verwandter Fall — Teilmengen boolescher Flags. Sendet man von einer Flag-Gruppe (Field.*, include_*) nur einige, behalten die übrigen ihren serverseitigen Default. Ein fields-Argument kann dann nur erweitern, nie einschränken — es ist ein No-op, der wie Steuerung aussieht. Gegenprobe: ein Call mit explizitem false für ein Default-true-Flag muss weniger liefern. Tut er das nicht, sendet der Server die Gruppe unvollständig.
1.2c Struktur-Assertion — eine leere Probe ist noch kein Befund
Bevor eine Null in die Befund-Tabelle wandert, muss feststehen, dass die Probe an der richtigen Stelle gesucht hat. Sonst dokumentiert das Protokoll einen Bedienfehler als Eigenschaft der Quelle — und zwar dauerhaft, denn die Tabelle ist später die Referenz.
Belegfall aus dem Portfolio (2026-07): Eine Abfrage der MCP Registry lieferte konsequent nichts. Die Felder liegen dort unter servers[].server.*; die Probe suchte sie eine Ebene höher. Kein Fehler, kein Status-Code, keine Warnung — nur eine leere Liste, die exakt so aussieht wie «diese Quelle kennt den Eintrag nicht».
# FALSCH — unterscheidet nicht zwischen «nichts da» und «falsch gelesen»
curl -s "$URL" | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('servers',[])))"
# RICHTIG — erst die Struktur bestätigen, dann zählen
curl -s "$URL" | python3 - <<'EOF'
import json, sys
d = json.load(sys.stdin)
assert "servers" in d, f"Antwort hat kein 'servers' — Keys: {list(d)[:10]}"
rows = d["servers"]
assert rows, "Huelle vorhanden, aber leer — das ist ein echter Nullbefund"
sample = rows[0]
assert "name" in sample or "name" in sample.get("server", {}), \
f"'name' weder oben noch unter 'server' — Struktur: {json.dumps(sample)[:200]}"
print(len(rows))
EOF
Regel: Jede Probe, die null meldet, druckt bei null zusätzlich die obersten Schlüssel der Antwort und einen gekürzten Rohauszug. Das kostet zwei Zeilen und trennt die beiden Fälle sofort.
In der Befund-Tabelle bekommt jede Null eine Spalte «Struktur bestätigt»:
| Endpoint | HTTP | Records | Struktur bestätigt | Bemerkung |
|---|---|---|---|---|
/v0/servers?search=x |
200 | 0 | ✅ Hülle + Beispielzeile geprüft | echter Nullbefund |
/v0/servers?search=y |
200 | 0 | ❌ noch offen | kein Befund, Probe nachziehen |
Das ist dieselbe Regel wie 3.6 («Leermenge ≠ Abwesenheit»), eine Ebene höher: Dort schützt sie das Modell vor dem Tool, hier die Probe vor sich selbst.
Verwandt — aggregierte Endpoints hinken nach. Liefert eine Quelle dieselbe Information über mehrere Wege, sind sie nicht gleich aktuell. Bei PyPI meldete der JSON-Sammel-Endpoint (/pypi/<pkg>/json) dreimal in Folge nach einem Release noch die Vorversion, während der Simple Index und eine echte Installation sofort korrekt waren. Für jede Freshness-Aussage im Protokoll gehört deshalb dazu, welcher Endpoint befragt wurde — und für die belastbare Aussage der autoritative, nicht der bequeme.
1.2d Feldnamen-Inventar — die Schreibweise ist Teil des Befunds
Regel: Die Live-Probe protokolliert die tatsächlichen Feld- bzw. Spaltennamen jeder Antwort, samt Schreibweise — Gross-/Kleinschreibung, Umlaute, Trennzeichen, Leerzeichen, BOM. Und sie legt die rohe Antwort als aufgezeichnete Fixture ab, aus der später die Tests lesen.
Das ist die einzige Regel in Schritt 1, die nicht die Menge der Daten betrifft, sondern ihre Beschriftung. Sie steht hier, weil die Beschriftung genau einmal billig zu messen ist: in dem Moment, in dem die Probe die Antwort ohnehin in der Hand hat.
Belegfall aus dem Portfolio (2026-08-03). Eine Quelle wechselte die Schreibweise ihrer CSV-Kopfzeile von Schulgemeinde auf schulgemeinde. Vier von sechs Datensätzen eines Servers lieferten daraufhin nichts mehr. Alle Unit-Tests blieben grün — ihre von Hand geschriebenen Fixtures pinnten die alte Schreibweise, also prüften sie den Server gegen eine Welt, die es nicht mehr gab. Der Ausfall war live sichtbar und im Testlauf unsichtbar, und das ist die teure Kombination: Ein handgeschriebenes Fixture ist eine Behauptung über die Quelle, kein Beleg. Es kann per Konstruktion nicht auffallen, wenn die Quelle sich bewegt.
# Feldnamen-Inventar: was die Quelle WIRKLICH schreibt, nicht was die Doku sagt.
# repr() statt print(): macht Leerzeichen am Rand, BOM und NBSP sichtbar —
# genau die Zeichen, an denen ein Lookup scheitert, ohne sich zu zeigen.
curl -sS "$URL" -o fixtures/raw/quelle_2026-08-03.csv # aufgezeichnet, nicht getippt
python3 - fixtures/raw/quelle_2026-08-03.csv <<'PY'
import csv, sys
with open(sys.argv[1], encoding="utf-8-sig", newline="") as fh:
header = next(csv.reader(fh))
for name in header:
print(repr(name))
PY
# JSON: dieselbe Frage, eine Ebene tiefer
curl -sS "$URL" -o fixtures/raw/quelle_2026-08-03.json
python3 - fixtures/raw/quelle_2026-08-03.json <<'PY'
import json, sys
rows = json.load(open(sys.argv[1], encoding="utf-8"))
rows = rows if isinstance(rows, list) else rows.get("results", [])
assert rows, "keine Zeile — erst 1.2c klaeren, dann hier weiter"
for name in rows[0]:
print(repr(name))
PY
Ins Probe-Protokoll kommt die Liste vollständig, nicht als «wie dokumentiert»:
| Datensatz | Feld laut Doku | Feld gemessen (repr) |
Aufgezeichnet am | Fixture |
|---|---|---|---|---|
| Schulliste | Schulgemeinde |
'schulgemeinde' |
2026-08-03 | fixtures/raw/schulen_2026-08-03.csv |
| Schulliste | PLZ |
'PLZ' |
2026-08-03 | fixtures/raw/schulen_2026-08-03.csv |
| Klassen | — | 'Jahr' |
2026-08-03 | fixtures/raw/klassen_2026-08-03.csv |
Aufgezeichnet, nicht getippt. Ein Fixture wird aus der echten Antwort gespeichert und mit dem Abrufdatum im Namen abgelegt; es wird nicht von Hand nachgebaut. Ein nachgebautes Fixture erbt die Erwartung des Schreibenden — das ist derselbe Fehler wie in 1.4, nur eine Schicht tiefer: Der grüne Test beweist dann, dass der Server tut, was erwartet wurde, nicht dass die Erwartung noch stimmt.
Was daraus für Schritt 3 folgt. Feldnamen sind Fremddaten, kein Vertrag. Ein Lookup, der genau eine Schreibweise kennt, ist ein Ausfall mit Ankündigung; entweder wird beim Einlesen normalisiert (strip(), casefold, BOM weg) oder die akzeptierten Schreibweisen stehen explizit im Code. Und mindestens ein Test liest die aufgezeichnete Fixture, damit ein Schreibweisen-Wechsel beim nächsten Aufzeichnen als Diff auffällt, statt als Nullbefund in der Produktion.
1.3 Befund-Tabelle erstellen
Ausgabe von Schritt 1 ist immer eine Tabelle in diesem Format:
| Endpoint | HTTP | Status | Records | Bemerkung |
|---|---|---|---|---|
/table/X/list |
200 | ✅ funktioniert | 139 | wie dokumentiert |
/table/Y/list |
200 | ⚠️ leer | 0 | SQL-Filter zu restriktiv |
/table/Z/id/1 |
404 | ❌ existiert nicht | – | Doku veraltet |
/search/default/Foo |
200 | ✅ funktioniert | ~5 |
1.3b Abdeckungs-Matrix — welcher Teil des Bestands bleibt unerreichbar
Ziel: Die Befund-Tabelle hält fest, was die geprobten Endpoints liefern. Sie hält damit noch nicht fest, welcher Teil des Bestands über die geplanten Tools gar nicht erreichbar ist. Genau diese Zeile fehlt später, wenn jemand den Scope begründen muss.
Der Unterschied zu 1.2b: Dort liefert ein befragter Endpoint weniger als erwartet, und ein Delta beweist es. Hier gibt es kein Delta, weil niemand gefragt hat — Bestandsteile, die kein geplanter Endpoint anfasst, erzeugen keinen Fehler, keine Auffälligkeit und keine Zeile. Aus der Probe heraus sind sie per Konstruktion unsichtbar.
Warum das in Schritt 1 gehört und nicht in Schritt 2. Der Scope wird später begründet: im README, im Audit, gegenüber einem User, der etwas vermisst. Wer erst dann begründet, rekonstruiert — und Rekonstruktion liefert plausible Gründe, nicht gemessene. Portfolio-Fall: Ein Audit-Befund (ARCH-003) verlangte die Begründung des Architektur-Entscheids. Die nachgelieferte Begründung erklärte Konkurse und Baugesuche für ausserhalb der Quelle. Tatsächlich liegen sie in der Quelle und nur ausserhalb der geplanten Tools. Der Scope war richtig, die Begründung falsch — und falsch auf die teure Art, weil sie die Quelle kleiner macht, als sie ist. Zwei Zeilen im Probe-Protokoll hätten den Fehler unmöglich gemacht: Wer den Scope begründet, zitiert dann Gemessenes.
Die Achse kommt aus der Quelle, nicht aus dem Plan. Fast jede Quelle trägt eine explizite Bestandsachse — Rubriken, Publikationstypen, Registerarten, Themen, Datasets — und diese Liste ist meist selbst ein Endpoint (/categories, /types) oder eine Facetten-Aggregation. Sie wird vollständig enumeriert, danach werden die geplanten Tools hineinmarkiert. Der umgekehrte Weg — die Liste aus dem Tool-Entwurf bilden — kann nichts finden, was der Entwurf übersieht.
# Bestandsachse der Quelle enumerieren, vollständig, vor der Tool-Planung
curl -s "$BASE/categories" -o cats.json
python3 - cats.json <<'PY'
import json, sys
cats = json.load(open(sys.argv[1], encoding="utf-8"))
COVERED = {"hr", "sh"} # von Hand: was die geplanten Tools abfragen
for c in cats:
key = c["id"]
mark = "erreichbar" if key in COVERED else "NICHT erreichbar"
print(f"{key:<20} {c.get('count', '?'):>9} {mark}")
PY
Ohne Kategorien-Endpoint: Facetten einer leeren Suche, das Typ-Feld des Bulk-Dumps auszählen, oder die Rubrikenliste der offiziellen Oberfläche — dieselbe Ground Truth wie in 1.4, eine Frage früher gestellt.
| Bestandsteil | in der Quelle | über geplante Tools | Beleg | Grund |
|---|---|---|---|---|
| Handelsregister-Meldungen | 812'000 | ✅ | /search?rubric=HR, 200 |
Kern der Anchor-Query |
| Konkurse | 96'000 | ❌ | Rubrik enumeriert, kein Tool | bewusst ausserhalb Scope (Phase 1) |
| Baugesuche | 41'000 | ❌ | Rubrik enumeriert, kein Tool | bewusst ausserhalb Scope (kantonal uneinheitlich) |
| Betreibungen | ? | ❌ | Auth nötig (401) | technisch nicht erreichbar |
Drei zulässige Gründe für ein ❌, mehr nicht:
- bewusst ausserhalb des Scopes — mit dem Grund, nicht nur mit dem Wort
- technisch nicht erreichbar — kein Endpoint, Auth, Lizenz, Rate-Limit
- noch offen — nicht geprüft; ein offener Befund, kein Freibrief
Nicht zulässig ist die vierte Möglichkeit, die in der Praxis die häufigste ist: gar nicht erwähnt. Eine Zeile ohne Grund ist ein offener Befund und gehört ins README, nicht in den Papierkorb — dieselbe Regel wie beim unerklärten Delta in 1.4b.
Wohin das Ergebnis geht: in die Rationale des Architektur-Entscheids (2.3) und ins README unter «Known limitations» / «Scope». Ein Server, der ein Viertel des Bestands abdeckt, ist völlig in Ordnung; ein Server, der nicht sagen kann, welches Viertel, ist es nicht.
1.4 Reality-Check gegen die offizielle Oberfläche
Gilt für Listen- UND für Such-Endpoints. Diese Erweiterung ist die Lehre aus termdat-mcp: Dort wurde der Reality-Check korrekt auf die Listen-Endpoints angewandt — 140 Collections, 23 Classifications, beide Zahlen stimmten — und nie auf den Such-Endpoint. Nicht die Regel fehlte, sondern ihre Reichweite. Recall entsteht in der Suche, also muss er dort gemessen werden.
(a) Bestandszahlen. Die Homepage behauptet in der Regel Zahlen («246 Parlamentarier:innen», «139 Lobbygruppen»). Live-Probe damit abgleichen. Liefert die API nur einen Bruchteil, ist das ein Alarm-Signal — entweder ist die API defekt, oder ein Filter ist restriktiver als dokumentiert.
(b) Recall-Ground-Truth für Such-Endpoints. Das offizielle Web-UI ist die einzige verfügbare Ground Truth. 3–5 Referenzbegriffe wählen und in beiden Oberflächen am selben Tag abfragen:
- einer mit vielen Treffern (fängt Limit- und Scope-Defaults),
- einer mit wenigen (fängt Feld-Abdeckung),
- einer als Kompositum oder mit Sonderzeichen (fängt Matching-Granularität und Encoding),
- nicht der Anchor-Demo-Query — der funktioniert immer, er wurde beim Bau optimiert.
| Referenzbegriff | Web-UI | API | Delta | Erklärung |
|---|---|---|---|---|
Quellensteuer |
12 | 7 | −5 | UI zählt Benennungen, API zählt Entries |
Pensionskasse |
25 (gekürzt) | 28 | +3 | UI-Anzeige gekürzt |
Bundeskanzlei |
4 | 4 | 0 | — |
Ein Delta ist zulässig — aber nur erklärt. «Weiss ich nicht» ist ein offener Befund und gehört ins README, nicht in den Papierkorb. Typische legitime Erklärungen: das UI zählt Benennungen statt Datensätze, das UI kürzt die Anzeige, das UI sucht über alle Sprachen, das UI durchsucht mehr Felder.
(c) Als Regressionstest festschreiben. Der Vergleich ist einmalig nur die Hälfte wert. Untergrenzen — grosszügig unter dem Ist-Wert, Faustregel Hälfte — als Live-Test:
@pytest.mark.live
async def test_recall_floor():
"""Recall-Canary: fängt Scope-Regressionen und Upstream-Default-Änderungen."""
for term, floor in [("Pensionskasse", 10), ("Quellensteuer", 1)]:
entries, _ = await client.search(term, max_results=100)
assert len(entries) >= floor, f"{term}: {len(entries)} < {floor} — Scope geschrumpft?"
Keine exakten Zahlen als Assertion: Der Test soll einen Kollaps von 21 auf 1 fangen, nicht bei jeder Bestandspflege rot werden. Ein Test, der ständig falsch anschlägt, wird abgeschaltet und fängt dann gar nichts mehr.
Bei Quellen ohne offizielles Web-UI: Ersatz-Ground-Truth dokumentieren — Zeilenzahl des Bulk-Dumps, veröffentlichte Bestandszahlen, Angaben im Katalogeintrag.
1.5 Widening-Schedule gegen die Live-API messen
Ziel: Wenn ein Tool bei null Treffern den Suchbegriff verkürzt und erneut fragt, ist diese Staffel eine Annahme über die Quelle — über ihre Matching-Granularität, ihre Stemming-Regeln, ihre Mindestlänge. Die Quelle beantwortet die Frage selbst, in einer Handvoll Calls: ab welcher Präfixlänge liefert sie Treffer? Das ist billig zu messen, solange man ohnehin an der API hängt, und teuer zu raten, weil eine zu früh abgebrochene Staffel wie ein sauberer Nullbefund aussieht (3.6).
Belegfall. Eine Staffel kürzte den Suchbegriff in Schritten von 30 % und hatte ihre unterste Stufe bei acht Zeichen. Für Betonsanierungsarbeiten endete sie damit bei Betonsan; Treffer lieferte die Quelle erst ab Beton. Drei Zeichen Abstand, und die Antwort lautete «nichts gefunden» für einen Bestand, der die Einträge hatte. Der Prozentsatz war nicht knapp daneben — er war die falsche Grösse. Deutsche Komposita brechen an Morphemgrenzen (Beton|sanierungs|arbeiten), und eine Prozentstaffel trifft eine Morphemgrenze nur zufällig. Die brauchbare Zahl steht nicht in der Formel, sondern in der Quelle.
Die Messung — pro Testbegriff jede Präfixlänge einmal abfragen:
widening_probe() {
# $1 = Testbegriff. Eine Zeile pro Präfixlänge: ab wo liefert die Quelle?
local term="$1" n p hits
for (( n=${#term}; n>=3; n-- )); do
p="${term:0:n}"
hits=$(curl -s --get --data-urlencode "q=$p" "$BASE/search" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('data') or []))")
printf " %2d %-28s %s\n" "$n" "$p" "$hits"
done
}
widening_probe "Betonsanierungsarbeiten"
3–5 Begriffe, bewusst gewählt: ein langes Kompositum, einer mit Bindestrich (bricht die Staffel am Trennzeichen?), einer mit Umlaut (Encoding über die Kürzung hinweg), einer aus einer anderen Sprachregion. Nicht die Anchor-Demo-Query — sie funktioniert immer, aus demselben Grund wie in 1.4b.
Das sind rund zwanzig Aufrufe pro Begriff. Bei engen Rate-Limits in Zweierschritten laufen und die Grenze danach binär einkreisen — die gesuchte Zahl ist ein einzelner Übergang von 0 auf n, keine Kurve.
| Testbegriff | Länge | kürzestes Präfix mit Treffern | Treffer | Morphemgrenze | Wildcard-Alternative |
|---|---|---|---|---|---|
Betonsanierungsarbeiten |
23 | Beton (5) |
143 | ✅ | Beton* → 143 |
Gebäudeversicherung |
19 | Gebäude (7) |
88 | ✅ | Gebäude* → 88 |
Baubewilligung |
14 | Baubewilligung (14) |
12 | – | Baubewilligung* → 19 |
Die dritte Zeile ist der Fall, den eine Staffel nicht lösen kann: Kürzen hilft nicht, weil kein Präfix ein ganzes Wort ist — Treffer bringt hier nur die Wildcard. Wer nur die Staffel baut, hat für diese Begriffsklasse gar keinen zweiten Versuch.
Drei Dinge entscheidet diese Messung, die vorher geschätzt wurden:
- Die unterste Stufe. Sie kommt aus der Spalte «kürzestes Präfix», nicht aus einem Prozentsatz. Eine Staffel, die diesen Wert nicht erreicht, meldet Abwesenheit für vorhandene Daten — und zwar leise.
- Ob die Staffel überhaupt das richtige Mittel ist. Beherrscht die Quelle Präfix-Wildcards, liefert
Beton*dasselbe in einem einzigen Aufruf. Dann ist die Staffel ein Workaround für eine vorhandene Funktion, mit N-fachem Verkehr und N-facher Latenz. Die Wildcard-Spalte gehört deshalb in dieselbe Messung: Steht sie, ist der Entscheid schon getroffen. - Wo die Präzision kippt. Nach unten hin trifft jedes Präfix irgendwann alles —
Befängt die halbe Quelle. Die Messung zeigt beide Enden. Eine Staffel braucht nicht nur einen Boden, sondern auch die Stufe, ab der sie besser abbricht und einenhintzurückgibt (3.6), statt 4'000 unspezifische Treffer als Erfolg auszugeben.
Was ins Protokoll geht: die Tabelle oben, und die gemessene unterste Stufe zusätzlich als Kommentar an die Staffel im Code — mit Begriff und Datum. Ohne diesen Kommentar wird die Zahl beim nächsten Refactoring auf einen runden Wert «vereinfacht», und die Messung war umsonst. Analog zum Recall-Canary aus 1.4c lohnt ein Live-Test, der den gemessenen Begriff über die Staffel schickt und Treffer verlangt: Er fängt sowohl eine gekürzte Staffel als auch eine Upstream-Änderung an der Matching-Granularität.
Führt der Server das Widening automatisch aus, gilt zusätzlich 3.6: Die Antwort muss sagen, welche Begriffe versucht wurden. Sonst ist die Leermenge nach fünf stillen Versuchen von der Leermenge nach einem nicht unterscheidbar.
1.6 Dump-Verfügbarkeit prüfen
Parallel zu den API-Probes immer prüfen, ob die Quelle einen Bulk-Download anbietet:
- CSV / JSON / XML / SQL-Dump
- Datenmenge und Update-Frequenz
- Lizenz gleich wie API (meistens ja, sicherheitshalber prüfen)
Faustregel: Jede Schweizer Behörden- oder NGO-Datenquelle, die «seriös» ist, bietet einen Dump. Wenn keiner auffindbar ist, gezielt nachfragen oder in GitHub-Issues suchen.
1.7 Aktualisierungsrhythmus messen — die Grundlage für ttlMs und cacheScope
Ziel: Festhalten, wann sich der Bestand ändert. Diese eine Beobachtung entscheidet später, wie lange ein Client eine Antwort behalten darf — und sie ist nach dem Bau nicht mehr billig zu bekommen, weil sie eine Serie über Tage ist und kein einzelner Call.
Der Schritt fragt genau eine Frage und variiert genau eine Grösse: die Zeit. Endpoint, Parameter und Abfrage bleiben über alle Messpunkte identisch — sonst misst man Parameterwirkung statt Rhythmus. Deshalb ist er ein eigener Schritt und kein Anhängsel an 1.6: Dort geht es darum, ob es einen Dump gibt, hier darum, wann die Quelle neu ist, und das gilt für Dump und Live-API gleichermassen.
Die Behauptung steht in der Doku, die Messung im Header. «Täglich aktualisiert» im Katalogeintrag ist derselbe Typ Aussage wie ein dokumentierter Parameter-Default aus 1.2b: plausibel, oft richtig, nie belegt. Belegt wird sie mit einer Serie — dieselbe Ressource, mehrfach, über mindestens zwei erwartete Zyklen:
# Eine Zeile pro Messpunkt. Ein einzelner Abruf zeigt einen Zeitstempel,
# keine Periode — und die Periode ist das Gesuchte.
freshness_probe() {
printf '%s ' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
curl -sI "$1" | python3 -c "
import sys
h = {k.strip().lower(): v.strip()
for k, v in (l.split(':', 1) for l in sys.stdin.read().splitlines() if ':' in l)}
print('last-modified:', h.get('last-modified', '—'), '| etag:', h.get('etag', '—'))
"
}
freshness_probe "$BASE/dump/current.json"
Liefert die Quelle weder Last-Modified noch ETag, trägt eine andere Stelle dieselbe Information — in dieser Reihenfolge, weil sie unterschiedlich nah an der Wahrheit sind:
- ein Datums- oder Versionsfeld im Payload (
stand,updated_at,data_version), - das Dateidatum des Bulk-Dumps oder der Verzeichnisindex, der ihn listet,
- der Katalogeintrag (opendata.swiss, CKAN
metadata_modified), - die Angabe der offiziellen Oberfläche — dieselbe Ground Truth wie in 1.4, eine andere Frage gestellt.
Der Vorbehalt aus 1.2c gilt hier besonders: aggregierte Endpoints hinken nach. Ein Katalogeintrag, der die Aktualisierung meldet, bevor der Dump sie hat, ist für ttlMs schlimmer als gar keine Angabe — er verspricht eine Frische, die der ausgelieferte Datenstand nicht hat.
Wo die Felder sitzen. ttlMs und cacheScope stehen auf der obersten Ebene des Result-Objekts, nicht in _meta. Die Spec bündelt sie in CacheableResult:
export interface CacheableResult extends Result {
ttlMs: number;
cacheScope: "public" | "private";
}
Sechs Result-Typen erben davon: ListToolsResult, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult und DiscoverResult — Letzterer für den optionalen server/discover-RPC aus 2.4. CallToolResult gehört nicht dazu: Ein Tool-Ergebnis trägt keine Cache-Angabe, und wer eine hineinschreibt, hat sie erfunden.
ttlMs ist in dieser Schnittstelle nicht optional. Es gibt kein Weglassen und Offenlassen — jede List-, Read- und Discover-Antwort nennt eine Zahl. Damit ist diese Messung keine Kür: Die Alternative zur gemessenen Zahl ist nicht «keine Zahl», sondern eine geratene.
Zwei ttlMs-Familien, nicht eine. Die sechs Typen zerfallen in zwei Haltbarkeiten mit zwei verschiedenen Uhren:
| Response | Was veraltet | Uhr | Woher die Zahl kommt |
|---|---|---|---|
resources/list, resources/templates/list, resources/read |
die Daten | Aktualisierungsrhythmus der Quelle | diese Messung |
tools/list, prompts/list, server/discover |
die Oberfläche des Servers | Deployment-Rhythmus | Release-Kadenz, nicht die Quelle |
Wer beiden dieselbe Zahl gibt, trifft eine von zwei Fehlentscheidungen: Er hält eine Tool-Liste über ein Release hinweg fest, oder er wirft stündlich einen Katalog weg, der sich zweimal im Jahr ändert. Für die Oberfläche ist ein Tagesdeckel die brauchbare Faustregel — sie ändert sich beim Deployment, und ein Deployment kündigt sich einem Client nicht an.
Die Ableitung für die Datenseite:
| Rhythmus der Quelle | Beispiel | Empfohlenes ttlMs |
Warum |
|---|---|---|---|
| periodisch, Zeitpunkt bekannt | MADD, täglich gegen 05:30 CET | Rest bis zum nächsten Lauf plus Karenz, pro Response berechnet | die Antwort weiss, wo im Zyklus sie steht — ein fixer Wert weiss es nie |
| periodisch, Zeitpunkt unbekannt | «wöchentlich», ohne Wochentag | halbe Periode, statisch (wöchentlich → 302'400'000) | ohne Zeitpunkt ist die halbe Periode der schlechteste Fall, den man garantieren kann |
| unregelmässig, ereignisgetrieben | Meldungsstrom, Störungsmeldungen | kurz, Minuten (z. B. 300'000) — und die Kürze begründet | es gibt keine Periode; ein langes TTL wäre eine Behauptung über die Zukunft |
| selten bis statisch | Jahresstatistik, Nomenklatur | lang, aber gedeckelt (86'400'000) | jenseits eines Tages hängt die Gültigkeit am Deployment, nicht mehr an der Quelle |
Die Karenz kommt aus der Serie, nicht aus einem runden Wert. Ein Nachtlauf, der meist um 05:30 fertig ist, ist an manchen Tagen um 06:07 fertig. Ein ttlMs, das exakt um 05:30 abläuft, holt an diesen Tagen den alten Stand und hält ihn einen ganzen Zyklus — der Fehler ist nicht 37 Minuten gross, sondern 24 Stunden. Die Karenz ist deshalb die grösste in der Messreihe beobachtete Verspätung, aufgerundet; dieselbe Logik wie bei der untersten Staffelstufe in 1.5, wo die brauchbare Zahl auch in der Quelle steht und nicht in der Formel.
cacheScope hat zwei Werte, und dahinter steht eine einzige Frage: Darf diese Antwort über Autorisierungskontexte hinweg geteilt werden? Die Semantik ist die von HTTP Cache-Control, mitsamt der Falle, die dort dieselbe ist — der Wert sagt nicht, wie vertraulich die Daten sind, sondern wer die gecachte Kopie zu sehen bekommt.
"public"— die Antwort ist in jedem Autorisierungskontext dieselbe und darf von einem gemeinsamen Zwischenspeicher geteilt werden. Öffentliche Behördendaten ohne Auth: der Normalfall in Phase 1, und genau das Erwünschte. Ein 17-MB-Dump, den jede Sitzung neu zieht, ist die Kostenseite von 2.3."private"— die Antwort hängt am Autorisierungskontext und bleibt in ihm. Sobald Auth im Spiel ist (Phase 2), ist das der Default, auch wenn der Inhalt zufällig für alle gleich aussieht: Die Frage ist nicht, ob die Daten geheim sind, sondern ob der Server garantieren kann, dass jeder Kontext dieselbe Antwort bekäme.
Der Entscheid fällt pro Response-Typ, nicht pro Server: Eine öffentliche Tool-Liste und eine auth-abhängige Resource-Liste im selben Server sind kein Widerspruch. Handles als Tool-Argumente (Stateless Core, 2.4) ändern daran nichts — Tool-Ergebnisse tragen die Felder gar nicht. Wer die Frage falsch beantwortet, baut den einen Fehler, den ein Cache machen kann und den kein TTL repariert: die Antwort für den falschen Aufrufer.
Frische innen, Haltbarkeit aussen. Zwei Zahlen, zwei Richtungen, und sie werden regelmässig verwechselt:
retrieved_atundsource_freshnessim Response-Envelope — nebensourceundprovenanceaus 3.2 — sagen, wie alt die Daten sind. Eine Aussage über die Vergangenheit, gerichtet an den Leser der Antwort.ttlMssagt, wie lange die Antwort gültig bleibt. Eine Aussage über die Zukunft, gerichtet an den Cache des Clients.
Merksatz fürs Portfolio: «Frische innen (source_freshness), Haltbarkeit aussen (ttlMs).»
Sie sind nie dieselbe Zahl, und die eine ersetzt die andere nicht. Ein ttlMs von zwölf Stunden auf einem Datenstand von gestern ist kein Widerspruch — es heisst «diese Antwort bleibt zwölf Stunden korrekt, und korrekt ist: Stand gestern». Fehlt die innere Angabe, liest der Client die äussere als Datenalter und irrt sich um einen ganzen Zyklus.
Deterministische Reihenfolge gehört in dieselbe Messung. Die Spec verlangt sie für List-Responses, und ohne sie ist ein ttlMs wertlos: Wer bei jedem Aufruf eine andere Reihenfolge bekommt, kann zwei Antworten nicht vergleichen und cacht eine Momentaufnahme statt eines Zustands. Quellen ohne ORDER BY (SQL-über-HTTP, Solr ohne sort, viele SPARQL-Endpoints) garantieren upstream nichts — dann sortiert der Server, und der Sortierschlüssel gehört ins Protokoll. Die Probe dazu ist eine Zeile: denselben Listen-Call zweimal, die IDs vergleichen.
Was ins Protokoll geht — eine Zeile pro Ressource, die eine List- oder Read-Response bedient:
| Ressource | dokumentierter Rhythmus | gemessene Serie | grösste Verspätung | empfohlenes ttlMs |
cacheScope |
Reihenfolge stabil |
|---|---|---|---|---|---|---|
| Tages-Dump | «täglich» | 05:28 / 05:31 / 06:07 CET | +37 min | bis 05:30 + 45 min, dynamisch | "public" |
✅ upstream nach id |
| Katalog-Endpoint | «laufend» | 4 Änderungen in 14 Tagen | – | 300'000 | "public" |
⚠️ Server sortiert nach id |
| Nomenklatur | «jährlich» | unverändert über 14 Tage | – | 86'400'000 (Deckel) | "public" |
✅ upstream |
Wohin das Ergebnis geht: in die Konsequenzen des Architektur-Entscheids (2.3), zusammen mit der internen Cache-TTL, die dort schon steht — die beiden sind nicht dasselbe und stehen bewusst nebeneinander. Die interne TTL sagt, wann der Server neu holt; ttlMs sagt, wann der Client neu fragt. Ein Server, dessen interne TTL länger ist als das ttlMs, das er verspricht, beantwortet die neue Anfrage aus demselben alten Cache und hat die Zusage gebrochen, ohne dass es jemand merkt.
Schritt 2: Architektur-Entscheid [Kern]
Ziel: Basierend auf den Probe-Befunden die richtige Architektur wählen — und den Entscheid schriftlich im README festhalten.
2.1 Der Entscheidungsbaum
Live-Probe-Ergebnisse
│
├─ Alle nötigen Endpoints funktionieren stabil
│ └─ ARCH A: Live-API-only
│
├─ Einige Endpoints broken/leer, Dump vorhanden
│ └─ ARCH B: Hybrid (Dump-first, API-Fallback) ⭐ Häufigster Fall
│
├─ Keine nutzbaren Endpoints, nur Dump
│ └─ ARCH C: Dump-only
│
├─ Weder stabile API noch Dump
│ └─ BLOCKER: An Datenanbieter eskalieren,
│ Portfolio-Karte als «geblockt» markieren
│
└─ Auth nötig
└─ Phase 2 verschoben, Phase 1 skippen ODER
No-Auth-Teil isolieren und nur diesen bauen
2.2 Portfolio-Synergie-Check
Bevor ein neuer Server gebaut wird, prüfen:
- Passt die Datenquelle in ein bestehendes Cluster? (Transport, Environment, Legal, Statistics, Education, Economics, Culture, Health, Registers, Parliament)
- Gibt es einen existierenden
*-mcp-Server, der diese Daten logisch ergänzt? → Tool-Extension statt neuer Server. - Wenn neuer Server: Was ist die «anchor demo query», die die Komplementarität zum Portfolio zeigt?
Entscheidungsfrage: «Wenn der User beide Server in derselben Konversation nutzt, was wird dadurch möglich, was vorher nicht ging?» → Wenn die Antwort schwach ist, ist es wahrscheinlich eine Tool-Extension.
2.3 Architektur-Entscheid im README dokumentieren
Zwingend in jedem neuen Server-README unter einem Abschnitt «Architecture decision» oder «Architektur-Entscheid» (bilingual):
## Architecture decision
This server uses **Architecture B (Hybrid: Dump-first, API-fallback)**.
Rationale (verified live on YYYY-MM-DD):
- The weekly JSON dump contains X records with Y fields, refreshed every …
- Live endpoint `/table/Z` returns empty results at release time.
- The ABC endpoint works reliably for lookups, so it is used for single-entity
calls.
Scope (measured, see coverage matrix in step 1.3b):
- Reachable: rubrics HR and SH — 812'000 of roughly 950'000 records.
- Out of scope by decision: bankruptcies, building permits — present in the
source, not covered by any tool of this server.
- Out of reach: debt-enforcement records — the endpoint requires authentication.
Spec target: MCP 2026-07-28 (portfolio default, no deviation — see step 2.4).
Consequences:
- Transports: stdio and streamable-http.
- Dump is cached on disk with Z hours TTL — one cache per process under stdio,
one shared cache per instance under streamable-http.
- Cache lifetime advertised to clients: ttlMs is derived from the source's daily
05:30 CET refresh and computed per response; the same answer is served to
every caller. Measured in step 1.7, not estimated.
- Library functions / retry / provenance behaviour: see docstrings.
Der Transport gehört in die Konsequenzen. Unterstützt werden immer beide —
stdio für Claude Desktop, streamable-http für Cloud-Deployments. Damit ist
bei ARCH B und C eine zweite Entscheidung getroffen, ohne dass sie jemand
ausgesprochen hat: Unter stdio startet pro Client ein eigener Prozess, der
Cache lebt genau eine Sitzung und der Dump wird pro Sitzung neu geladen. Unter
streamable-http bedient ein Prozess viele Clients, derselbe Cache lebt so
lange wie die Instanz und wird geteilt. Dieselbe TTL bedeutet also zwei
verschiedene Dinge — bei einem 17-MB-Dump die Frage, ob jede Sitzung ihn zieht
oder keine.
Der Scope gehört in dieselbe Begründung, mit Zahlen. Ein Architektur-Entscheid sagt, wie die Daten geholt werden; ohne den Scope-Absatz sagt er nicht, welche. Die Zeilen dafür stehen bereits in der Abdeckungs-Matrix aus 1.3b und müssen nur übernommen werden — genau darum wurde sie beim Proben angelegt und nicht hier. Wer sie erst hier schreibt, schreibt sie aus dem Gedächtnis.
Das ändert auch, was provenance: cached aus 3.2 aussagt: unter stdio «in
dieser Sitzung schon geholt», unter streamable-http «womöglich Stunden alt und
für jemand anderen geholt». Den Zeitstempel des letzten erfolgreichen Abrufs
verlangt 3.5 bereits — dort für den Ausfall. Bei geteiltem Cache braucht ihn
auch die erfolgreiche Antwort, sonst hängt das Alter der Daten an der
Deployment-Konfiguration statt an der Antwort.
2.4 Spec-Ziel-Entscheid — welche mcp_spec_version der Server spricht
Ziel: Neben A/B/C trägt jeder neue Server einen zweiten Pflicht-Entscheid: gegen welche MCP-Spec-Version er gebaut wird. Er wird gleich behandelt wie der Architektur-Entscheid — hier getroffen, im README begründet, im Portfolio eingetragen. Ein Entscheid, der nur im Code steht, ist kein Entscheid, sondern ein Zustand.
Standard neu: 2026-07-28. Das ist kein Vorschlag, sondern der Default. Die Tier-1-SDKs (Python, TypeScript, Go, C#) sprechen die Version; für Variante A des Portfolios (mcp 2.x mit MCPServer) gibt es damit keinen technischen Abweichungsgrund.
Die zulässigen Abweichungsgründe, abschliessend:
- Ein SDK-Pin blockiert. Standalone
fastmcp3.x pinntmcpunterhalb 2.0,fastmcp4.0 ist erschienen und bringt Breaking Changes. Wer auf dieser Variante baut, trägt drei Dinge ein: die Version, die das SDK tatsächlich spricht, den Pin, der sie erzwingt, und die Bedingung, unter der die Abweichung endet. - Eine belegte Upstream-Abhängigkeit. Ein Client oder eine Deployment-Plattform, die nachweislich noch nicht so weit ist — mit Beleg, nicht mit Vermutung.
Nicht zulässig: «das Beispiel im Tutorial sah anders aus», «der letzte Server im Portfolio macht es so», «wir migrieren später ohnehin». Der dritte ist der teuerste, weil er stimmt und trotzdem falsch ist: Ein neuer Server auf altem Stand vergrössert genau die Migrationswelle, deren Ende er abwarten will.
Kein neuer Server auf deprecated Bausteinen. Vier Bausteine stehen im 12-Monats-Fenster. Ein bestehender Server darf sie tragen, bis seine Welle dran ist; ein neuer fängt nicht damit an. Das Fenster ist eine Frist für Bestehendes, kein Budget für Neues.
| Deprecated | Ersatz | Was das konkret heisst |
|---|---|---|
| Roots | explizite Handles als Tool-Argumente | Der Server fragt den K |
…(truncated)