# MCP Audit

> Reproduzierbares Audit von MCP-Servern gegen einen versionierten Best-Practice-Katalog. Verwende diesen Skill wenn der User (1) einen MCP-Server gegen Best Practices prüfen will, (2) Sicherheitsfindings für einen Server dokumentieren möchte, (3) den MCP Audit Tracker (Notion) abarbeitet, (4) fragt «ist mein Server sicher / production-ready / standard-konform», (5) den Begriff «Audit», «Findings», «Compliance-Check», «Best Practice» im MCP-Kontext erwähnt, (6) einen Refactoring-Plan für einen bestehenden Server erstellt, oder (7) für mehrere Server einen vergleichenden Audit-Report erstellen möchte. Auch bei allgemeinen Aussagen wie «ist der Server gut gebaut?», «was muss ich noch fixen?», «entspricht das den Standards?» diesen Skill anwenden.

- Skill: `malkreide/mcp-audit` (Agent Skill, multi-file: 16 files)
- Install (CLI): `npx skillmds@latest add malkreide/mcp-audit`
- Raw SKILL.md: https://api.skillmd.com/api/skills/malkreide/mcp-audit/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: malkreide (https://skillmd.com/u/malkreide)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/malkreide/mcp-audit

---


# 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:**

1. **Profil zuerst, Checks danach** — applicability filtert alles
2. **Evidenz schlägt Vermutung** — jeder Befund braucht Code-Stelle oder konkretes Verhalten
3. **Severity ohne Mitleid** — `critical` blockiert 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
# Bash/PowerShell — vor Python-Aufrufen exportieren:
export PYTHONUTF8=1            # Bash
$env:PYTHONUTF8 = "1"          # PowerShell
```

Oder im Python-Code direkt:

```python
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
# 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
# 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:

```bash
# 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_offset`
- `skill_version`, `catalog_hash` (SHA-256 aller `checks/*.md` + `MANIFEST.txt`), `catalog_dir`
- `target_repo`, `target_sha`, `target_dirty`, `target_branch` — die Revision des auditierten Repos (nur mit `--target-repo`)
- Leeres `agent_runs`-Array (wird in Step 4 von `agent_run_log.py` befü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 `&gt;` 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 `&#39;` 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 `&#34;` 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 `&#39;`, 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:

```bash
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:

```yaml
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:

```bash
# 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:** `transport` ausserhalb von `stdio-only` / `dual` / `HTTP/SSE`. Ein Wert, gegen den keine `applies_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*, das `UnknownFieldError` auslö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 |

```yaml
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](#27-spec-baseline-welcher-protokollstand-gemessen-wird). 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:**

1. Als `advisory` mergen. Der Check läuft im nächsten Portfolio-Durchlauf mit und meldet, ohne zu blockieren.
2. Die Advisory-Findings über das Portfolio auswerten: Ist der Check richtig geschnitten? Produziert er Fehlalarme?
3. Wenn die betroffenen Server nachgezogen haben — oder der Rückstand bewusst akzeptiert ist —, auf `enforced` promovieren. Die Promotion gehört in den CHANGELOG, nicht in einen Diff, den niemand liest. Wird sie auf `critical` oder `high` ausgesprochen, ist sie ausserdem ein **Re-Audit-Auslöser** nach [§5d](#versionierung-des-check-katalogs): 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. Ein `production_ready: true` von 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`.

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:

```bash
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:

```markdown
---
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:**

1. **Gibt es den Check schon, aber `applies_when` schliesst den Fall aus?** Dann ist die Klausel das Problem, nicht der Katalog. Reichweite korrigieren.
2. **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.
3. **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.py` pinnten 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 `FID` entstand aus `termdat-mcp#11` an 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:**

1. **Jeder `automated`-Modus braucht einen Ausgang für «Harness lief nicht».** Ein Exit-Code, ein Statuswert, irgendetwas Unterscheidbares — und er wird auf `todo` abgebildet, nie auf `pass`. `IDENT-006` und `IDENT-007` tun das mit Exit `127`, `IDENT-001` mit `unverified`.
2. **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.
3. **Wo die Erkennung selbst scheitern kann, gehört das als eigener Befundwert in den Check** — nicht in eine Fussnote. `IDENT-001` führt `unverified` als Ergebnis mit eigenem Exit-Code, gerade damit es nicht in `pass` fällt.
4. **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](#41-drei-verifikationsmodi).

**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:

```yaml
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_when` beantwortet: *Ist dieser Server die Art von Server, um die es geht?*
- `spec_baseline` beantwortet: *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`](docs/applies-when-dsl.md), die Referenz-Implementierung in [`tools/eval_applicability.py`](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](#27-spec-baseline-welcher-protokollstand-gemessen-wird)). 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.).

```bash
# 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`, alle `SDK`, ~5 `SEC` (basale Best Practices), `OBS`-Logging-Basics, einige `CH`
- 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:

```bash
# 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)
