MCP Audit — Standardisiertes Audit-Vorgehen
Dieser Skill kodiert ein reproduzierbares Audit-Verfahren für MCP-Server gegen den im Anhang dokumentierten Best-Practice-Katalog (PDF-Quelle plus Schweiz-, Datentreue- und Identitäts-Layer sowie den Spec-Migrations-Layer, 120 Checks in zwölf Kategorien auf zwei Spec-Baselines). Ziel: bei 30+ Servern im Portfolio dieselbe Methodik anwenden, ohne dass der menschliche Auditor (oder Claude) bei jedem Server das PDF neu interpretiert.
Das Mantra in drei Zeilen:
- Profil zuerst, Checks danach — applicability filtert alles
- Evidenz schlägt Vermutung — jeder Befund braucht Code-Stelle oder konkretes Verhalten
- Severity ohne Mitleid —
criticalblockiert Produktion, Punkt
Jeder Audit folgt sechs Schritten in dieser Reihenfolge. Abweichungen sind möglich, müssen aber im Audit-Report dokumentiert werden.
Schritt 0: Umgebung vorbereiten
Bevor irgendein Schritt beginnt, müssen Cross-Platform-Voraussetzungen erfüllt sein. Diese Sektion existiert, weil bei realen Audit-Läufen auf Windows wiederholt UTF-8- und Pfad-Probleme aufgetreten sind.
0.1 UTF-8 für Python
Auf Windows defaultet Python stdout/stderr zu cp1252 und crasht bei Emojis oder Umlauten. Vor jedem Python-Snippet:
# Bash/PowerShell — vor Python-Aufrufen exportieren:
export PYTHONUTF8=1 # Bash
$env:PYTHONUTF8 = "1" # PowerShell
Oder im Python-Code direkt:
from tools.path_utils import force_utf8_stdio
force_utf8_stdio() # idempotent, sicher mehrfach aufzurufen
0.2 Pfad-Konventionen
| Tool | Erwartetes Pfad-Format |
|---|---|
Bash (cat, grep, ls) |
POSIX (/c/Users/foo) |
| Read / Edit / Write | OS-native (C:\Users\foo auf Windows) |
Python pathlib.Path |
beides, aber konsistent halten |
Helper im Repo:
# Bash — sourceable
source tools/paths.sh
native_path=$(to_native_path "/c/Users/foo") # → C:\Users\foo auf Windows
posix_path=$(to_posix_path "C:\\Users\\foo") # → /c/Users/foo
# Python
from tools.path_utils import to_native_path, to_posix_path, is_windows
read_path = to_native_path(skill_base) # für Read-Tool-Aufrufe
0.3 Inline-Heredocs sind verboten
Inline-python3 << 'PYEOF'-Blöcke crashen auf Windows Git Bash regelmässig durch Quoting (Issue #11, real beobachtet im srgssr-Audit). Für jede nicht-triviale Operation existiert ein dediziertes Helper-Script unter tools/. Verwende diese, schreibe niemals Inline-Python während eines Audits:
| Aufgabe | Helper-Script |
|---|---|
| Run-ID + Output-Dir + audit-meta.json initialisieren | python tools/audit_init.py init <server> --base-dir audits/ --catalog-dir checks/ --target-repo <repo> |
| Ziel-Revision am Ende erneut prüfen | python tools/audit_init.py verify-target <audit_dir> |
| Profil-Validierung (Placeholder/Schema-Gate) | python tools/validate_profile.py path/to/profile.yaml |
Catalog parsen (Frontmatter aller *.md) |
python tools/parse_catalog.py --format json |
| Catalog vs. Manifest validieren | python tools/parse_catalog.py --format manifest-check |
| Portfolio-Inventar gegen die Checkouts prüfen | python tools/verify_inventory.py --portfolio portfolio.yaml |
applies_when evaluieren |
python tools/eval_applicability.py catalog profile.yaml |
| Zwei Applicability-Auswertungen vergleichen | python tools/eval_applicability.py diff <alt> <neu> --labels alt,neu |
| Verification-Results aggregieren | python tools/aggregate_results.py aggregate results.json --checks-dir checks/ --out summary.json |
| Gegen den Vorlauf vergleichen (Katalog-Epoche) | python tools/aggregate_results.py aggregate ... --previous audits/<vorlauf>/ |
| Findings-Set vs. Disk validieren (inkl. Leer-Prüfung) | python tools/aggregate_results.py validate <audit_dir> |
| Unveränderte Findings aus früheren Läufen übernehmen | python tools/carry_forward.py <audit_dir> --from <vorheriger_lauf> |
| Audit-Report generieren | python tools/build_report.py <audit_dir> |
Handgeschriebene Zahlen gegen summary.json prüfen |
python tools/check_reported_numbers.py <summary.json> <datei>... |
| Task-Agent-Output verifizieren | python tools/verify_raw_outputs.py raw/ --expected-ids ID1,ID2 |
| Task-Agent-Run loggen | python tools/agent_run_log.py log --meta-path audit-meta.json ... |
| Release-Vorschlag (Schritt 7) | python tools/propose_release.py propose <audit_dir> <target_repo> |
| Release anwenden (CHANGELOG + Tag) | python tools/propose_release.py apply <audit_dir> <target_repo> --bump <patch|minor|major> |
| Tracker-Update (CSV/Notion) | python tools/tracker_sync.py update <server> --from-summary <summary.json> |
| Pfad zu Native/POSIX konvertieren | python tools/path_utils.py to-native <path> |
Wenn ein Audit ein Snippet braucht das hier nicht abgedeckt ist: erst Issue im Skill-Repo öffnen, dann Helper-Script bauen, dann verwenden. Inline-Heredoc ist der Anti-Pattern, der nicht-reproduzierbare Audits erzeugt.
Dieselbe Regel gilt für CI-Guards, und dort wiegt sie schwerer
Ein Workflow-Schritt der Form run: | mit python - <<'PY' … PY ist ausführbarer Code ohne Testbarkeit: Er lässt sich nicht importieren, nicht mit Grenzfällen aufrufen und vor allem nicht mutationstesten. Ein Guard, den man nicht kaputtmachen kann, ist kein nachgewiesener Guard — und anders als ein Audit-Snippet läuft er unbeaufsichtigt weiter, oft jahrelang.
Der Beleg stammt aus diesem Repo. tools/render_description_issue.py war zuerst als Heredoc im Workflow geschrieben. Als Skript mit Tests fiel sofort auf, dass die naheliegende Zwei-Zustands-Logik — Body geschrieben heisst «Issue öffnen», kein Body heisst «Issue schliessen» — ein offenes Issue geschlossen hätte, obwohl der Vergleich nie stattgefunden hatte. Vier verschiedene Eingaben führten dorthin. Im Heredoc wäre der Fehler ausgeliefert worden, weil es dort keine Stelle gibt, an der man ihn hätte suchen können.
Die Grenze verläuft nicht bei der Zeilenzahl, sondern bei der Frage, ob der Schritt urteilt:
| Im YAML zulässig | Gehört in ein Skript mit Tests |
|---|---|
echo, mkdir, Datei kopieren, ein Werkzeug aufrufen |
Zustände unterscheiden, Schwellen prüfen, Ergebnisse klassifizieren |
| Ein Kommando, dessen Exitcode das Urteil ist | Ein Kommando, dessen Ausgabe erst interpretiert wird |
python - <<'PY' in einem Workflow ist damit fast immer das Signal, dass ein Skript fehlt. Der Katalog führt das als OPS-008.
0.4 Run-ID + Audit-Meta initialisieren (verbindlich seit Issue #15)
Niemals date +%Y-%m-%d für den Output-Verzeichnisnamen — das hat im ersten Audit zu Drift zwischen UTC-Container und lokalem Kalendertag geführt (2026-04-30 statt 2026-05-01). Stattdessen:
# Erzeugt Output-Dir mit ISO-Timestamp + Timezone-Offset, schreibt
# initiale audit-meta.json mit Skill-Version, Catalog-Hash und Ziel-SHA.
python "$SKILL_BASE/tools/audit_init.py" init "$SERVER_NAME" \
--base-dir "$TARGET/audits/" \
--skill-version "3.0.0" \
--catalog-dir "$SKILL_BASE/checks/" \
--target-repo "$TARGET"
# Output (JSON): { "run_id": "2026-05-02T091245-Z-srgssr-mcp", "output_dir": "...", "meta_path": "..." }
Run-ID-Format: YYYY-MM-DDTHHMMSS-<offset>-<server>, wobei <offset> Z (UTC) oder +HHMM/-HHMM ist. Bei Sekunden-genauer Kollision (Re-Audit unmittelbar danach) wird das Verzeichnis mit -2, -3, ... gesuffixt; die Run-ID selbst bleibt identisch.
Die initiale audit-meta.json enthält:
server_name,run_id,started_at(ISO mit TZ-Suffix),timezone_offsetskill_version,catalog_hash(SHA-256 allerchecks/*.md+MANIFEST.txt),catalog_dirtarget_repo,target_sha,target_dirty,target_branch— die Revision des auditierten Repos (nur mit--target-repo)- Leeres
agent_runs-Array (wird in Step 4 vonagent_run_log.pybefüllt)
Der catalog_hash ist der Reproduzierbarkeits-Anker: jeder Re-Audit kann verifizieren, dass derselbe Katalog-Stand verwendet wurde.
0.5 Platzhalter in spitzen Klammern überleben den Weg nach draussen nicht
Diese Datei, templates/finding.md und die Finding-Dokumente schreiben Platzhalter als <ID>, <slug>, <CHECK-ID>. Im Repository ist das richtig und bleibt so — Git transportiert Text, nicht HTML.
Wird derselbe Text von einem Agenten über die GitHub-Werkzeugschicht abgeschickt, ist er weg. Ein Pull-Request-Body mit dem Satz «suchte findings/<ID>.md, während der Lauf <ID>-<slug>.md benannt hatte» kommt als «suchte findings/.md, während der Lauf -.md benannt hatte» an: <ID> und <slug> werden als unbekannte Tags verworfen. Backticks schützen nicht — die Umwandlung läuft vor dem Markdown-Parser.
Gemessen, nicht angenommen: In PR #79 zweimal reproduziert — beim Anlegen und beim Korrekturversuch mit denselben Klammern. Am selben Ort wurde > am Zeilenanfang zu > escaped, das Blockquote also gleich mit zerstört.
Wo genau das passiert, ist nicht belegt, und die Vermutung gehört nicht in die Regel. Der gespeicherte Body enthält ' für Apostrophe; GitHub escaped die in Issue- und PR-Bodies nicht. Der Verlust entsteht also mit hoher Wahrscheinlichkeit in der Werkzeug- oder Proxy-Schicht des Agenten und nicht bei GitHub — ein Mensch, der denselben Text im Web-UI einfügt, dürfte nichts verlieren. Wer das braucht, misst es: Text mit <ID> von Hand einfügen, speichern, zurücklesen. Bis dahin gilt die Regel für den Agentenpfad, für den sie gemessen ist.
Das doppelte Anführungszeichen gehört in dieselbe Klasse — gemessen in PR #101. Der Body dieses Pull Requests enthielt keine spitzen Klammern, nur die zitierten grep-Aufrufe samt ihrer Anführungszeichen. Gespeichert stand dort " statt des Zeichens, und weil das Zitat in Backticks steht, wird die Entität wörtlich angezeigt statt aufgelöst: aus einem Befehl, den man kopieren kann, wird einer, der nicht läuft. Kein Verlust wie bei <ID>, aber derselbe stille Mechanismus, und dieselbe Ursachenlage — es passt zu dem ', das oben schon beschrieben ist. Der Rückvergleich hat es gefunden, nicht die Vermutung: Erwartet wurden die Klammern, angeschlagen hat ein Zeichen, an das niemand gedacht hatte. Genau dafür ist die Gegenprobe da; eine Liste verdächtiger Zeichen wäre immer eine Zeichenklasse zu kurz.
Bösartig ist der Fall, weil das Ergebnis plausibel bleibt. findings/.md sieht nicht nach einem Fehler aus, sondern nach einem Dateinamen. Ein Satz über den Unterschied zweier Schreibweisen wurde so zu einem Satz, der beide gleich nennt — ohne Fehlermeldung, ohne rotes Gate. Dieselbe Mechanik trifft das Finding-Template aus §5.1: dessen Überschrift ## Finding: <CHECK-ID> — <CHECK-TITLE> wird beim Einfügen in ein Issue zu ## Finding: — .
Regel: Text, den ein Agent in einen PR-Body, ein Issue, einen Review-Kommentar oder den Tracker schreibt, schreibt Platzhalter als {ID}, {slug}, {CHECK-ID} und sagt einmal dazu, dass die geschweifte Form für die spitze steht. Befehlszeilen im selben Text kommen ohne Anführungszeichen aus — ein Suchmuster ohne Leerzeichen braucht keine, und wo eines nötig wäre, gehört der Befehl in einen Code-Block statt in eine Tabellenzelle. Danach den gespeicherten Body zurücklesen und vergleichen — die Umwandlung ist still, also ist die Gegenprobe der einzige Beleg. Derselbe Reflex wie beim Gegen-Test einer Mutation: ein Schritt ohne Rückmessung ist kein belegter Schritt.
0.6 Die Ziel-Revision festhalten (verbindlich)
catalog_hash hält fest, womit gemessen wurde. target_sha hält fest, woran — und erst beide zusammen machen einen Lauf reproduzierbar. Ohne die zweite Zahl teilt ein Commit, der mitten im Audit landet, den Report unbemerkt: Die Checks vor ihm beschreiben einen Baum, die danach einen anderen, und der Report präsentiert die Mischung als ein Urteil. Ein Audit, dessen Ziel sich während des Laufs bewegt, ist kein Audit — es ist eine Aussage über keine bestimmte Revision.
target_dirty steht daneben, weil ein sauberer SHA über einem verschmutzten Working-Tree einen Baum beschreibt, den es nur auf dieser Maschine gibt. Uncommittete Arbeit zu auditieren ist legitim; der Report darf nur nicht behaupten, er habe den genannten Commit geprüft.
Am Ende des Laufs — vor Schritt 6 — wird erneut geprüft:
python "$SKILL_BASE/tools/audit_init.py" verify-target "$OUTPUT_DIR"
# Exit 0: unverändert. Exit 1: HEAD bewegt, Working-Tree nachträglich verschmutzt,
# oder gar keine Ziel-Revision aufgezeichnet.
Dieselbe Prüfung läuft automatisch im Pflicht-Gate aus Schritt 5.0 (aggregate_results.py validate). Dort gilt sie abgestuft, und die Abstufung ist Absicht:
| Lage | Gate | Warum |
|---|---|---|
| SHA aufgezeichnet, bewegt | hard fail | Die Findings beschreiben zwei Bäume |
| SHA aufgezeichnet, unverändert | pass | |
| Kein SHA aufgezeichnet | Warnung, Eintrag target.status: unrecorded |
Läufe von vor --target-repo haben keinen; ein hard fail hier würde nur beibringen, --skip-target-check reflexhaft zu setzen — und damit auch den Fall abschalten, auf den es ankommt |
| Repo nicht mehr auffindbar | Warnung mit genanntem Pfad | Nur der Auditor weiss, wohin es verschoben wurde |
Jede dieser Lagen landet in target.status im Gate-Report — eine Warnung, die nur nach stderr geht, ist beim Lesen des Run-Verzeichnisses verschwunden.
Schritt 1: Profil laden
Ziel: Den Server-Kontext aus dem Notion MCP Audit Tracker (DB-ID a2736a65-677d-4cf3-9f94-e874f74a1975) holen, damit nachfolgende Schritte die richtigen Checks filtern können.
1.1 Pflichtfelder aus dem Tracker
Bevor ein Audit beginnt, müssen diese Felder in der Audit-Tracker-Karte gesetzt sein:
| Feld | Werte | Verwendung im Audit |
|---|---|---|
Transport |
stdio-only / dual / HTTP/SSE |
filtert Netzwerk-Checks |
SDK-Sprache |
Python / TypeScript |
filtert die SDK-Checks und IDENT-005 |
Auth-Modell |
none / API-Key / OAuth-Proxy |
filtert OAuth-Checks |
Datenklasse |
Public Open Data / Verwaltungsdaten / PII |
filtert PII-Checks und CH-Compliance |
Schreibzugriff |
read-only / write-capable |
filtert HITL-Checks |
Deployment |
local-stdio / Railway / Render / andere |
filtert Cloud-Checks |
MCP-Spec-Version |
2025-11-25 / 2026-07-28 |
wählt die Baseline — welche Hälfte des Katalogs geprüft wird |
Repo URL |
GitHub-URL | für Code-Review-Schritte |
Wenn ein Pflichtfeld fehlt, wird der Audit gestoppt und der User aufgefordert, das Feld zu füllen. Audits mit unvollständigem Profil sind wertlos — applicability wird falsch berechnet, die Findings werden unverlässlich.
MCP-Spec-Version ist seit v2.0.0 Pflicht und hat bewusst keinen Default. Während der Migrationswellen A–D stehen beide Protokollstände gleichzeitig im Portfolio. Ein Default würde die Frage für jedes Profil beantworten, das sie vergessen hat — und zwar still: Fünf Checks messen einen Gegenstand, den 2026-07-28 entfernt hat, vierzehn messen einen, den es davor nicht gab. Die falsche Antwort tauscht die geprüfte Hälfte des Katalogs aus, ohne dass irgendwo etwas rot wird. Das ist der transport: HTTP-Vorfall aus §1.3, eine Achse weiter und mit grösserer Reichweite.
1.2 Profil-Notation für interne Verwendung
Während des Audits arbeitet Claude mit einem konsolidierten Profil-Objekt:
profile:
name: zurich-opendata-mcp
repo: https://github.com/malkreide/zurich-opendata-mcp
transport: dual
sdk_language: Python # filtert SDK-001…006 und IDENT-005
mcp_spec_version: "2025-11-25" # 2025-11-25 | 2026-07-28 — wählt die Baseline
auth_model: none
data_class: Public Open Data
write_capable: false # bool — kanonisches Feld (siehe Migration unten)
deployment: [local-stdio, Railway]
is_cloud_deployed: true # derived: true iff deployment hat irgendwas ausser local-stdio (siehe Issue #16)
prio: 14 # aus Tracker-Formel
Dieses Profil ist die einzige Wahrheit für applies_when-Auswertung in Schritt 3.
Schema-Hinweis (seit Issue #13): Das kanonische Profil-Feld ist write_capable: bool. Das frühere write_access: "read-only" | "write-capable" (Enum-String) wurde abgelöst. Der Notion-Tracker behält das Schreibzugriff-Select-Feld zur besseren Lesbarkeit; audit-notion-sync.py mappt es beim pull automatisch auf write_capable: bool. Profile mit Legacy-Feld write_access führen beim Evaluator zu UnknownFieldError — das ist beabsichtigt (siehe docs/applies-when-dsl.md "loud failure"-Prinzip).
1.3 Validation-Gate (verbindlich seit Issue #14)
Bevor Step 2 startet, MUSS das Profil gegen Placeholder und Schema-Lücken geprüft werden. Im ersten realen Audit hatte der User versehentlich das Template mit ...-Werten reingepastet — Claude hat das zwar erkannt, aber nur dank Defensive-Behavior. Jetzt verbindlich:
# Profil als YAML/JSON file-validieren (oder als Inline-Block)
python "$SKILL_BASE/tools/validate_profile.py" path/to/profile.yaml
# exit 0 = clean, exit 1 = Placeholder oder Schema-Fehler
Der Validator catcht:
- Placeholder-Werte:
...,<placeholder>,<TODO>,TODO, leere Strings,null/None, leere Listen, Listen mit Placeholder-Members - Fehlende Pflichtfelder: alle 16 Profil-Top-Level-Felder plus
data_source.is_swiss_open_data - Type-Mismatches:
bool-Feld mit String-Wert,list-Feld mit String-Wert, etc. - Unbekannte Werte in geschlossenen Vokabularen:
transportausserhalb vonstdio-only/dual/HTTP/SSE. Ein Wert, gegen den keineapplies_when-Klausel je vergleicht, lässt Checks still wegfallen — der Evaluator wirft dafür keinen Fehler, weil ein unbekannter Wert ein ganz normaler String ist (anders als ein unbekanntes Feld, dasUnknownFieldErrorauslöst).
Bei Exit-1 wird Step 2 nicht gestartet. Der Output zeigt strukturiert, welche Felder betroffen sind (missing / placeholder / type_mismatch / enum_mismatch). Nutze das, um den User zur Korrektur aufzufordern.
Warum enum_mismatch ein eigenes Gate braucht: Ein Profil mit transport: HTTP — eine Schreibweise, die dieses Repo selbst empfohlen hat — verlor SCALE-002, SCALE-003, SCALE-007 und SDK-004, zwei davon high, während jede transport != "stdio-only"-Klausel weiterhin griff. Das Profil war halb erkannt, und der Report meldete einen sauberen Lauf über einen kleineren Katalog als behauptet. Genau der Fall aus OPS-005: Was nicht gelaufen ist, sieht aus wie bestanden.
Schritt 2: Check-Katalog laden
Ziel: Den vollständigen Katalog (checks/*.md) parsen und nach category + severity indizieren.
2.1 Zwölf Kategorien
| Kategorie | Quelle | Typische Anzahl Checks | Status |
|---|---|---|---|
ARCH |
PDF Sec 2 + Anhang A + Custom + Spec 2026-07-28 — Tool-Design, Annotations, Idempotency, Retry-Politik, Repo-Struktur, Versionsquelle, Spec-Versionierung, Stateless-Konformität, Handles, Extensions | 10–22 | 22 / 22 ✅ |
SDK |
PDF Sec 3 — FastMCP, TypeScript, Zod, Lifecycle | 5–7 | 6 / 6 ✅ |
SEC |
PDF Sec 4 + Anhang B + Spec 2026-07-28 — Security (grösste Kategorie) | 20–28 | 28 / 28 ✅ |
SCALE |
PDF Sec 5 + Spec 2026-07-28 — Transport, LB, Container, Gateway, Pflichtheader, Abkündigungsfristen | 5–11 | 10 / 10 ✅ |
OBS |
PDF Sec 6 + Anhang B10 + Custom — Logging, Errors, SIEM, Tracing, Bereitschaftsmarker | 5–8 | 8 / 8 ✅ |
HITL |
PDF Sec 7 + Spec 2026-07-28 — Sampling, Human-in-the-Loop, MRTR | 4–6 | 6 / 6 ✅ |
CH |
Custom — DSG/EDÖB, Schweiz-Compliance | 5–8 | 8 / 8 ✅ |
OPS |
Anhang C + Custom — Test-Strategie, Doku, Phasenarchitektur, Audit-Redlichkeit, Pipeline-Ehrlichkeit, reproduzierbare Urteile, ausfuehrbare Anleitungen, pruefbare Guards, Herkunft der Fixtures, Gegenprobe als Abnahmekriterium | 3–10 | 10 / 10 ✅ |
FID |
Custom — Datentreue: Scope, Recall, Leermengen, Antwortstruktur und Feldnamen, unterdrückte Zahlwerte | 4–7 | 7 / 7 ✅ |
IDENT |
Custom — Identität: User-Agent, __version__, Manifest, Doku-Version, Release-Gap, Gesundheit des Artefakts |
5–8 | 7 / 7 ✅ |
DRIFT |
Custom — Upstream-Vertrag und Repo-Prosa: Endpoint-Drift, Fallback-Semantik, Testgüte, CHANGELOG gegen Code | 4–8 | 7 / 7 ✅ |
DEP |
Custom — Auflösungsraum des publizierten Artefakts: Obergrenzen, Major-Wechsel | 1–3 | 1 / 1 ✅ |
| Total | ~99 | 120 / 120 ✅ |
2.2 Severity-Stufen
| Stufe | Bedeutung | Konsequenz |
|---|---|---|
critical |
Sicherheitslücke oder Compliance-Bruch | Blockiert Produktion. Muss vor Release gefixt sein. |
high |
Architektureller Mangel mit signifikantem Risiko | Im laufenden Sprint fixen, max. 1 Sprint Karenz. |
medium |
Best-Practice-Verletzung, kein akutes Risiko | Im nächsten Sprint planen. |
low |
Polish, Optimierung, Stilistik | Backlog. Bei Tippfehler-Audits: low + auto-fix. |
2.3 Adoptionsstufen
Severity sagt, wie schlimm ein Verstoss ist. Die Adoptionsstufe sagt, ob der Katalog das Portfolio schon darauf festnageln darf. Zwei verschiedene Fragen, und ohne die zweite trifft jeder neue Check am Tag des Merges 30+ Server als rote Pipeline — so werden Checks zurückgenommen statt übernommen.
| Stufe | Bedeutung | Konsequenz |
|---|---|---|
enforced |
Der Katalog hält das Portfolio daran fest | Ein fail auf critical/high blockiert Production-Readiness |
advisory |
Der Check meldet, urteilt aber noch nicht | Finding wird erzeugt, gezählt und mit voller Severity geführt — blockiert aber nicht |
adoption: advisory # optional; fehlt das Feld, gilt `enforced`
Die Adoptionsstufe ist die eine von zwei Achsen, auf denen ein Check aufhören kann zu beissen. Die andere ist die Spec-Baseline — siehe §2.7. Sie beantworten verschiedene Fragen: adoption sagt, ob der Katalog schon urteilen darf; spec_baseline sagt, ob der Check das Protokoll überhaupt noch beschreibt.
Advisory versteckt nichts. Das Finding entsteht, trägt seine Severity und erscheint im Report. Nur das Veto entfällt. Eine Stufe, die den Befund unterdrückte statt nur sein Veto, wäre schlimmer als gar keine Stufe.
Ein Advisory-Finding auf blockierender Severity wird im Report unter advisory_findings namentlich genannt — auch bei grünem Verdikt. Wer später promoviert, weiss vorher, was rot würde.
Der Weg eines neuen Checks:
Als
advisorymergen. Der Check läuft im nächsten Portfolio-Durchlauf mit und meldet, ohne zu blockieren.Die Advisory-Findings über das Portfolio auswerten: Ist der Check richtig geschnitten? Produziert er Fehlalarme?
Wenn die betroffenen Server nachgezogen haben — oder der Rückstand bewusst akzeptiert ist —, auf
enforcedpromovieren. Die Promotion gehört in den CHANGELOG, nicht in einen Diff, den niemand liest. Wird sie aufcriticaloderhighausgesprochen, ist sie ausserdem ein Re-Audit-Auslöser nach §5d: Ab diesem Moment verlieren Server ihre Production-Readiness, deren letztes Audit dasselbe Finding noch als folgenlos führen durfte.- e) Spec-Baseline verengt oder Prüfkriterium an eine neue Revision angepasst. Ein Sonderfall von b) und c) mit eigener Auslösebedingung: Wechselt ein Server seine
mcp_spec_version, ändert sich die geprüfte Katalogmenge in beide Richtungen, ohne dass an einem einzigen Check etwas geändert wurde. Das Audit davor hat gegen einen anderen Katalog gemessen als das danach — nicht gegen einen kleineren oder grösseren, sondern gegen einen teilweise anderen. Einproduction_ready: truevon vor der Migration trägt deshalb nicht über sie hinweg. Die Migration eines Servers ist ein Re-Audit-Auslöser, unabhängig davon, ob der Katalog sich bewegt hat.
Wird Schritt 2 übersprungen — Promotion ohne dazwischenliegenden Portfolio-Durchlauf —, stützt sie sich auf «Rückstand bewusst akzeptiert» und nicht auf ausgewertete Advisory-Findings. Beides ist zulässig; welches von beidem gilt, gehört in den CHANGELOG-Eintrag. Eine Promotion, die Evidenz behauptet, die nicht erhoben wurde, ist der Fehler aus
OPS-004.- e) Spec-Baseline verengt oder Prüfkriterium an eine neue Revision angepasst. Ein Sonderfall von b) und c) mit eigener Auslösebedingung: Wechselt ein Server seine
Ein Tippfehler in adoption ist ein harter Fehler beim Katalog-Parsen. Eine stille Demotion wäre die leiseste Art, einen Check zu verlieren.
Der Katalog ist autoritativ, nicht die Ergebnisdatei:
python tools/aggregate_results.py aggregate verification-results.json \
--checks-dir "$SKILL_BASE/checks/" --out summary.json
Ohne --checks-dir gilt, was in verification-results.json steht — und ein fehlendes Feld bekommt dort still den enforced-Default. Das ist die sichere Richtung, aber es heisst auch: Eine Advisory-Stufe wirkt nur, wenn der Katalog gelesen wird.
2.4 Check-Schema
Jeder Check ist eine eigenständige Markdown-Datei im Format:
---
id: SEC-001
title: "Confused Deputy: Per-Client Consent Flow"
category: security
severity: critical
applies_when: 'auth_model == "OAuth-Proxy"'
spec_baseline: beide # optional; fehlt das Feld, gilt `beide`
adoption: enforced # optional; fehlt das Feld, gilt `enforced`
pdf_ref: "Sec 4.1"
spec_ref: "SEP-xxxx (PR xxxx)" # bei Checks aus einem Spec-Changelog
evidence_required: 3
---
# Body mit Description, Verification, Pass Criteria, Remediation
Ein Check, der aus einem Spec-Changelog stammt, trägt in spec_ref seine SEP-Nummer. Das ist keine Zierde: Die Begründung eines solchen Checks liegt nicht im Katalog, sondern in einem Dokument, das sich weiterentwickelt. Ohne die Nummer ist bei der nächsten Revision nicht feststellbar, ob ein Check noch die aktuelle Fassung seiner Quelle wiedergibt — und genau das ist der Zustand, aus dem OBS-001 acht Monate lang das Gegenteil der Spec gelehrt hat.
Details siehe templates/finding.md und beliebige Datei in checks/.
2.5 Reichweite vor neuer Regel
Ein Fund, den kein Check gemeldet hat, löst einen Reflex aus: einen neuen Check schreiben. Das ist die teure Richtung, und meistens die falsche. Häufiger als eine fehlende Regel ist eine vorhandene Regel, die zu eng angewandt wurde.
Vor jedem neuen Check drei Fragen, in dieser Reihenfolge:
- Gibt es den Check schon, aber
applies_whenschliesst den Fall aus? Dann ist die Klausel das Problem, nicht der Katalog. Reichweite korrigieren. - Gibt es den Check schon, aber seine Verification nennt nur einen Endpoint, ein Artefakt, eine Stelle? Dann prüft er die richtige Sache am falschen Umfang. Verification erweitern.
- Fragt wirklich kein Check danach — ist es eine eigene Prüfdimension? Erst dann ein neuer Check.
Nur die dritte Frage rechtfertigt eine neue Datei in checks/.
Beide Ausgänge sind schon vorgekommen:
- Reichweite, nicht Regel: Die Guard-Tests in
tests/test_skill_counts.pypinnten Katalogzahlen gegen die Doku — und liessen trotzdem eine Überschrift «Zehn Kategorien» über einer Tabelle mit elf Zeilen durch. Es fehlte kein Test. Der vorhandene reichte nur bis zur Intro-Zeile. Die Korrektur war eine erweiterte Verification, kein neuer Check. - Wirklich neu: Die Kategorie
FIDentstand austermdat-mcp#11an einem Server, der 68 Checks bestanden hatte. Keine der acht damaligen Kategorien stellte die Frage, ob der Server liefert, was die Quelle hat. Das war keine zu enge Klausel, sondern eine fehlende Dimension.
Warum die Reihenfolge zählt: Zwei Checks, die einander teilweise überlappen, sind schlimmer als einer mit korrekter Reichweite. Sie doppeln das Finding, und wenn der Server die Ursache behebt, bleibt der zweite rot — der Fix sieht aus, als hätte er nicht gewirkt. Ein Katalog, der per Reflex wächst, wird ausserdem irgendwann nicht mehr vollständig gelesen.
Der Gegenfehler: Einen Check so weit dehnen, bis er ein Sammelbehälter wird. Das Signal ist konkret — wenn die Erweiterung ein oder in die Pass-Criteria zwingt, das mit dem ursprünglichen Kriterium nichts zu tun hat, ist es ein neuer Check. Ein Check muss in einem Schritt behebbar bleiben.
Eselsbrücke: «Zuerst fragen, ob die Regel zu kurz gegriffen hat — nicht, ob sie fehlt.»
2.6 Ein Check, der nichts findet, muss sagen können, ob er gesucht hat
Diese Regel gilt für den Katalog selbst, nicht für einen einzelnen Server. Sie ist die Bedingung dafür, dass die Ergebnisse aller anderen Checks etwas bedeuten.
Ein Check hat drei mögliche Ausgänge, nicht zwei:
| Ausgang | Bedeutung |
|---|---|
pass |
Gesucht, und der geprüfte Zustand liegt vor |
fail |
Gesucht, und ein Verstoss liegt vor |
todo / unverified |
Nicht gesucht, oder gesucht und die Form nicht erkannt |
Die dritte Zeile ist die, die in der Praxis verschwindet. «Nichts gefunden» und «nicht hingeschaut» erzeugen dieselbe Beobachtung — eine leere Ergebnisliste — und werden deshalb ohne Zutun zum selben Ausgang zusammengelegt. Der Ausgang, zu dem sie zusammenfallen, ist immer pass, weil ein Werkzeug meldet, was es findet, und nicht, was es nicht gesucht hat.
Der Beleg steht in IDENT-001: Die erste Fassung der Identitäts-Probe erklärte 24 Pakete für unauffällig, von denen 16 drifteten. Kein Fehler in der Vergleichslogik — die Probe erkannte die Form des User-Agents nicht (verschachteltes Dict, Literal im Konstruktor, f-String ohne Ziffer) und meldete für diese Pakete nichts. Nichts las sich als «in Ordnung». Zwei Drittel der Befunde gingen an genau dieser Stelle verloren, und die Zusammenfassung war grün.
Was das für jeden Check im Katalog heisst:
- Jeder
automated-Modus braucht einen Ausgang für «Harness lief nicht». Ein Exit-Code, ein Statuswert, irgendetwas Unterscheidbares — und er wird auftodoabgebildet, nie aufpass.IDENT-006undIDENT-007tun das mit Exit127,IDENT-001mitunverified. - Eine Pass-Criterion in der Form «kein X gefunden» ist unvollständig. Sie muss sagen, wie gesucht wurde und woran man erkennt, dass die Suche funktioniert hat. Sonst besteht sie jedes Repo, in dem das Werkzeug versagt hat.
- Wo die Erkennung selbst scheitern kann, gehört das als eigener Befundwert in den Check — nicht in eine Fussnote.
IDENT-001führtunverifiedals Ergebnis mit eigenem Exit-Code, gerade damit es nicht inpassfällt. - Gegenprobe beim Schreiben des Checks: Die Verifikation einmal gegen ein Repo laufen lassen, in dem der Verstoss sicher vorliegt. Meldet sie nichts, prüft sie nichts — dieselbe Gegenprobe wie bei jedem Gate, siehe §4.1.
Verhältnis zu OPS-005: Dort geht es um die Pipeline eines auditierten Servers — ein Test, der nie lief, sieht aus wie ein Test, der bestand. Hier geht es eine Ebene höher um die Bauart der Checks selbst. Dieselbe Asymmetrie, zwei verschiedene Adressaten: OPS-005 prüfen wir an fremden Repos, §2.6 schulden wir dem eigenen Katalog.
Eselsbrücke: «Schweigen ist kein Freispruch.»
2.7 Spec-Baseline: welcher Protokollstand gemessen wird
Seit v2.0.0 trägt jeder Check ein zweites Feld, das über seine Anwendbarkeit entscheidet:
spec_baseline: 2026-07-28 # 2025-11-25 | 2026-07-28 | beide (Default)
Es wird gegen mcp_spec_version aus dem Profil gehalten. Der Anlass ist konkret: 2026-07-28 hat Sitzungen, den initialize-Handshake und die SSE-Resumability entfernt. Fünf Checks messen damit einen Gegenstand, den es nicht mehr gibt; vierzehn messen einen, den es vorher nicht gab. Während der Wellen A–D stehen beide Stände gleichzeitig im Portfolio.
| Wert | Bedeutung |
|---|---|
2025-11-25 |
misst gegen das Protokoll vor der Stateless-Umstellung |
2026-07-28 |
misst gegen das Protokoll danach |
beide |
protokollunabhängig — Default, wenn das Feld fehlt |
Warum das eine eigene Stufe ist und keine applies_when-Klausel. Technisch ginge beides; mcp_spec_version == "2026-07-28" wäre ein gewöhnlicher Feldvergleich. Die Trennung ist die Entscheidung:
applies_whenbeantwortet: Ist dieser Server die Art von Server, um die es geht?spec_baselinebeantwortet: Beschreibt dieser Check noch das Protokoll, das dieser Server spricht?
Zusammengefaltet erscheinen beide Ausgänge im Applicability-Report als dasselbe no-match — und die Unterscheidung, die §3.4 zwischen «Katalog hat sich geändert» und «Profil hat sich geändert» zieht, verliert ihre dritte Möglichkeit. Ein Check, der wegfällt, weil der Server stdio-only ist, verlangt eine Profilkorrektur; einer, der wegfällt, weil der Server migriert ist, verlangt gar nichts. Der Report muss das sagen können.
Drei Ausgänge, nicht zwei — dieselbe Konstruktion wie in §2.6:
| Reason | Bedeutung |
|---|---|
| (leer) | Baseline passt, applies_when entscheidet |
baseline-mismatch |
Geprüft und ausgeschlossen: der Check misst die andere Revision |
baseline-unresolved |
Nicht geprüft — das Profil sagt nicht, welchen Stand der Server spricht |
Der dritte ist der Grund für den eigenen Wert. Fiele er mit dem zweiten zusammen, sähe ein Profil mit vergessenem Feld exakt aus wie ein sauberer Lauf über einen kleineren Katalog. eval_applicability.py catalog exitet deshalb mit 3, wenn ein Check unresolved bleibt.
Verengen statt löschen. Ein Check, dessen Gegenstand verschwunden ist, wird nicht entfernt und nicht umbenannt — er bekommt spec_baseline: 2025-11-25 und nennt in seinem Kopf den Nachfolger. Löschen würde die Frage mit dem Check verschwinden lassen; ohne Nachfolgerangabe wäre nicht auffindbar, wo sie jetzt gestellt wird. Die fünf verengten Checks sind SCALE-002, SCALE-003, SCALE-007, SDK-004 und SEC-009.
Eselsbrücke: «adoption sagt, ob schon geurteilt wird. spec_baseline sagt, ob noch das Richtige gemessen wird.»
Schritt 3: Applicability-Filter
Ziel: Aus den ~50 Checks nur diejenigen auswählen, die für das aktuelle Server-Profil tatsächlich relevant sind. Ohne diesen Filter überfluten irrelevante Findings den Report (z.B. OAuth-Checks für stdio-only-Server ohne Auth).
3.1 Auswertung der applies_when-Klausel
Die Klausel ist ein Boolean-Ausdruck gegen die Profil-Felder. Die formale DSL-Spezifikation steht in docs/applies-when-dsl.md, die Referenz-Implementierung in tools/eval_applicability.py.
| Operator | Beispiel | Bedeutung |
|---|---|---|
== |
transport == "HTTP/SSE" |
exakter String-Vergleich |
!= |
auth_model != "none" |
Negation |
.includes(...) |
deployment.includes("Railway") |
Multi-Select-Membership |
and / or |
transport == "HTTP/SSE" and auth_model == "OAuth-Proxy" |
Verknüpfung |
always |
always |
Check ist universell, läuft immer |
Vor der Klausel läuft die Baseline-Stufe (§2.7). Die Reihenfolge ist nicht kosmetisch: Beschreibt ein Check ein Protokoll, das dieser Server nicht spricht, sagt sein applies_when-Urteil nichts Berichtenswertes — die verglichenen Profilfelder sind zwar gültig, aber der geprüfte Gegenstand existiert nicht. Der Report nennt dann den gröberen Grund.
Pflicht: Verwende den kanonischen Evaluator, niemals Python eval() oder ad-hoc-Substitution. Letzteres hat in der Vergangenheit zu nicht-reproduzierbaren Audits geführt (Listen-vs-String-Vergleiche, True vs true, etc.).
# Catalog-Auswertung gegen ein Profil
python tools/eval_applicability.py catalog path/to/profile.yaml --format table
# Einzelner Ausdruck testen
python tools/eval_applicability.py expr 'auth_model != "none"' path/to/profile.yaml
3.2 Typische Filter-Muster
stdio-only-Server ohne Auth, Public Open Data, read-only:
- Anwendbar: alle
ARCH, alleSDK, ~5SEC(basale Best Practices),OBS-Logging-Basics, einigeCH - Nicht anwendbar: SSRF, OAuth-Flow, Session-Hijacking, Stateful-LB, Sandboxing
- Geschätzt: ~15–20 Checks
HTTP/SSE-Server mit OAuth-Proxy, Cloud-Deployment, Verwaltungsdaten:
- Anwendbar: praktisch alles
- Geschätzt: ~45–55 Checks
3.3 Applicability-Report (vor Audit-Start)
Bevor der eigentliche Audit beginnt, gibt Claude diese Übersicht aus:
=== Audit applicability for zurich-opendata-mcp ===
Profile: dual transport, no auth, Public Open Data, read-only,
Deployment: [local-stdio, Railway]
MCP spec: 2025-11-25
Spec baseline: 11 check(s) dropped as written for the other revision
dropped: ARCH-015, ARCH-016, ARCH-017, ARCH-018, ARCH-020, ARCH-021,
HITL-006, SCALE-008, SCALE-010, SEC-025, SEC-027
Applicable checks: 23 / 50
ARCH: 7/7 (universal)
SDK: 6/6 (universal)
SEC: 4/18 (cloud-relevant subset)
SCALE: 3/6 (Railway-relevant subset)
OBS: 3/5 (universal subset)
HITL: 0/4 (no write access, no sampling)
CH: 0/6 (Public Open Data, no PII)
Severity breakdown of applicable checks:
critical: 4 high: 11 medium: 6 low: 2
Wichtig: Wenn ein Check nicht anwendbar ist, erscheint er gar nicht im Report — nicht einmal als «N/A». Das hält Reports fokussiert und vermeidet Audit-Müdigkeit.
Ausnahme: Baseline-Ausfälle werden namentlich genannt. Sie sind kein Profil-Detail, sondern eine Aussage über den Katalog — und über die Migration eines konkreten Servers bewegen sie zweistellige Zahlen in beide Richtungen. Ein Lauf, der das verschweigt, meldet einen sauberen Durchgang über einen kleineren Katalog, als er behauptet. Das ist der Fehler aus OPS-005, gestellt an das Audit selbst.
Bleibt ein Check baseline-unresolved, wird Schritt 4 nicht gestartet: Das Profil hat nicht gesagt, welchen Protokollstand der Server spricht, und die betroffenen Checks sind weder gelaufen noch ausgeschlossen.
3.4 Applicability gegen den Vorlauf vergleichen
Bei einem Re-Audit ist die interessante Frage nicht, welche Checks anwendbar sind, sondern welche es nicht mehr oder neu sind. Dieser Vergleich gehört wie jeder andere in ein Skript:
# Auswertung des laufenden Audits sichern — der Katalog-Stand des Vorlaufs
# liegt vielleicht nicht mehr auf der Platte, die gespeicherte Auswertung schon.
python tools/eval_applicability.py catalog profile.yaml > audits/<run>/applicability.json
# Gegen den Vorlauf halten
python tools/eval_applicability.py diff \
audits/<vorlauf>/applicability.json \
audits/<run>/applicability.json \
--labels vorlauf,jetzt --format table
# Exit 0: identisch. Exit 1: Unterschied. Exit 2: eine Seite war leer.
Der Helfer trennt zwei Dinge, die eine reine Anwendbarkeits-Differenz gleich aussehen lässt: welche Checks überhaupt ausgewertet wurden (der Katalog hat sich geändert) und welche anwendbar waren (das Profil hat sich geändert). Das sind verschiedene Ereignisse mit verschiedenen Konsequenzen.
Und er verweigert den Vergleich, wenn eine Seite leer parst. Eine handgeschriebene Fassung dieses Diffs meldete einmal «0 == 0, identisch» — beide Seiten hatten wegen eines falschen Pfads nichts geparst, und die Arithmetik stimmte. Das ist schlimmer als kein Vergleich: Ohne Helfer bleibt die Frage offen und wird irgendwann beantwortet; mit ihm schliesst eine grüne Zeile die Frage mit Belegen, die nie erhoben wurden. Dieselbe Fehlerklasse wie OPS-005 (was nicht lief, sieht aus wie bestanden) und FID-003 (eine Leermenge, die der Server für den Aufrufer deutet). Ein leerer Input ist kein Befund von Gleichheit, sondern das Fehlen einer Beobachtung.
Alle Vergleichs-Helfer im Repo laufen deshalb über tools/compare_guard.py. Wo eine leere Seite tatsächlich die erwartete Antwort ist, gibt es --allow-empty — als Flag und nicht als stille Toleranz, damit die Entscheidung eine Spur hinterlässt.
Schritt 4: Check-Ausführung
Ziel: Jeden anwendbaren Check methodisch verifizieren — entweder automatisch (grep, AST, curl) oder via manuellem Code-Review.
…(truncated)