MCP Transport Hardening — kommt der Server hoch, weist er ab wen er abweisen muss, und bleibt er zustandslos?
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: kommt er unter dem konfigurierten Transport überhaupt hoch, weist er ab wen er abweisen muss, und hält er das auch ohne Sitzung durch?
Das ist eine eigene Fehlerklasse, weil sie ebenfalls still ist — nur anders still als bei mcp-data-fidelity. Dort liefert der Server eine plausible Antwort, die inhaltlich falsch ist. Hier liefert er gar keine: grüne Unit-Tests, sauberer Linter, und in Produktion startet der Prozess nicht oder beantwortet jede Anfrage unter einem echten Hostnamen mit HTTP 421. Der Transport-Pfad ist genau der Teil, den eine Testsuite gegen importierte Module nie berührt.
Geltungsbereich: nicht der gefahrene Transport entscheidet, sondern die Stelle im Code. Die Frage ist nicht «fährt dieser Server HTTP?», sondern «steht die Zeile vor oder hinter der Transport-Weiche?». Alles vor der Weiche — Imports, Settings-Zuweisungen, Lifespan, Bereitschaftsmarker — läuft unter jedem Transport, stdio eingeschlossen. Erst hinter der Weiche wird der Transport zur Bedingung: Bind, eingehende Allow-List und Header-Prüfung setzen einen Netz-Transport voraus. Ein stdio-Server ist damit nicht ausgenommen, sondern nur enger im Umfang — und in Regel 14 ist er der Hauptfall.
Diese Abgrenzung stand hier bis 2.2.0 falsch: «nicht nötig für Server, die ausschliesslich über stdio laufen». Widerlegt hat sie zh-education-mcp 0.2.4 — die 1.x-Settings-Zuweisung aus Regel 1(b) stand vor der Transport-Weiche, also war der Server unter stdio genauso tot wie unter HTTP. Wer den Skill nach seiner eigenen Abgrenzung übersprungen hätte, weil der Server stdio fährt, hätte den Fehler behalten. Der Fall steht ausgeschrieben unter «Woher diese Regeln stammen».
Eine Schicht höher fallen die beiden Klassen allerdings zusammen: Wer das 421 nur daran misst, dass keine Datensätze zurückkommen, reicht es als Leermenge weiter — und dann ist es doch wieder eine plausible, inhaltlich falsche Antwort (mcp-data-fidelity Regel 3, FID-003). Verlass dich also nicht darauf, dass ein 421 auffällt; sichtbar wird es nur dort, wo der Transport-Pfad selbst geprüft wird.
Die Leitfrage bei jedem Server mit Netz-Transport: Wenn ich den Bind ändere — folgt die eingehende Allow-List mit, auf jedem Pfad, der eine App baut, und wird ein Test rot, wenn sie es nicht tut? Ist die Antwort nein, greift eine der Regeln 1–4.
Die zweite Leitfrage, seit Spec 2026-07-28: Wenn zwei Aufrufer nichts mehr teilen — keinen Handshake, keine Sitzung, keine Verbindung —, sieht der eine dann noch etwas vom anderen, und wird ein Test rot, wenn er es tut? Ist die Antwort ja, greift eine der Regeln 8–12.
Die dritte Leitfrage, und die einzige, die keinen Transport voraussetzt: Wenn ich das veröffentlichte Artefakt starte und nichts frage — woran sehe ich, dass es bedient? Ist die Antwort «am Ausbleiben eines Fehlers», greift Regel 14.
Wie die vierzehn Regeln geordnet sind
| Block | Regeln | Frage |
|---|---|---|
| Bind und Verdrahtung | 1–4 | Kommt er hoch, und weist er richtig ab? |
| Der Beweis | 5–7, 13 | Woran erkennt man, dass es trägt, und wen deckt der Beweis ab? Gilt auch für 8–12 |
Die Stateless-Welt 2026-07-28 |
8–12 | Hält er ohne Sitzung, und spricht er den neuen Umschlag? |
| Der Bedienzustand | 14 | Sagt er an, dass er hört — oder muss man es annehmen? |
Der Beweisblock steht in der Mitte und nicht am Ende, weil er älter ist als der dritte Block und weil dieses Repo, sein eigenes CHANGELOG und vier Nachbar-Repos «Regel 6» und «Regeln 5–7» namentlich zitieren. Eine Umnummerierung würde die eigene Historie rückwirkend falsch machen — neue Regeln werden deshalb angehängt, nicht eingeschoben. Regel 13 ist der Grund, warum diese Zeile nicht zusammenhängend ist: Sie gehört zum Beweis, kam aber nach 8–12 dazu, und eine ordentliche Nummer war es nicht wert, dieselbe Historie zu brechen. Regel 14 gehört der Frage nach, mit der Block 1 anfängt — «kommt er hoch?» —, und steht aus demselben Grund als Letzte.
Der zweite Teil bleibt der teurere: Transportregeln kann man nachschlagen, die Beweisführung nicht. Genau deshalb bekommt jede der Regeln 8–12 ihren Nachweis in der Form der Regeln 5–7 — Mutation benennen, anwenden, protokollieren.
Welche Regeln stdio betreffen. Nach der Abgrenzung oben: Regel 1 vollständig (die brechenden Zeilen stehen vor der Weiche), die Regeln 5–7 und 13 vollständig (Beweisführung hat keinen Transport), Regel 14 vollständig — und aus dem Stateless-Block alles, was prozesslokalen Zustand betrifft. Nur die Regeln 2–4 und 9 verlangen einen Netz-Transport. Ein stdio-Server, der diesen Skill überspringt, überspringt also die Mehrheit.
Zwei Baselines gleichzeitig. Die Regeln 1–7 gelten unverändert auf beiden Ständen: Bind, Verdrahtung, Host-Allow-List und Beweisführung hängen am Transport, nicht am Lebenszyklus. Die Regeln 8–12 gelten auf 2026-07-28. Und die beiden Stände stehen nicht nacheinander, sondern nebeneinander — im selben Prozess. Am Portfolio nachgemessen und in zurich-opendata-mcps pyproject.toml festgehalten: Der Legacy-initialize-Handshake cappt weiter bei 2025-11-25 (ein Client, der 2026-07-28 verlangt, bekommt 2025-11-25 zurück), während derselbe Server daneben einen per-request-Umschlag bedient, der 2026-07-28 erreicht. Ein Stateless-Fehler ist damit für jeden Client unsichtbar, der noch auf der alten Ära spricht.
Regel 1 — Der SDK-Major-Sprung bricht drei Dinge, nur eines davon mechanisch
(a) Modul und Klasse. mcp.server.fastmcp.FastMCP → mcp.server.mcpserver.MCPServer. Das ist der mechanische Teil: ein Suchen-und-Ersetzen über Server-, Client- und Testmodule, den der Import-Fehler zuverlässig anzeigt.
(b) mcp.settings ist schreibgeschützt. Unter 1.x war das Setzen von Host und Port über die Settings der einzige Weg. Dieselbe Zeile wirft unter 2.x, bevor der Server hört.
# ✗ 1.x — wirft unter 2.x: ValueError: "Settings" object has no field "host"
mcp.settings.host = settings.host
mcp.settings.port = settings.port
mcp.run(transport=settings.transport)
# ✓ 2.x — der Bind geht als Kwargs an run(), und von dort in die Allow-List
mcp.run(transport=settings.transport, host=settings.host, port=settings.port)
Nachgemessen statt angenommen: die Zuweisung wirft ValueError, ein Lesezugriff wirft AttributeError. Ein Server mit der alten Zeile startet gar nicht — und zwar unter jedem Transport, nicht nur unter HTTP.
Das ist der Punkt, an dem die frühere Abgrenzung dieses Skills gescheitert ist. Die Zuweisung steht in der Zeile vor mcp.run(...), also vor der Transport-Weiche: Sie wirft, bevor irgendetwas entschieden hat, ob dieser Prozess stdio oder HTTP fährt. zh-education-mcp 0.2.4, gemessen am installierten Artefakt aus PyPI in einem leeren Venv, kam mit transport="stdio" genau so weit — ValueError: "Settings" object has no field "host", dann still beendet. Die veröffentlichte Fassung war monatelang unbenutzbar, und es fiel niemandem auf, weil nichts das installierte Artefakt startete.
(c) Tool-Annotations werden snake_case gelesen.
# ✗ unter 2.x immer None — der Attributname ist nicht mehr camelCase
if tool.annotations.readOnlyHint:
# ✓ snake_case im Python-Zugriff; camelCase bleibt der Alias auf dem Draht
if tool.annotations.read_only_hint:
Das Entscheidende daran: das Drahtformat ist unverändert. camelCase überlebt als pydantic-Alias, serialisiert kommt weiterhin readOnlyHint heraus. Nur der lesende Zugriff im Python-Code bricht. Deshalb findet das ein Test und kein Client — und deshalb ist camelCase in TypeScript-Servern weiterhin korrekt. Wer nach dieser Regel einen Node-Server «repariert», bricht funktionierenden Code.
Abgrenzung, die genauso wichtig ist: Das eigenständige PyPI-Paket fastmcp ist ein anderes Projekt als mcp.server.fastmcp im offiziellen SDK. from fastmcp import FastMCP bleibt dort gültig und wird von dieser Regel nicht berührt. Zwei Projekte, ein Name — wer sie verwechselt, macht funktionierenden Code kaputt.
Die dritte Achse, seit 2026-07-28: der Cap ist keine Formalie mehr, sondern eine Weiche. fastmcp 3.x pinnt seinerseits mcp<2.0. Ein Server auf dem eigenständigen Paket kann deshalb nicht nebenbei auf die 2er-Linie des offiziellen SDK wandern — und fastmcp 4.0 ist ein eigener Bruch daneben. Wer beide Pakete im selben Environment auflösen lässt, bekommt keinen Fehler, sondern einen Resolver-Entscheid.
Der Versions-Cap wandert mit: mcp[cli]>=1.0.0,<2 wird zu >=2.0.0,<3. Der Bound bleibt, nur am anderen Ende verankert. Ein <2-Cap kauft Zeit, indem er auf der letzten 1.x pinnt — ein Ziel ist er nie. Die untere Grenze ist dabei genauso tragend wie die obere: 2.0.0 hat mcp.server.fastmcp ersatzlos entfernt, eine >=1.x-Range lässt einen Resolver also eine Version wählen, die am Import stirbt. Im Katalog ist das DEP-001.
Und der Bound wirkt erst im Lock. Der Auslöser dieses ganzen Sprungs war ein unbeschränkter Resolve — kein Cap, also nahm die nächste Auflösung die neue Major mit. Die Lehre daraus wird falsch gezogen, wenn sie beim Bound in pyproject.toml stehen bleibt: Die Deklaration sagt, was gelten soll. Installiert wird, was der Lock sagt.
# ✗ Bound gesetzt, Lock unberührt — das Deployment installiert weiter die alte Auflösung
dependencies = ["mcp[cli]>=2.0.0,<3"] # pyproject.toml, allein committet
# ✓ derselbe Bound, und der Lock im selben Commit neu aufgelöst
# uv lock && git add uv.lock
uv sync löst zwar von sich aus neu auf, wenn pyproject.toml sich bewegt hat — aber genau die Pfade, die zählen, tun das nicht: --frozen, ein bereits gebautes Environment, ein Container-Image aus dem committeten Lock. Der Bound steht dann korrekt in der Datei, in der ihn ein Review liest, und ist im Prozess folgenlos. Das ist derselbe Riss wie in Regel 2, nur eine Ebene tiefer: Die Deklaration und der Ort, an dem sie wirken müsste, sind zwei verschiedene Dateien, und niemand prüft die Naht dazwischen.
Beide Richtungen sind nötig, und sie widersprechen sich nicht: Der Lock verdeckt die schlechte Auflösung von morgen (deshalb prüft man frisch), und er verdeckt den guten Bound von heute (deshalb muss man ihn mitführen).
Nachweis: Die 1.x-Settings-Zuweisung zurückbauen — ein Test muss mit ValueError scheitern, nicht das Deployment. Das genügt aber nicht: Ein Test, der die Zuweisung importiert und auslöst, prüft den Checkout. Was Nutzende bekommen, ist das Artefakt. Also zusätzlich das Konsolen-Skript starten, in einem leeren Venv gegen die installierte Distribution, unter stdio und mit geschlossenem stdin, sechs Sekunden lang:
# stdin zu = keine Anfrage möglich = was auf stderr steht, ist Selbstauskunft.
# stdout getrennt halten, dort läuft das Protokoll.
timeout 6 uv run --no-project --with 'zh-education-mcp==0.2.4' \
zh-education-mcp </dev/null >/tmp/out.txt 2>/tmp/err.txt
echo "exit=$?" # 124 = Timeout = der Server lief noch. DAS ist der gute Fall.
cat /tmp/err.txt
exit=124 heisst: er stand nach sechs Sekunden noch. Jeder andere Exit-Code heisst, dass er sich beendet hat — und genau das ist der Befund, der zh-education-mcp 0.2.4 monatelang niemandem auffiel. Die Messung kostet sechs Sekunden und braucht kein HTTP, keinen Port und keinen Client; sie ist die Messung, die diesen Fall gefunden hat. Ob im gleichen Fenster auch eine Marker-Zeile erscheint, ist Regel 14 — hier zählt nur, ob der Prozess steht. Und die negative Kontrolle gehört dazu: einmal mit einem ungültigen Argument starten und sehen, dass auf stderr überhaupt etwas ankommt, sonst misst der Aufbau nichts.
Für (c): beide Schreibweisen serialisieren und die JSON vergleichen; sind sie identisch, ist es ein reines Lesethema und der Client bleibt aussen vor. Für den Cap: in einer leeren Umgebung installieren und den Import ausführen — eine Range, die im Lockfile funktioniert, sagt nichts über die Auflösung von morgen. Und für den Lock nicht die Deklaration lesen, sondern die Installation messen: den Installationspfad fahren, den die CI fährt, dann importlib.metadata.version("mcp") ausgeben. Steht dort die alte Version, wurde der Lock nicht mitgeführt — und der Bound ist Dekoration.
Regel 2 — host ist die Saat der Allow-List, kein kosmetischer Parameter
host defaultet auf 127.0.0.1, und das SDK leitet daraus die eingehende Host-Allow-List ab. Wird er dem App-Builder nicht durchgereicht, schaltet das SDK 127.0.0.1:* scharf und beantwortet jede Anfrage unter einem echten Hostnamen mit HTTP 421 — auf genau dem MCP_HOST=0.0.0.0-Deployment, für das der Server dokumentiert ist.
# ✗ host bleibt auf 127.0.0.1 → 421 unter jedem echten Hostnamen
def create_http_app():
return mcp.create_http_app()
# ✓ die Factory liest denselben Bind wie main()
def create_http_app():
settings = get_settings()
return mcp.create_http_app(host=settings.host, port=settings.port)
Die uvicorn-Falle: uvicorn ruft eine --factory ohne Argumente auf. --host konfiguriert nur den Listener und erreicht die App nie. Die Factory muss den Bind deshalb selbst aus derselben Konfiguration lesen wie main() — sonst hört der Prozess auf 0.0.0.0 und die App glaubt weiterhin, sie sei Loopback.
Die PaaS-Variante derselben Falle. Auf einer Plattform, die den Port beim Start injiziert ($PORT) und den Hostnamen generiert, ist der Bind erst zur Laufzeit bekannt. Ein im Code stehender Port ist dort nicht bloss unschön, er ist falsch: Regel 4 verlangt Portgenauigkeit, und eine portgenaue Liste mit dem falschen Port ist dasselbe 421 wie hier. Der Bind muss also aus derselben Quelle stammen, die die Plattform tatsächlich setzt — und die Allow-List aus dem gelesenen Wert zusammengesetzt werden, nicht aus einem Literal.
Daraus folgt eine Doku-Pflicht: Im README muss stehen, warum MCP_HOST/MCP_PORT neben den uvicorn-Flags nicht redundant sind. Das ist im Code unsichtbar, sieht wie eine Verdopplung aus, und hat genau deshalb ein reales Deployment getroffen.
Nachweis: Den host-Kwarg aus der Factory entfernen — Verdrahtungstest und End-to-End-Regressionstest müssen scheitern. Achtung bei der Konstruktion: Setzt der Regressionstest MCP_ALLOWED_HOSTS, besteht er trotz angewandter Mutation, weil der Kwarg bei expliziter Allow-List irrelevant ist. Tragend wird er erst, wenn das SDK raten muss.
Regel 3 — Jeder Pfad, der eine ASGI-App baut, wird identisch verdrahtet
Ein Server hat selten einen Pfad in die App. In den drei Migrationen unten fanden sich: ein eigener App-Builder, der nur benutzt wurde, wenn Auth oder CORS konfiguriert war; ein vom SDK servierter run()-Pfad; und ein deprecateter SSE-Pfad daneben. Wird nur einer verdrahtet, hängt das Scharfschalten einer Sicherheitskontrolle still davon ab, ob zufällig Auth gesetzt ist.
# ✗ nur ein Pfad bekommt die Kontrolle — welcher läuft, entscheidet die Konfiguration
if settings.auth_token or settings.cors_origins:
uvicorn.run(build_http_app(transport_security=policy), ...)
else:
mcp.run(transport="streamable-http") # ohne Policy
# ✓ eine Quelle, jeder Pfad
policy = build_transport_security(host=settings.host, port=settings.port)
if settings.auth_token or settings.cors_origins:
uvicorn.run(build_http_app(transport_security=policy), ...)
else:
mcp.run(transport="streamable-http", transport_security=policy)
Der Port reist mit. Ein Repo reichte dem Builder nur den Host durch und liess ihn den Port defaulten — die Loopback-Einträge der Allow-List nannten damit einen Port, den niemand bedient. Host ohne Port ist eine halbe Verdrahtung.
Seit 2026-07-28 reist noch etwas mit: die Header-Prüfung aus Regel 9. Sie ist dieselbe Art Kontrolle wie transport_security und macht denselben Fehler mit — ein Pfad ohne sie ist ein Pfad ohne Sicherheitsgrenze. Und der SSE-Pfad ist nicht mehr bloss «deprecated, aber erreichbar», sondern trägt ein Abschaltdatum; siehe Regel 10.
Nachweis: transport_security einzeln aus jedem Pfad entfernen; jede Entfernung muss mindestens einen Test zum Scheitern bringen. Dasselbe für den Port an der Naht zwischen Serve-Funktion und App-Builder: in einem Repo scheiterte dabei kein einziger Test, weil der Port-Test nur den Builder abdeckte — und der wird mit explizitem Port gerufen.
Regel 4 — Die eingehende Host-Allow-List ist eine eigene Kontrolle
Der Angriff ist DNS-Rebinding in der eingehenden Richtung: Eine Seite im Netz des Betreibers löst ihren eigenen Hostnamen auf die Adresse dieses Servers auf und spricht dann aus dem Browser mit ihm. Der Angreifer braucht keinen Netzzugang — nur jemanden im richtigen Netz, der seine Seite öffnet.
Drei naheliegende Kontrollen greifen hier nicht:
| Kontrolle | Warum sie nicht hilft |
|---|---|
| CORS | Aus Browsersicht ist die Anfrage same-origin — es gibt keinen Cross-Origin-Request, den eine Policy prüfen könnte |
| Auth-Token | Sagt, wer fragt. Die angreifende Seite läuft in einem Kontext, der bereits eines hält |
| Egress-Allow-List | Die Gegenrichtung: wohin der Server sprechen darf, nicht unter welchem Namen er angesprochen wird |
Nur die Host-Prüfung beantwortet die Frage.
# ✗ geraten — weist genau das Deployment ab, das die Liste schützen soll
allowed_hosts = ["*"] # oder: der Hostname aus der Doku
# ✓ portgenau, Loopback immer drin, CORS-Origins mit aufgenommen, kein «*»
hosts = [f"127.0.0.1:{port}", f"localhost:{port}", *configured_hosts]
origins = [o for o in configured_origins if o != "*"]
if not configured_hosts and host not in LOOPBACK:
log.warning("transport.host_allowlist_disabled", bind=host) # fail-open, sichtbar
Vier Eigenschaften, jede mit einem Grund: portgenau, weil Einträge wörtlich verglichen werden; Loopback immer drin, wegen Container-Health-Checks; konfigurierte CORS-Origins aufgenommen, sonst weist der Transport genau die Browser-Clients ab, für die CORS geöffnet wurde; kein *, weil Origins literal verglichen werden und ein Stern damit niemanden trifft.
Ohne Konfiguration auf einem Nicht-Loopback-Bind bleibt der Schutz aus — fail-open, aber sichtbar, mit Startwarnung. Auf 0.0.0.0 ist der erreichbare Name im Prozess unbekannt, und eine geratene Liste ist schlechter als keine: sie weist das Deployment ab, das sie schützen soll. Das ist dasselbe 421 wie in Regel 2, nur selbst verschuldet.
Diese Regel überlebt den Wegfall der Sitzung unbeschadet — und wird dadurch wichtiger. Wo es keine Sitzung mehr gibt, an die sich irgendetwas binden liesse (Regel 8), ist die Host-Prüfung die einzige Kontrolle, die vor der Bearbeitung jeder einzelnen Anfrage steht. Sie ersetzt keine Authentifizierung; sie ist nur die einzige, die nicht mit dem Lebenszyklus verschwunden ist.
Nachweis: Richtiger Hostname, falscher Port muss abgewiesen werden — ein evil.example.com allein beweist nichts, weil eine zurückfallende Loopback-Policy ihn ebenfalls abweist. Dazu: ein gültiges Bearer-Token darf einen fremden Host nicht retten.
Regel 5 — Ein Negativtest muss aus deinem Grund scheitern, nicht aus dem eines Defaults
Die Regeln 1–4 sagen, was verdrahtet sein muss. Die Regeln 5–7 sagen, woran man erkennt, dass es verdrahtet ist (und Regel 13, für wen dieser Nachweis dann gilt) — und sie sind der teurere Teil, weil man sie nicht nachschlagen kann. Sie gelten unverändert für die Regeln 8–12: jede der neuen Kontrollen hat einen zweiten Grund, aus dem ihr Negativtest grün werden könnte.
Ein Negativtest behauptet: «Diese Anfrage wird abgewiesen.» Grün heisst aber nur, dass sie abgewiesen wurde — nicht, dass deine Kontrolle sie abgewiesen hat. Überall dort, wo ein Default, ein Fallback oder eine vorgelagerte Schicht dieselbe Anfrage ebenfalls ablehnt, ist der Test mit der Kontrolle und ohne sie grün. Er misst dann die Umgebung, nicht den Code.
Die Prüffrage lautet deshalb nicht «wird abgewiesen?», sondern: Gibt es einen zweiten Grund, aus dem genau diese Anfrage scheitern könnte? Gibt es einen, ist der Test noch nicht scharf. Man braucht den Fall, den nur die eigene Kontrolle richtig entscheidet.
Kanonisches Beispiel, die eingehende Allow-List:
# ✗ ein fremder Hostname — beweist nichts
def test_foreign_host_is_rejected(client):
assert client.get("/mcp", headers={"Host": "evil.example.com:8000"}).status_code == 421
# ✓ richtiger Hostname, FALSCHER Port — das entscheidet nur eine portgenaue Liste
def test_right_host_wrong_port_is_rejected(client):
assert client.get("/mcp", headers={"Host": "mcp.example.ch:9999"}).status_code == 421
evil.example.com wird in jedem Zustand abgewiesen: von der korrekten Liste, von einer auf Loopback zurückgefallenen Default-Policy, und auch von einer Liste, die nur auf den Hostnamen und nicht auf den Port schaut. Drei verschiedene Zustände, ein grüner Test — der Informationsgehalt ist null.
mcp.example.ch:9999 trennt sie: Eine portgenaue Liste weist ab, eine hostnamen-only Liste lässt durch. Und weil dieser Test seinen positiven Zwilling braucht — richtiger Name, richtiger Port wird angenommen —, fällt der Loopback-Rückfall ebenfalls auf: unter ihm scheitert der positive Test. Erst das Paar pinnt den Zustand fest.
Nachweis: Zu jedem Negativtest die zweite Ursache benennen, die dieselbe Ablehnung erzeugen würde — und wenn es eine gibt, einen Fall wählen, den sie nicht abdeckt. Ein Negativtest ohne seinen positiven Zwilling unterscheidet «abgewiesen» nicht von «alles wird abgewiesen».
Regel 6 — Der Mutationstest ist das Abnahmekriterium für jede Sicherheitskontrolle
Nicht «Tests schreiben». Sondern: Mutation benennen, anwenden, prüfen dass sie angekommen ist, protokollieren, welche Tests fallen. Eine Kontrolle, deren Entfernung nichts rot macht, ist unbewiesen — unabhängig davon, wie viele grüne Tests daneben stehen.
Der dritte Schritt ist kein Formalismus: die Mutation muss mutieren. Eine Ersetzung, die ihr Ziel verfehlt, hinterlässt eine unveränderte Datei und eine grüne Suite — dasselbe Bild wie eine echte Lücke im Guard. Null in der Spalte heisst dann nicht «Kontrolle unbewiesen», sondern «nichts passiert», und die beiden sind am Ergebnis nicht zu unterscheiden. Der Fall, in dem das zuschlug: Das Suchmuster stand in einem umbrochenen Absatz, 5 partial lag im Text als 5\npartial — die Ersetzung traf nichts, und der scheinbar überlebende Mutant war ein No-op. Dieselbe Ursache wie in mcp-audit §4.1 («Whitespace normalisieren, bevor auf Text geprüft wird»): Wer auf Zeilenumbrüche prüft, prüft den Zeilenumbruch — nicht den Satz. Dort geht ein Check-Treffer verloren, hier eine Mutation, und beide Ergebnisse sehen aus wie eine Aussage über den Code.
Eine Zeile mehr, und der Fall ist ausgeschlossen:
# ✗ angewandt geglaubt — traf die Ersetzung nichts, sieht das aus wie ein überlebender Mutant
sed -i 's/transport_security=policy//' src/server.py
pytest # 0 rot → Befund? Oder gar keine Mutation?
# ✓ erst beweisen, dass sich etwas geändert hat, dann erst testen
sed -i 's/transport_security=policy//' src/server.py
git diff --exit-code src/server.py && { echo "Mutation war ein No-op"; exit 1; }
pytest
git diff --exit-code endet mit 0, wenn nichts geändert wurde — der &&-Zweig ist also genau der Fehlerfall. Bei einer Mutation von Hand tut derselbe Blick in git diff es auch; ungeprüft bleiben darf es nicht. Und beim Zurücksetzen zwischen zwei Mutationen: aus einer Kopie des Arbeitsbaums, nicht mit git checkout -- — das restauriert HEAD und wirft jede uncommittete Änderung derselben Datei weg.
In den drei PRs unten hat der Handgriff dreimal etwas gefunden, das sonst durchgegangen wäre. Der teuerste Fall zuerst:
# ✗ stellt selbst die Bedingung her, unter der der Fehler nicht auftreten kann
def test_real_hostname_is_accepted(client, monkeypatch):
monkeypatch.setenv("MCP_ALLOWED_HOSTS", "mcp.example.ch:8000")
assert client.get("/mcp", headers={"Host": "mcp.example.ch:8000"}).status_code != 421
# ✓ ohne explizite Liste muss das SDK aus dem Bind ableiten — erst dann trägt der Kwarg
def test_real_hostname_is_accepted(client, monkeypatch):
monkeypatch.delenv("MCP_ALLOWED_HOSTS", raising=False)
assert client.get("/mcp", headers={"Host": "mcp.example.ch:8000"}).status_code != 421
Die drei Funde, jeder mit seinem Merksatz:
- Der Test bestand mit angewandter Mutation. Er setzte die Allow-List-Variable selbst, und bei expliziter Liste ist der
host-Kwarg irrelevant — tragend wird er erst, wenn das SDK raten muss. Ein Test, der die Bedingung herstellt, unter der der Fehler nicht auftreten kann, prüft nichts. - Den Port zwischen zwei Funktionen fallenzulassen liess gar keinen Test scheitern. Die Naht war ungetestet, weil der vorhandene Test die Zielfunktion mit explizitem Port aufrief. Getestet wird die Naht, an der der Wert reist — nicht die Funktion, die ihn schon hat.
- Die Kontrolle zu entfernen liess die Suite hängen statt scheitern. Das ist kein Betriebsunfall, sondern der Regelfall: Ohne Kontrolle wird die verbotene Anfrage zugelassen, und zugelassen heisst bei einem Stream warten. Siehe Regel 7.
Der erste Merksatz gilt über den Transport hinaus: In mcp-data-fidelity (Regel 5) bildet ein Mock die eigene Annahme ab — ist sie falsch, ist der Mock falsch, und der Test bestätigt den Fehler, statt ihn zu finden. Derselbe Fehler, ein Spezialfall davon: Dort ist die hergestellte Bedingung die ganze Antwort.
Nachweis: Die Tabelle selbst — sie entsteht nur, wenn jede Mutation tatsächlich angewandt und die Suite tatsächlich gelaufen ist. Eine Zeile mit null roten Tests ist ein Befund, kein Nebenergebnis: Entweder fehlt der Test, oder die Kontrolle tut nichts — oder die Mutation ist nie angekommen. Die dritte Möglichkeit wird zuerst ausgeschlossen, mit einem Diff, sonst untersucht man einen Befund, den es nicht gibt. Die Tabelle gehört in den PR.
| Mutation | scheiternde Tests |
|---|---|
transport_security aus dem eigenen App-Builder |
4 |
transport_security aus dem SDK-run()-Pfad |
2 |
transport_security aus dem SSE-Pfad |
1 |
| Allow-List nicht portgenau | 3 |
| Port reist nicht bis zum Builder | 1 |
Für die Regeln 8–12 gilt dieselbe Form. Die Mutationen stehen dort jeweils unter «Nachweis» — Handle-Argument entfernen, Header-Vergleich entfernen, Idempotenzschlüssel entfernen, iss-Prüfung entfernen.
Regel 7 — Die Test-Harness ist bei HTTP-Transporten selbst eine Fehlerquelle
Vier Fallen. Die ersten drei haben dasselbe Symptom: Der Befund sieht aus wie ein Infrastrukturproblem und wird als Rauschen abgetan. Die vierte hat gar keines — sie nimmt einem Test still seinen Gegenstand, und ein Test ohne Gegenstand ist grün.
(a) Ein blanker httpx.ASGITransport liefert 500 auf jede Anfrage. Streamable HTTP baut seinen Transport-Manager im App-Lifespan auf, und dieser Transport führt den Lifespan nie aus. Wer den 500er für einen Befund hält, debuggt den falschen Code. Das gilt unabhängig von der Baseline: Was 2026-07-28 entfernt, ist die Protokoll-Sitzung, nicht der Aufbau der App.
# ✗ kein Lifespan → kein Transport-Manager → 500 auf alles
transport = httpx.ASGITransport(app=build_http_app(settings))
client = httpx.AsyncClient(transport=transport, base_url="http://test")
# ✓ TestClient führt den Lifespan aus
with TestClient(build_http_app(settings)) as client:
...
(b) Die Patch-Ebene muss konsistent bleiben. Ein Test patchte mcp.run auf der Instanz. monkeypatch schreibt einen von der Instanz gelesenen Klassen-Wert beim Zurückrollen auf die Instanz — mcp.run bleibt dauerhaft verdeckt, ein späterer Klassen-Patch wird wirkungslos, und echtes uvicorn startet mitten in der Suite. Symptom: Der Test besteht allein und hängt die Suite. Wer im Repo bereits auf der Instanz patcht, patcht überall auf der Instanz.
(c) Jeder Zweig-Test behauptet ausdrücklich, welcher Zweig lief. Sonst scheitert ein falscher Zweig nicht, er hängt.
# ✗ prüft das Ergebnis, nicht den Weg — nimmt der Test den anderen Zweig, startet uvicorn
serve_http(settings)
assert policy_was_applied
# ✓ der Zweig ist Teil der Behauptung
calls: list[dict] = []
monkeypatch.setattr(mcp, "run", lambda **kw: calls.append(kw))
serve_http(settings)
assert len(calls) == 1, "der Builder-Zweig lief — dieser Test behauptet den run()-Zweig"
assert calls[0]["transport_security"] is not None
Warum der SSE-Fall hängt, verbindet (a) und (c): Ohne Allow-List wird ein SSE-GET unter fremdem Host zugelassen und öffnet einen endlosen Event-Stream, auf den der TestClient beim Verlassen wartet. Die fehlende Kontrolle äussert sich also nicht als roter Test, sondern als stehende Suite — und ein Hänger wird routinemässig als Flake abgetan. Regel 11 fügt dieser Klasse eine zweite Ursache hinzu, die nichts mit SSE zu tun hat.
(d) Eine autouse-Fixture, die ein fremdes Modul patcht, entschärft die Mechanik im ganzen Prozess. monkeypatch.setattr(modul.asyncio, "sleep", ...) liest sich, als bliebe der Griff in modul — aber modul.asyncio ist das Modul asyncio, dasselbe Objekt, das jeder andere Import im Prozess hält. Mit autouse=True gilt der Griff für jeden Test der Suite, auch für die, die davon nichts wissen. (b) betrifft die Ebene des Patches, hier geht es um sein Ziel: Wem gehört der Name, auf den er zeigt?
Was real passiert ist: Eine solche Fixture hat eine Parallelitätsprüfung stillgelegt. Der Test liess zwei Coroutinen ineinandergreifen und benutzte dafür asyncio.sleep(0) — den Standardweg, dem Event-Loop das Wort zu geben. Der Ersatz gab es nicht weiter: Eine async-Funktion, die zurückkehrt, ohne etwas abzuwarten, suspendiert nie. Die eine Coroutine lief also durch, bevor die andere begann, und der Test behauptete Nebenläufigkeit über einen Ablauf, in dem es keine gab.
Er wurde rot, und das war Glück — er prüfte die Verschränkung direkt. Hätte er die Nebenläufigkeit nur indirekt geprüft, an einem Zähler, einer Reihenfolge, einem Ergebnis, wäre er grün geblieben und hätte nichts mehr abgesichert. Das ist der Unterschied zu (a)–(c): Dort ist der Schaden sichtbar und wird bloss falsch zugeordnet. Hier bleibt nichts übrig, das man zuordnen könnte.
# ✗ sieht lokal aus, greift aber ins Modul asyncio — jeder Import im Prozess, jeder Test der Suite
@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch):
async def _instant(_delay): return None # kehrt zurück, ohne zu suspendieren
monkeypatch.setattr(server.asyncio, "sleep", _instant)
# ✓ der Produktivcode hält einen Alias, die Fixture patcht den Alias
# src/server.py: _sleep = asyncio.sleep … await _sleep(delay)
@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch):
async def _instant(_delay): await asyncio.sleep(0) # Dauer weg, Übergabe an den Loop bleibt
monkeypatch.setattr(server, "_sleep", _instant)
Zwei Eigenschaften tragen. Der Patch zielt auf einen Namen, den dieses Repo besitzt — dann ist seine Reichweite am Namen ablesbar, statt aus der Importkette erschlossen werden zu müssen. Und der Ersatz nimmt die Dauer weg, nicht die Übergabe an den Event-Loop: await asyncio.sleep(0) ist genauso schnell, lässt aber den Punkt stehen, an dem eine andere Coroutine drankommt. Der Alias muss dafür an jeder Aufrufstelle stehen; bleibt irgendwo ein direktes await asyncio.sleep(...), patcht die Fixture daran vorbei — derselbe No-op wie eine Mutation, die ihr Ziel verfehlt (Regel 6).
Damit schliesst (d) eine Lücke in Regel 6: Der Mutationstest ist dort das Abnahmekriterium, und dies ist genau der Fall, in dem er grün bleibt, ohne etwas zu prüfen. Nicht weil die Mutation nicht ankam — sie kam an —, sondern weil der Test, der sie hätte fangen sollen, seinen Gegenstand vorher an die Fixture verloren hat.
Nachweis: Ein Timeout auf die Suite (pytest --timeout=30) macht aus jedem Hänger einen Fehlschlag mit Stacktrace, und die Stelle ist damit benannt statt gemutmasst. Dazu jeden Zweig-Test einzeln und in der vollen Suite laufen lassen: Die Instanz-Patch-Falle aus (b) zeigt sich ausschliesslich im zweiten Fall. Für (d) ist die Fixture selbst der Gegenstand von Regel 6 — den Ersatz gegen einen ohne await tauschen: Verliert kein Test seine Aussage, hat auch keiner die Nebenläufigkeit geprüft, die er behauptet. Und ein setattr, dessen Ziel ein importiertes Fremdmodul ist, ist ein Befund beim Hinsehen:
grep -rnE 'setattr\(\s*([A-Za-z_][A-Za-z0-9_.]*\.)?(asyncio|time|socket|os|random|subprocess)\s*,' tests/
Regel 8 — Ohne Sitzung teilt sich Zustand still, statt zu fehlen
Mit 2026-07-28 fällt der Lebenszyklus weg, um den herum bisher gebaut wurde: initialize und notifications/initialized sind entfernt, der Mcp-Session-Id-Header ebenfalls. Jede Anfrage trägt Protokollversion, clientInfo und Capabilities selbst, in _meta unter io.modelcontextprotocol/*. Zustand über Aufrufe hinweg läuft nur noch über explizite, server-geprägte Handles als gewöhnliche Tool-Argumente.
Der gefährliche Fall ist nicht der Server, der abstürzt — der fällt beim ersten Aufruf auf. Der gefährliche Fall ist der Server, der weiterläuft und still degradiert: Er hält seinen Zustand in einer prozesslokalen Struktur, die per Konvention über die Sitzung adressiert war. Ohne Sitzung landet jeder Request im selben Eimer. Bei einem Nutzer merkt das niemand; bei zweien ist es ein Datenleck zwischen Aufrufern, das keinen Fehler wirft.
# ✗ prozesslokaler Zustand, adressiert über etwas, das es nicht mehr gibt
_CURSORS: dict[str, int] = {} # war: pro Sitzung — jetzt: pro Prozess
@mcp.tool()
async def next_page() -> str:
offset = _CURSORS.get("current", 0) # jeder Aufrufer liest denselben Eintrag
_CURSORS["current"] = offset + 50
return await fetch(offset)
# ✓ der Zustand steht im Argument, ist server-geprägt und läuft ab
@mcp.tool()
async def next_page(page_handle: str | None = None) -> str:
offset = _decode_handle(page_handle) # signiert, opak, mit Ablauf
return await fetch(offset, next_handle=_mint_handle(offset + 50))
Drei Worte der Spec tragen die Last. Explicit: Der Handle steht im Schema des Tools, ein Modell sieht ihn. Server-minted: Der Server prägt ihn, der Client denkt ihn sich nicht aus — ein Handle namens cursor=42 ist eine ratbare Referenz auf fremden Zustand und verschiebt die Angriffsfläche bloss in die Tool-Signatur, wo kein Auth-Layer mehr hinschaut. As ordinary tool arguments: Er reist im Argument, nicht in einem Header und nicht in einer Tabelle neben dem Request.
Der zweite Fehler ist leiser: ein Handle ohne Ablauf ist Zustand ohne Ende. Bei der Sitzung erledigte das Aufräumen der Verbindungsabbruch. Ohne Sitzung gibt es kein Ereignis mehr, an dem irgendetwas aufräumt — ein Dict voller Handles ist ein Leck, das keine Testsuite bemerkt, weil es sich in Tagen zeigt und nicht in Sekunden.
server/discover ist serverseitig Pflicht, nicht Kür. Die Spec ist hier asymmetrisch: Server MÜSSEN den RPC implementieren, um Protokollversionen, Capabilities und Identität bekanntzugeben; Clients DÜRFEN ihn vor jeder anderen Anfrage rufen. Genau diese Asymmetrie ist die Falle — weil kein Client ihn rufen muss, funktioniert ein Server ohne ihn im Alltag scheinbar tadellos, und auf stdio, wo er als Rückwärtskompatibilitäts-Sonde dient, kann ein Client danach nicht unterscheiden, ob er einen alten Server vor sich hat oder einen neuen mit einer Lücke. Ein fehlendes server/discover ist kein fehlendes Feature, sondern eine falsche Auskunft über die eigene Protokollversion.
Nachweis: Zwei Aufrufer, kein gemeinsamer Kontext. Zwei unabhängige Requests absetzen und behaupten, dass der zweite nichts vom ersten sieht. Der Mutationstest dazu: das Handle-Argument entfernen und auf den prozesslokalen Eimer zurückfallen — ein Test mit einem Aufrufer bleibt dabei grün, ein Test mit zweien muss rot werden. Das ist Regel 5 auf diese Regel angewandt: Der Einzelaufruf-Test hat einen zweiten Grund, grün zu sein, nämlich dass er die Bedingung gar nicht herstellt. Für server/discover: den RPC tatsächlich aufrufen — eine grüne Tool-Suite beweist nichts, weil sie ihn nie ruft. Für den Ablauf: einen Handle mit abgelaufenem Zeitstempel einreichen und die Ablehnung behaupten.
Regel 9 — Die Adresse steht neu aussen auf dem Umschlag, und beide Seiten müssen dasselbe lesen
Streamable HTTP verlangt auf jedem POST zwei Header: Mcp-Method mit der JSON-RPC-Methode und Mcp-Name mit dem Namen des adressierten Tools, Prompts oder der Ressource. Weichen Header und Body voneinander ab, ist die Antwort HeaderMismatchError — JSON-RPC-Code -32020.
Der Gewinn ist offensichtlich: Bisher musste jede Instanz zwischen Client und Server den Body parsen, um zu wissen, was durchläuft — ein Gateway, das nur ein bestimmtes Werkzeug durchlassen soll, ein Rate-Limiter mit Grenzen je Tool, ein Logpfad, der Methoden zählt. Jetzt steht das im Klartext an der Anfrage.
Und genau daraus entsteht der Angriff. Wenn eine Zwischenschicht am Header entscheidet und der Server am Body, entscheiden zwei Instanzen über zwei verschiedene Anfragen. Ein Client schickt Mcp-Name: search_datasets im Header und delete_record im Body: Das Gateway erlaubt, der Server führt aus. Die Header sind deshalb keine Metadaten — die Prüfung ihrer Übereinstimmung ist eine Sicherheitsgrenze, und sie muss serverseitig stattfinden, weil nur dort beide Seiten vorliegen.
# ✗ Header als Metadatum behandelt — geloggt, geroutet, nie gegen den Body gehalten
log.info("mcp.request", method=request.headers.get("Mcp-Method"))
return await dispatch(body["method"], body["params"])
# ✓ Übereinstimmung ist eine Vorbedingung, und ein fehlender Header ist keine Ausnahme
declared_method = request.headers.get("Mcp-Method")
declared_name = request.headers.get("Mcp-Name")
if declared_method is None or declared_name is None:
raise HeaderMismatchError(-32020, "Mcp-Method/Mcp-Name required")
if (declared_method, declared_name) != (body["method"], _addressed_name(body)):
raise HeaderMismatchError(-32020, "header does not match body")
return await dispatch(body["method"], body["params"])
Der fehlende Header ist der Teil, den man am ehesten weglässt, und der die Kontrolle aushebelt: Wer nur vergleicht, wenn beide Header da sind, hat eine Prüfung gebaut, die man durch Auslassen umgeht. Dieselbe Form wie die «present»-Klausel in Regel 12.
Daraus folgt die zweite Doku-Pflicht dieses Skills — Schwester der MCP_HOST-Pflicht aus Regel 2. Im README gehört, auf welche Header-Werte das Deployment routet und limitiert: Ein Gateway, das auf Mcp-Name allow-listet, ist Teil der Sicherheitsarchitektur des Servers, steht aber nirgends in seinem Code. Wer das nicht aufschreibt, hat eine Kontrolle, die niemand pflegt, weil niemand von ihr weiss.
Nachweis: Drei Fälle, und der dritte ist der, den man vergisst. (1) Header und Body stimmen überein → durchgelassen, der positive Zwilling. (2) Mcp-Name nennt ein anderes Tool als der Body → -32020. (3) Header fehlen ganz → ebenfalls -32020, nicht durchgelassen. Der Mutationstest: den Ver
…(truncated)