- name
- mcp-data-fidelity
- description
- Datentreue-Regeln für MCP-Server-Tools, die eine externe Datenquelle abfragen — damit ein Server nicht still unvollständig liefert. Verwende diesen Skill ergänzend zu mcp-builder immer wenn (1) ein Such-, Query- oder Filter-Tool für einen MCP-Server entworfen oder gebaut wird, (2) eine Tool-Description dafür geschrieben wird, (3) jemand meldet, ein Server finde nichts, zu wenig oder weniger als die offizielle Oberfläche («findet nichts», «leeres Ergebnis», «Web-UI zeigt mehr», «Recall», «Scope»), (4) ein Modell auf ein leeres Tool-Result hin etwas erfunden hat oder ein Fuzzy-Fallback entworfen wird, (5) optionale API-Parameter (Filter, Facetten, Feld-Flags) in Requests übersetzt werden, (6) Tests dafür geschrieben werden, (7) ein Server auf MCP-Spec 2026-07-28 migriert wird (Sortierreihenfolge, `ttlMs`/`cacheScope`, MRTR), oder (8) eine Antwort geparst wird — Kopfzeilen, Spaltennamen, Schreibweise, Zahlenspalten mit unterdrückten Werten («1 bis 5»), Summen. Nicht nötig für Server ohne externe Datenquelle.
# MCP Data Fidelity — liefert der Server, was die Quelle hat?
Companion zu `mcp-builder`. Dessen Best Practices decken ab, ob ein Server **korrekt gebaut** ist — Naming, Annotations, Pagination, Transport, Fehlerbehandlung. Dieser Skill deckt die Frage daneben ab: **liefert er, was die Quelle tatsächlich hat?**
Das ist eine eigene Fehlerklasse, weil sie still ist. HTTP 200, wohlgeformtes JSON, grüne Tests — und inhaltlich falsch. Ein Server, der zwei Prozent des Bestands durchsucht und das nicht meldet, produziert Antworten, die niemand als falsch erkennt.
**Die Leitfrage bei jedem datenabfragenden Tool:** *Wenn dieses Tool nichts findet — kann ich unterscheiden, ob es nichts gibt oder ob ich falsch gefragt habe?* Ist die Antwort nein, greift eine der vierzehn Regeln unten.
Seit Regel 10 steht die Stufe darunter daneben: *und wenn ich falsch gefragt habe — komme ich von hier zur richtigen Frage?* Die erste Frage entscheidet, ob das Modell schweigen darf. Die zweite, ob es weiterkommt, ohne sich einen Treffer zu erfinden.
Regel 11 liefert das Material für die erste Frage: Wer nicht mitliest, **welche Anfrage** die Leermenge erzeugt hat, kann «nichts da» von «falsch gefragt» prinzipiell nicht trennen — er kann die Leitfrage nur raten. Regel 12 stellt beide Fragen eine Ebene tiefer, am einzelnen Feld: Ein `null` beantwortet sie genauso wenig wie ein `[]`.
Die Regeln 13 und 14 setzen dort an, wo die Antwort die Quelle bereits verlassen hat und der eigene Code sie liest: Regel 13 am Namen des Feldes, Regel 14 an seinem Inhalt. Beide erzeugen einen Ausfall, der wie eine Antwort aussieht — die eine eine leere Trefferliste, die andere eine Zahl, die zu tief ist.
Die Regeln 1–6 und 10–14 stammen aus Vorfällen, die Regeln 7–9 aus der Spec 2026-07-28. Der Unterschied ist ausgewiesen und nicht kosmetisch — siehe den Abschnitt vor Regel 7. Die Nummerierung folgt der Reihenfolge, in der die Regeln dazugekommen sind, nicht dieser Gruppierung.
---
## Regel 1 — Scope-Parameter explizit senden, nie erben
Ein optionaler Filter-Parameter bedeutet beim Weglassen oft **nicht** «unbeschränkt», sondern einen willkürlichen Teilausschnitt. Diese Tatsache steht ausschliesslich in der **Parameterbeschreibung** der Spec — nicht im Response-Schema, nicht im Doku-Beispiel, und an einem funktionierenden Call ist sie nicht erkennbar.
Verbreitete Vertreter:
| Quelle | Parameter | Default bei Weglassen |
|---|---|---|
| CKAN `package_search` | `rows` | 10 Treffer |
| WFS `GetFeature` | `count` / `maxFeatures` | serverseitiges Limit |
| SPARQL | `FROM` / Named Graphs | nur Default-Graph |
| Elasticsearch / Solr | `size`, `fq`, `df` | 10 Hits, eingeschränktes Default-Feld |
| GraphQL (Relay) | `first` | schema-abhängig, oft klein |
| TERMDAT `/v2/Search` | `ClassificationIds` | 1 von 23 Sachgebieten |
```python
# ✗ Der Parameter geht nur raus, wenn der Aufrufer ihn kennt.
if classification_ids:
params["ClassificationIds"] = classification_ids
# ✓ Kein Filter vom Aufrufer → voller Scope, explizit gesendet.
if classification_ids is None:
classification_ids = await self._all_classification_ids()
if classification_ids:
params["ClassificationIds"] = classification_ids
```
Muss der volle Scope zur Laufzeit ermittelt werden (Vokabular-Endpoint), dann **best-effort**: Fällt die Ermittlung aus, läuft die Suche unerweitert weiter. Eine Erweiterung darf nie brechen, was sie erweitert.
**Nachweis:** Zwei Calls, exakt eine Variable geändert — Parameter weggelassen vs. explizit maximal. Delta ≠ 0 heisst, der Server muss ihn senden.
**Wer den Recall verengt, zitiert den Scope.** Eine bewusste Verengung — exakt statt Wildcard, kein Fuzzy-Matching, kein Prefix — wird fast immer mit einem Risiko begründet: Eine falsche Zuordnung wäre *hier* besonders teuer. Das Argument trägt nur, wenn die Datenklasse, die das Risiko trägt, über diesen Server überhaupt erreichbar ist.
Belegfall ([`amtsblatt-mcp`, ARCH-003-Finding vom 2026-07-30](https://github.com/malkreide/amtsblatt-mcp/blob/main/audits/2026-07-30T105205-Z-amtsblatt-mcp/findings/ARCH-003.md)): Version 0.20.0 lehnte einen Vorschlags- oder Fuzzy-Mechanismus ausdrücklich ab und begründete das mit «bankruptcy notices, debt-collection summonses, estate calls, construction objections» — dem Schaden, die falsche Firma als konkurs zu benennen. **Jede Rubrik dieser Liste ist rot und über kein Tool erreichbar:** `KK`, `SB`, `SR`, `LS`, `NA`, `ES`, `TE-*`, `GB-*`, `GE-*`, `BP-*` liegen sämtlich ausserhalb der `GREEN_RUBRICS` — einer Allow-Liste, die genau dafür existiert, systematische Personendaten auszuschliessen.
Die Verkehrung ist der Punkt: Weil der durchsuchbare Bestand der **nicht-sensible** ist, wurde die Ausnahme für sensible Daten für genau die Menge beansprucht, auf die das Kriterium anzuwenden gewesen wäre. Die Begründung klang zwingend, stand in beiden `SECURITY`-Dateien und im CHANGELOG — und war an nichts gekoppelt, was der Server je ausliefert. Es ist die vorformulierte Ausrede aus Regel 4, eine Stufe früher: Regel 4 fängt sie dort, wo das Modell sie liest, hier wird sie dort gefangen, wo jemand sie schreibt.
Was von der Begründung übrig bleibt, ist schmaler und echt — und zeigt, wozu die Prüffrage dient: `HR`/`BH` (Handelsregister) und `OB-*` (Beschaffungen) **sind** erreichbar und nennen juristische Personen, ein verbreiterter Firmenname liefert also Meldungen über andere Firmen. Das ist ein Argument darüber, *wie* verbreitert wird, keine Ausnahme dagegen, überhaupt etwas anzubieten. Der Unterschied zwischen den beiden Begründungen ist nicht die Sorgfalt, mit der sie formuliert sind, sondern ob eine erreichbare Rubrik darunter steht.
**Prüffrage, zwei Teile:** *Nenne die Rubriken oder Datenklassen, die das Risiko tragen — und weise nach, dass sie erreichbar sind.* Der Nachweis ist derselbe wie oben: die Aufzählung des vollen Scopes, gegen die diese Regel ohnehin misst. Steht die Klasse nicht darin, fällt die Begründung. Die Verengung kann trotzdem richtig sein — aber sie muss aus dem neu begründet werden, was tatsächlich in Reichweite liegt. Ist die riskante Klasse erreichbar, gilt die Umkehrung: Dann ist Exakt-only richtig (die Ausnahme für sensible Daten in `ARCH-003`), und die Klasse gehört namentlich in die Tool-Description — sonst liest das Modell die Verengung als Lücke und rät sich darüber hinweg.
## Regel 2 — Parameter-Gruppen vollständig senden
Sendet man von einer zusammengehörigen Gruppe (`Field.*`, `include_*`, Facetten-Schalter) nur einige Mitglieder, behalten die übrigen ihren **serverseitigen Default**. Das Argument kann dann nur erweitern, nie einschränken — ein No-op, der wie Steuerung aussieht.
```python
# ✗ Nicht gesendete Flags bleiben upstream auf true → `fields` wirkt nicht.
for field in fields:
params[f"Field.{field}"] = "true"
# ✓ Jedes Mitglied explizit — erst dadurch kann `fields` verengen.
requested = set(fields)
for field in SEARCH_FIELDS:
params[f"Field.{field}"] = "true" if field in requested else "false"
```
**Nachweis:** Ein Call mit explizitem `false` für ein Default-true-Flag muss weniger liefern. Tut er das nicht, geht die Gruppe unvollständig raus.
## Regel 3 — Leermenge trägt einen nächsten Schritt
Ein leeres Result ist mehrdeutig: Begriff existiert nicht / Query zu eng / Scope eingeschränkt / Syntax passte nicht. Das Modell muss raten.
```python
_EMPTY_HINT = (
"No entry matched. `search_term` is Lucene syntax: try a prefix wildcard "
"(e.g. 'Quellensteuer*') to catch compounds, or the fuzzy operator ('~'). "
"Widen `fields`. Only then conclude the term is absent — and never "
"substitute a guess for the official designation."
)
class SearchResult(BaseModel):
returned: int
hint: str | None = None # gesetzt, wenn returned == 0
entries: list[TermEntry]
```
Der Hinweis muss **konkret** sein. «Versuchen Sie eine andere Suche» ist kein nächster Schritt. Und er gehört ins Tool-Result, nicht ins README — das wird nicht an das Modell weitergereicht. Wie konkret er werden darf, ohne selbst zum Treffer zu werden, steht in Regel 10: vorschlagen ja, abfragen nein.
Konkret ist er allerdings erst zusammen mit der Anfrage, auf die er sich bezieht. Ein `hint`, der auf jeder Leermenge dieselbe Konstante ist, sagt nichts über *diese* Abfrage — er ist eine Formulierung des Nulls, keine Auskunft über sie. Was daneben stehen muss, damit der nächste Schritt überhaupt überprüfbar wird, steht in Regel 11.
**Abgrenzung:** Ein Transport- oder Autorisierungsfehler ist keine Leermenge und darf nie als solche formatiert werden. Ein abgewiesener Request — HTTP 421 auf einen fremden Host-Header, 401, 403, ein Verbindungsabbruch — erreicht die Quelle nie und kommt bei der aufrufenden Schicht trotzdem als «Fehlschlag ohne Daten» an; wer nur auf «keine Datensätze» prüft, reicht ihn als Leermenge durch. Er trägt aber einen anderen nächsten Schritt: **Konfiguration prüfen, nicht Suche verbreitern.** Ein Hinweis, der zur Wildcard rät, während die Abfrage gar nicht angekommen ist, schickt das Modell in die falsche Richtung — und ein Konfigurationsfehler unterläuft genau die Regel, die das Raten verhindern soll. Solche Fälle gehören mit `isError` in den Fehlerkanal, wie die Strukturabweichung in Regel 6.
Eine dritte Tür hat die Spec 2026-07-28 aufgemacht: die MRTR-Rückfrage. Sie sieht erfolgreich aus und ist trotzdem keine Leermenge — Regel 9.
## Regel 4 — Die Tool-Description ist eine Halluzinations-Oberfläche
Die schwerste der neun incident-belegten Regeln, weil sie kontraintuitiv ist: **Eine Formulierung, die eine Leermenge erklärt, erzeugt Konfabulation zuverlässiger als gar keine Formulierung.**
Realer Fall (`termdat-mcp`, 2026-07). Die Description enthielt:
> *«Scope caveat: an empty result usually means the term is out of scope, not that it is wrong.»*
Als Ehrlichkeit gemeint. Faktisch eine vorformulierte Ausrede für das eigene Schweigen. Das Modell hat sie genommen und eine plausible, vollständig erfundene Erklärung geliefert — für einen Begriff, der die ganze Zeit in der Datenbank stand. Es hat nicht halluziniert, weil es schlecht war, sondern weil das Werkzeug ihm eine Erklärung mitgab und keinen nächsten Schritt.
```python
# ✗ lizenziert eine Schlussfolgerung
"""An empty result usually means the term is out of scope, not that it is wrong."""
# ✓ fordert zum Nachfassen auf und schliesst das Raten aus
"""Scope caveat: the source holds administrative nomenclature, so a term may
genuinely be absent. Establish that with a wildcard retry, not from a single
empty result, and never fill the gap with a guessed designation."""
```
Faustregel: Jeder Satz in einer Tool-Description, der mit «usually means», «likely», «wahrscheinlich» oder «bedeutet meist» anfängt und ein leeres Resultat deutet, gehört gestrichen oder in eine Handlungsanweisung umgeschrieben.
Ein `not_found`-Verdikt (QA-/Check-Tools) heisst «nicht in dieser Quelle», nie «falsch». Der Server masst sich sonst eine Aussage an, die die Datenlage nicht trägt.
## Regel 5 — Query-Syntax in die Description, Recall in die Tests
**Syntax.** Spricht ein Such-Argument eine eigene Abfragesprache (Lucene, CQL, SQL-Fragmente, Regex, Glob), gehört sie in die Tool-Description. Zwingend dazu die **Matching-Granularität**: Die meisten Volltextindizes matchen auf ganzen Wörtern, womit deutsche Komposita von ihren Bestandteilen nicht gefunden werden.
```python
"""Search the terminology database for official designations.
`search_term` is **Lucene query syntax**: `*` and `?` wildcards and the `~` fuzzy
operator work. Matching is on whole words, so a compound is not found by its
parts — «Quellensteuer» does not match «Quellensteuerverordnung», but
«Quellensteuer*» does. Reach for a wildcard before concluding a term is absent.
"""
```
Wildcards serverseitig automatisch anhängen ist **kein** Ersatz — es macht Phrasensuche unmöglich und verschiebt das Problem.
**Recall.** Mocks bilden die eigene Annahme ab. Ist die Annahme falsch, ist der Mock falsch, und der Test bestätigt den Fehler, statt ihn zu finden. Scope- und Recall-Bugs sind für Mocks strukturell unsichtbar.
Dieselbe Fehlerform tritt auch ohne Mock auf: In `mcp-transport-hardening` setzte ein Regressionstest die Umgebungsvariable, deren *Fehlen* der eigentliche Prüfgegenstand war — und bestand deshalb auch mit absichtlich eingebautem Fehler. **Ein Test, der die Bedingung herstellt, unter der der Fehler nicht auftreten kann, prüft nichts.**
```python
@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?"
```
Untergrenzen, keine exakten Zahlen — grosszügig unter dem Ist-Wert, Faustregel Hälfte. 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.
**Die Fixture trägt ihre Herkunft — und ihr Datum.** «Mocks bilden die eigene Annahme ab» sagt, was ein Mock *nicht* kann. Der Satz daneben, den dieser Skill lange nicht gesagt hat, ist die positive Pflicht: **Wo eine Fixture herkommt und wann sie aufgezeichnet wurde, steht im Repo.** Sonst ist «aufgezeichnet» nach zwei Jahren von «ausgedacht» nicht mehr zu unterscheiden — die Datei sieht in beiden Fällen gleich aus, und niemand weiss, ob sie den Stand von gestern zeigt oder den von vor drei Schema-Wechseln.
Der Beleg ist derselbe Vorfall wie bei den Regeln 13 und 14, aus der Testsuite her gelesen. `zh-education-mcp` sagt in `test_schema_drift.py` selbst, was passiert ist: *«Die Fixtures pinnten die alte Kopfzeile und die alten Zellwerte, also blieben sie grün, während der Server gegen die echte Quelle nichts mehr fand.»* Kein Test war falsch geschrieben — die Fixture war alt, und **das war ihr nicht anzusehen**.
```python
# ✗ Ein CSV-Literal ohne Vorgeschichte. Stimmt es? Hat es je gestimmt?
_PAGE = "Schulgemeinde,anzahl\nUster,412\n"
# ✓ Dieselbe Fixture, als Beleg statt als Vermutung.
_PAGE = (
# Aufgezeichnet 2026-08-03 von GET {BISTA_API}/{EP_SEK1}, erste zwei
# Datenzeilen, unverändert. Kopfzeile absichtlich in der Schreibweise
# der Quelle von diesem Tag — sie hat vorher schon gewechselt (Regel 13).
"schulgemeinde,anzahl\n"
"Uster,412\n"
"Bonstetten,1 bis 5\n" # unterdrückte Fallzahl, echt so geliefert
)
```
Das Datum ist der Teil, der die Arbeit macht, nicht die Herkunftsangabe: Es macht den Abstand zwischen Aufnahme und heute zu einer lesbaren Zahl statt zu einem Gefühl. Und es ersetzt den Live-Canary oben **nicht** — es sagt, wie alt die Grundlage ist, gegen die offline geprüft wird. Beides zusammen ist die Antwort auf «Mocks fangen das nicht»: der Canary misst die Quelle, die Herkunft datiert den Mock.
Wer die echte Antwort ohnehin abruft, um daraus eine Fixture zu machen, schreibt den Abruf als Skript daneben statt als Handgriff ins Gedächtnis — dann ist die Aufzeichnung wiederholbar und das nächste Datum kostet einen Lauf.
**Vergleiche: exakt, nicht Teilzeichenkette.** Der Satz oben — *ein Test, der die Bedingung herstellt, unter der der Fehler nicht auftreten kann, prüft nichts* — hat eine zweite, unauffälligere Ausprägung: den Vergleich, der nicht scheitern kann. **Auf einem strukturierten Feld gilt exakte Gleichheit, nicht Teilzeichenkette.** Ein Präfix-Assert besteht, bis der Feldwert wächst — und dann besteht er weiter und meint etwas anderes. Er prüft ab da nur noch den Teil, der sich nie ändert.
```python
# ✗ besteht auch, wenn das Feld längst etwas anderes sagt
assert MARKER in result["message"]
assert result["status"].startswith("ready")
# ✓ die Zusicherung ist so breit wie das Feld, das sie zusichert
assert result["message"] == MARKER
assert result["status"] == "ready"
```
Gemessener Fall: Ein Marker war als «Lifespan gestartet» deklariert, das Feld lautete «Lifespan gestartet — geteilter HTTP-Client bereit». Der exakte Vergleich schlug fehl, obwohl der Server korrekt lief — und zeigte damit genau auf die Stelle, die schief stand: die veraltete Deklaration. Ein `in`-Vergleich wäre grün geblieben, hätte die Deklaration konserviert und wäre auch dann noch grün gewesen, wenn der Rumpf des Feldes irgendwann etwas ganz anderes meldet. Der exakte Vergleich kostet einmal einen roten Lauf mit klarer Ursache; der unscharfe kostet die Prüfung.
**Abgrenzung gegen Regel 1.** Dort geht es um «exakt statt Wildcard» beim **Abfragen der Quelle** — eine Verengung des Recalls, die begründet und belegt werden muss. Hier geht es um den Vergleich eines **zurückgekommenen Feldwerts**, und da ist nichts abzuwägen: Die Teilzeichenkette behauptet weniger, als der Test in seinem Namen verspricht. Ebenso wenig widerspricht das dem Präfix-Wildcard weiter oben — der richtet sich gegen einen **Textbestand**, der Vergleich hier gegen einen **Wert**. Volltext will unscharf sein, ein Statusfeld nicht.
---
## Regel 6 — Die Antwort auf Struktur prüfen, nicht durchgreifen
Die Regeln 1–5 betreffen, was der Server **sendet** und was er dem Modell **sagt**. Es gibt eine dritte Stelle mit derselben Fehlerklasse: was er **liest**.
Eine falsch angenommene Verschachtelung liefert exakt dieselbe leere Liste wie ein echter Nullbefund — ohne Exception, ohne Status-Code, ohne Log-Eintrag. Aus Sicht des Modells ist das nicht von «die Quelle kennt das nicht» zu unterscheiden, und damit ist es dieselbe Konfabulations-Einladung wie Regel 3.
Belegfall (2026-07): Eine Abfrage der MCP Registry lieferte konsequent nichts. Die Felder liegen unter `servers[].server.*`, gesucht wurde eine Ebene höher. Der Code war syntaktisch einwandfrei und semantisch blind.
```python
# ✗ greift durch die Struktur hindurch — jede Änderung upstream wird zur Leermenge
rows = payload.get("servers", [])
names = [r.get("name", "") for r in rows] # bleibt leer, wenn name eine Ebene tiefer liegt
# ✓ Struktur bestätigen, bevor gezählt wird
rows = payload.get("servers")
if rows is None:
raise UpstreamSchemaError(
f"Antwort ohne 'servers'. Vorhandene Schlüssel: {sorted(payload)[:10]}"
)
if rows and "name" not in rows[0]:
raise UpstreamSchemaError(
f"Zeile ohne 'name'. Struktur: {json.dumps(rows[0])[:200]}"
)
```
Der Unterschied liegt in der Behandlung des **unerwarteten** Falls: `.get(x, [])` macht aus einer Strukturänderung stillschweigend ein gültiges leeres Ergebnis. Ein Schema-Fehler ist aber ein Fehler und gehört als solcher gemeldet — laut, mit `isError`, wie jeder andere Upstream-Defekt. Eine Leermenge nach Regel 3 ist etwas anderes: dort war die Abfrage korrekt und die Quelle hat nichts.
**Abgrenzung:** Das ist keine vollständige Schema-Validierung. Geprüft wird nur, was der Code tatsächlich anfasst — die Hülle und die gelesenen Felder. Alles darüber hinaus ist Aufwand ohne Ertrag und bricht bei jeder harmlosen Erweiterung upstream.
**Warum Mocks das nicht fangen:** aus demselben Grund wie bei Regel 5. Der Mock bildet die angenommene Struktur ab. Ist die Annahme falsch, ist der Mock falsch. Diese Klasse fällt nur gegen die echte Antwort auf — im Live-Test oder in der Probe (siehe `mcp-data-source-probe`, Abschnitt 1.2c).
---
## Regeln aus der Spec 2026-07-28 — belegt durch den Mechanismus, nicht durch einen Schaden
Die Regeln 1–6 stehen hier, weil etwas kaputtgegangen ist: eine Suche über 1 von 23 Klassifikationen, eine Registry-Abfrage eine Ebene daneben. Die Regeln 10 bis 14 stehen hinter diesem Abschnitt und gehören trotzdem zur ersten Gruppe — sie sind später dazugekommen, nicht anders belegt. Wie weit der Beleg bei 11 und 12 trägt, steht bei ihnen: der eine ist in einem Nachbarwerkzeug gemessen, der andere im Review abgefangen statt ausgeliefert. Die Regeln 13 und 14 sind wieder ausgeliefert gewesen, in einem Server des Portfolios, und ihre Zahlen sind an der laufenden Quelle gemessen. Für die Regeln 7–9 gilt das nicht, und das gehört gesagt, statt sie stillschweigend danebenzustellen. Ihr Beleg ist der **Mechanismus**: Die Spec 2026-07-28 hat drei Felder eingeführt oder abgeschafft, aus denen sich dieselbe stille Unvollständigkeit ableiten lässt wie aus einem vergessenen Filter — nachrechenbar, aber noch nicht nachgemessen. Fällt einer der drei in freier Wildbahn auf, gehört der Vorfall hierher; bis dahin sind es Regeln mit Herleitung statt mit Narbe.
Das Contributing-Kriterium dieses Repos bleibt davon unberührt: Ein **Vorschlag** von aussen braucht weiterhin einen eingetretenen Schaden. Was hier über die tiefere Latte kommt, ist eine Protokolländerung, die alle 42 Server des Portfolios gleichzeitig betrifft — nicht eine plausible Empfehlung.
**Geltungsbereich.** Regel 7 gilt unabhängig von der Spec-Version; instabile Sortierung zerlegt Pagination auch auf 2025-06-18. Dasselbe gilt für die Regeln 10 bis 14 und für alles vor Regel 7. Die Regeln 8 und 9 setzen 2026-07-28 voraus — `ttlMs`/`cacheScope` auf den List-Responses und MRTR (`resultType: "input_required"`) existieren vorher nicht. Wer einen Server der Wave D oder ein eingefrorenes Repo prüft, hakt sie als nicht anwendbar ab, statt sie zu erfüllen.
## Regel 7 — Deterministische Reihenfolge, dokumentiert
`tools/list`, `prompts/list`, `resources/list` und jedes Query-Resultat gehen in einer **stabilen, dokumentierten** Sortierung raus. Die Spec verlangt deterministische Reihenfolge, aber der Grund steht nicht dort: Sie hat `initialize`/`initialized` und `Mcp-Session-Id` abgeschafft. Jede Anfrage steht für sich, Reconnect ist der Normalfall statt der Ausnahme — und jede neu sortierte `tools/list` invalidiert den Prompt-Cache des Clients bei unverändertem Server. Nicht falsch, nur teuer, und niemand sieht warum.
Die scharfe Ausprägung liegt eine Ebene tiefer, und dort ist sie Datentreue: **Bei instabiler Sortierung über Seitengrenzen hinweg verliert Pagination Treffer.** Ein Datensatz, der zwischen dem Abruf von Seite 1 und Seite 2 die Position wechselt, erscheint zweimal oder gar nicht. Das ist stille Unvollständigkeit derselben Klasse wie Regel 1 — nur entstanden beim Blättern statt beim Filtern, und im Gegensatz zum Filter-Fall auch bei korrekt gesendeten Parametern.
Ein Relevanz-Score allein ist keine Ordnung. Er hat Ties, und was bei Ties passiert, entscheidet der Sortieralgorithmus der Quelle — nicht selten der Zufall der Shard-Verteilung.
```python
# ✗ Ties fallen beliebig — zwei identische Abfragen, zwei Reihenfolgen.
rows.sort(key=lambda r: -r["score"])
# ✓ Eindeutiger Schlüssel als letztes Glied: die Ordnung ist total.
rows.sort(key=lambda r: (-r["score"], r["id"]))
```
Dasselbe gilt für die Tool-Registry selbst: Eine Liste im Quelltext ist stabil, ein `set`, ein Verzeichnis-Glob oder ein über mehrere Module eingesammeltes Dict sind es nicht. Und wenn sortiert wird, gehört der Schlüssel in die Tool-Description **und** in den Envelope (`sort_key`) — eine Reihenfolge, die man nicht benennen kann, ist keine, auf die sich jemand stützen darf.
**Nachweis / Test (respx offline).** Der Mock muss permutieren, sonst prüft der Test die eigene Fixture:
```python
@respx.mock
async def test_order_survives_upstream_permutation():
"""Regel 7: Der Server sortiert, nicht die Quelle."""
payloads = itertools.cycle([_PAGE, _permuted(_PAGE)]) # gleiche Menge, andere Ordnung
respx.get(SEARCH_URL).mock(
side_effect=lambda _req: httpx.Response(200, json=next(payloads))
)
first = await client.search("Steuer")
second = await client.search("Steuer")
assert [e["id"] for e in first] == [e["id"] for e in second]
```
Zweimal dieselbe Reihenfolge zu mocken und dann Gleichheit zu behaupten, ist die Fehlerform aus Regel 5: Der Test stellt die Bedingung her, unter der der Fehler nicht auftreten kann.
**`@pytest.mark.live`.** Zwei identische Calls gegen die echte Quelle, plus der Pagination-Schnitt — den fängt kein Offline-Test, weil er von der Seitenaufteilung der Quelle abhängt:
```python
@pytest.mark.live
async def test_pagination_is_disjoint():
page1 = await client.search("Steuer", offset=0, limit=50)
page2 = await client.search("Steuer", offset=50, limit=50)
ids1, ids2 = {e.id for e in page1.entries}, {e.id for e in page2.entries}
assert not ids1 & ids2, f"{len(ids1 & ids2)} Treffer doppelt — Ordnung nicht total"
assert len(ids1 | ids2) == len(page1.entries) + len(page2.entries)
```
## Regel 8 — Ehrliches `ttlMs`
Auf GitHub ansehen