# Audit MCP + API de connexion — Launch readiness

- **Date** : 2026-07-24
- **Objectif** : addon MCP + synchro clients↔plateforme « parfaites » pour un launch public proche.
- **Méthode** : cartographie exhaustive + 3 audits parallèles (sécurité/auth, fiabilité/synchro/quota, DX/product), findings vérifiés dans le code par l'assemblage.
- **Périmètre** : le **Checklist MCP add-on** (client MCP externe → `/api/mcp`, clé Bearer `eli_`), le broker `/api/worker/gsc-token`, le worker GSC Rust, les clés API + quota. Hors périmètre : `~/Github/mcp-gsc` (produit séparé).

## Verdict global

**Le socle technique est prêt ; le produit ne l'est pas.** Rien ne casse l'isolation entre clients, ne permet de dépasser le quota ni de corrompre les données. Mais **on ne peut pas lancer publiquement en l'état** : pas d'activation self-serve, pas de doc, et la promesse multi-clients (Claude Desktop/ChatGPT) n'est pas tenable sans OAuth.

Trois catégories : **bloquants launch** (à traiter avant d'ouvrir), **durcissements** (techniques, non-controversés, je peux les faire), **décisions produit** (Bastien tranche).

---

## A. Bloquants launch (produit — décision requise)

### A1 — Aucune activation self-serve de l'add-on `checklist_mcp` — VÉRIFIÉ
La ligne `org_addons (orgId, "checklist_mcp")` n'est posée QUE par `setOrgAddonAction` (`src/lib/mcp/keys-actions.ts:126`, **god-mode super-admin only**). Rien dans `src/lib/billing/` ne l'active — alors que le pattern existe déjà pour `seranking_byok` (`src/lib/billing/seranking-addon.ts:81`, insert via checkout/pack). Les plans définissent pourtant des quotas MCP (`src/lib/billing/plans.ts`, 1 000 → 500 000/mois). Un client normal voit « Contacte-nous pour l'activer » **sans lien** (`api-keys-manager.tsx:251`).
→ **Décision** : l'addon est-il inclus par plan (auto-activé si `mcpMonthly > 0`), un add-on payant séparé, ou gratuit en bêta ? C'est le verrou nº1.

### A2 — Compatibilité clients : header statique ≠ Claude Desktop / ChatGPT
La config affichée (`api-keys-manager.tsx:137`) marche pour Cursor / Claude Code / VS Code / Windsurf (headers). **Claude Desktop/claude.ai et ChatGPT attendent OAuth**, pas un Bearer statique. Le 401 renvoie `WWW-Authenticate: Bearer` nu, sans `resource_metadata` OAuth (`route.ts:30`).
→ **Décision** : v1 honnête = « clients à header » + contournement documenté `npx mcp-remote <url> --header` pour Claude Desktop ; v2 = OAuth (DCR) pour claude.ai/ChatGPT. Ne pas promettre Claude Desktop/ChatGPT au launch sans l'un des deux.

### A3 — Aucune documentation externe
Seules les specs internes existent. Ni page publique, ni mention du MCP sur `/pricing`. Un client hors de la page settings ne peut ni découvrir ni brancher l'addon. Le `fix-prompt` (localisé 4 langues, config embarquée) est un excellent point de départ pour cette doc.
→ **Action** : page docs publique (URL serveur, format Bearer, tableau des 9 tools, snippet par client, quota par plan).

## B. Durcissements (technique — non-controversés)

### B1 — SSRF via `run_site_audit` (HIGH) — VÉRIFIÉ
`fetchHome`/`fetchText` (`src/lib/spec/audit/snapshot.ts:71,94`) font `redirect: "follow"` sans filtrage d'host ; la validation d'URL du site est `z.url().max(255)` (`src/lib/sites/schema.ts:14`) — accepte IP privées/loopback/metadata cloud. Un membre d'une org avec l'addon peut créer un site `http://169.254.169.254/…` puis `run_site_audit` → fetch serveur vers cible interne (+ robots/sitemap/security.txt/llms.txt), oracle d'existence/statut/timing. **Bloquant si l'app tourne dans un cloud avec metadata endpoint.**
→ **Fix** : rejet des hosts non-publics (résolution DNS → refus loopback/RFC1918/link-local/IPv6 ULA), re-vérifié après chaque redirect (ou `redirect: "manual"` + validation du `Location`).

### B2 — Rate-limiting HTTP absent sur `/api/mcp` et `/api/worker/gsc-token` (MEDIUM) — convergent 3 audits
Seuls freins : quota mensuel (après auth réussie) + cooldown 60 s de `run_site_audit`. Un flood non authentifié coûte un sha256 + SELECT par requête (DoS économique) ; un client authentifié peut brûler 25 000 crédits en minutes (UPDATEs sérialisés sur la hot row `organization`). Pas de compromission (clés 256 bits).
→ **Fix** : limiteur par IP (avant lookup DB, pour le cas 401) + par clé (~120 req/min), via WAF edge ou la table `rate_limit` Better Auth déjà présente (`auth-schema.ts:120`).

### B3 — Worker GSC : client HTTP sans timeout ni retry (MEDIUM)
`reqwest::Client::new()` (`crates/gsc-worker/src/main.rs:72`, `broker.rs:17`) — **aucun timeout par défaut** : une connexion qui hang bloque le run indéfiniment (`finished_at` NULL, cron du lendemain se superpose). Aucun retry/backoff sur 429/5xx (`main.rs:36`) → un 429 transitoire perd le site pour la journée (mitigé par la fenêtre glissante J-5→J-1 : 5 échecs consécutifs pour perdre définitivement un jour).
→ **Fix** : `Client::builder().timeout(60s).connect_timeout(10s)` ; 3 retries backoff+jitter sur 429/5xx uniquement (pas 403/404) ; `pg_try_advisory_lock` anti-chevauchement de runs.

## C. Améliorations DX (avant launch, gratuites maintenant)

- **C1 — Renommage tools** : `getKeyword`/`getBrief` (camelCase) vs 7 autres snake_case → `get_keyword`/`get_keyword_brief`. **Breaking après launch** (le fix-prompt embarque les noms) — dernier moment gratuit.
- **C2 — `serverInfo`** : `"seo-insight-checklist"` daté (le serveur expose aussi keywords) + version `"1.0.0"` dupliquée en 2 constantes (`route.ts:21`, `server.ts:297`) → `"seo-insight"`, source unique. Même fenêtre breaking que C1.
- **C3 — Annotations MCP** : aucun tool ne déclare `readOnlyHint`/`destructiveHint`/`title` — 8 tools sont read-only, `run_site_audit` mute. Les clients modernes s'en servent pour les confirmations.
- **C4 — `get_capabilities` + `instructions` serveur** : absent. Un tool listant tools + quota restant (fonctions déjà là : `getOrgMcpMonthlyUsage`) + le champ `instructions` du protocole (« commence par list_sites, spec statique, cooldown 60 s ») aident l'agent à s'auto-réguler. Pattern éprouvé dans mcp-gsc.
- **C5 — Erreurs actionnables** : 401/403 ne disent pas quoi faire (`auth.ts:43,54,61`) — ajouter l'action + URL (`{appUrl}/settings/api-keys`, `/settings/billing`) ; le 403 quota devrait inclure `used/limit` + mois de reset.
- **C6 — Langue** : serveur MCP 100 % FR (descriptions, réponses, erreurs) alors que l'app est i18n 4 langues et le fix-prompt défaut EN → passer les descriptions/erreurs en anglais (langue de l'agent, standard MCP).
- **C7 — Métrologie quota** : chaque requête HTTP compte (`initialize`, `tools/list` inclus), pas seulement les tool calls → soit exempter le handshake, soit documenter « 1 requête = 1 unité » (sinon écart visible client).

## D. Ce qui est déjà solide (ne pas retoucher)

- **Isolation cross-tenant exemplaire** : orgId toujours issu de la clé (`request.auth.extra.orgId`), jamais de l'input ; chaque requête tool filtre `org_id` ; réponses cross-org = « introuvable » sans oracle. IDOR non exploitable (testé adversarialement).
- **Gate de quota atomique prouvé correct sous concurrence** (`quota.ts:145-165`, un seul `UPDATE…WHERE…RETURNING`, EvalPlanQual sérialise les courses) : impossible de dépasser le quota ou double-dépenser un crédit.
- **Fulfillment packs idempotent** (UNIQUE `stripe_session_id` + `ON CONFLICT DO NOTHING` transactionnel) : retries webhook Stripe = no-op.
- **Upserts GSC idempotents** : clé de conflit = PK exacte `(site_id, date, query|page)`, re-run après crash = auto-correction sans doublon.
- **Clés API** : 256 bits, sha256 en base (sans sel = correct pour haute entropie), affichage one-time, révocation lue en DB sans cache (effet immédiat), unicité de hash (index unique).
- **Broker worker** : `timingSafeEqual` précédé du check de longueur, fail-closed si `WORKER_API_KEY` absent (503), token jamais logué.
- **Caches cohérents** : `updateTag`/`revalidateTag {expire:0}` sur tous les chemins d'écriture applicatifs (toggle addon, overrides, webhook Stripe) — les TTL 30 s/60 s ne sont qu'un filet pour les éditions DB hors app.
- **Anti-boucle** : cooldown 60 s de `run_site_audit` triple-communiqué (tool desc + réponse + fix-prompt), fetch capé 512 KB / timeout 8 s.

## Findings mineurs (post-launch)

- Rollover de mois : sous-comptage possible d'une poignée de requêtes au boundary (jamais de dépassement facturable) — `quota.ts:151` (fix : `WHERE mcp_usage_month <= month`).
- Bump analytics fire-and-forget : `.catch(()=>{})` masque une panne systémique — ajouter `console.error` (`auth.ts:83`).
- Observabilité worker : exit 0 même si un site échoue chaque nuit ; `gsc_snapshot_runs.error` ne garde que le 1er échec — alerter sur `sites_failed > 0`.
- Clés API sans expiration (révocation manuelle seulement).
- `serankingApiKey` en clair (`auth-schema.ts:186`, dette connue hors périmètre).

## Séquence launch recommandée

1. **A1** (activation self-serve) — verrou nº1, décision produit.
2. **B1 + B2 + B3** (SSRF, rate-limit, worker timeout/retry) — durcissement, faisable tout de suite.
3. **C1 + C2** (renommages) — dernier moment gratuit avant breaking.
4. **A2 + A3** (compat clients honnête + doc externe).
5. **C3–C7** (annotations, get_capabilities, erreurs actionnables, anglais, métrologie).

---

## État d'avancement (2026-07-24)

### Durcissements techniques APPLIQUÉS (mergés sur `dev`, reviews Fable READY_TO_MERGE)
- ✅ **B1 — SSRF** (`c111799`) : `src/lib/spec/audit/ssrf-guard.ts` (blocage loopback/RFC1918/link-local/CGNAT/IPv6 ULA/IPv4-mapped, résolution DNS all-addresses) + `snapshot.ts` en `redirect: "manual"` re-validé à chaque hop. Contournements de parsing IP (décimal/octal/hex/formes courtes/IPv4-mapped) testés empiriquement contre Bun 1.3.14 → tous bloqués. 152 tests verts.
- ✅ **B3 — worker GSC** (`50a45b7`) : timeout 60s/connect 10s, retry backoff+jitter sur 429/5xx/réseau uniquement (403/404 permanents), `pg_try_advisory_lock` anti-chevauchement. cargo build+clippy+7 tests verts.
- ✅ **B2 — rate-limiting HTTP** (`42c13fc`) : `src/lib/mcp/rate-limit.ts` (fixed-window en mémoire). `/api/mcp` : 60/min par IP pré-auth (avant sha256+SELECT) + 120/min par id de clé post-auth (avant le quota UPDATE). `/api/worker/gsc-token` : 30/min par IP. 12 tests dédiés verts.

### Follow-ups sécurité résiduels (non-bloquants launch, à ticketer)
- **DNS rebinding TOCTOU** [Medium] : `assertPublicUrl` résout puis `fetch` re-résout indépendamment — fenêtre théorique fermable en épinglant l'IP validée (custom `lookup`/dispatcher undici). Cas courant (domaine → IP interne) déjà bloqué ; SSRF ici quasi-aveugle.
- **IPv6 `::/96` (IPv4-compatible) + NAT64 `64:ff9b::/96`** [Low, ~6 lignes] : littéraux non couverts par `isBlockedIPv6` ; non routés vers loopback par les kernels modernes (dépréciés) / NAT64 requis. Patch : traiter `::/96` comme embedded-IPv4.
- **Rate-limit in-memory** : par process (systemd mono-instance aujourd'hui OK) ; vidé au restart. Reco : `limit_req` Nginx en amont ; Redis `INCR`/`EXPIRE` si scale-out (le seam `checkRateLimit(bucketKey, opts)` est prêt).

### Décisions produit ARBITRÉES + exécutées (2026-07-24, mergées sur `dev`)
- ✅ **A1 — activation par plan** (`a80e232`, `e394670`) : `src/lib/billing/mcp-addon.ts` réconcilie `org_addons (checklist_mcp)` selon `mcpMonthly>0`, branché dans le webhook Stripe (`subscription-sync.ts`, best-effort pour ne pas bloquer les backstops), message UI « inclus dès le plan Solo », `backfillMcpAddons()` pour les orgs existantes. Downgrade → entitlement retiré, clés conservées (rejet 403). Review Fable READY_TO_MERGE, 19 tests DB verts.
- ✅ **A2 — clients + note OAuth** (`797eb5a`) : guide documente header-native (Cursor/Claude Code/VS Code/Windsurf) + contournement `mcp-remote` pour Claude Desktop ; note UI + guide signalent l'OAuth natif (claude.ai/ChatGPT) comme prévu ensuite.
- ✅ **A3 — doc externe** (`797eb5a`) : `docs/mcp-addon-guide.md` (9 tools, config par client, quota par plan, sécurité, domaine `dash.seoinsight.app`). Page publique marketing = chantier design restant.
- ✅ **C1/C2 — renommages** (`4bbc378`) : `get_keyword`/`get_keyword_brief`, serverInfo `"seo-insight"` (source unique `MCP_SERVER_INFO`).

### Backfill prod à lancer (une fois `dev`→`main` déployé) — additif, idempotent
```bash
DATABASE_URL="<prod>" bun -e 'const {backfillMcpAddons}=await import("./src/lib/billing/mcp-addon.ts");console.log(await backfillMcpAddons())'
```

### Reste optionnel (non-bloquant launch)
- **DX non tranchés** : C3 (annotations `readOnlyHint`), C4 (`get_capabilities` + `instructions` serveur), C5 (erreurs 401/403 actionnables avec URL), C6 (descriptions/erreurs en anglais — sujet launch international), C7 (métrologie « 1 requête = 1 unité »).
- **Sécurité résiduelle** : rebinding IP-pinning [Medium], IPv6 `::/96`/NAT64 [Low], rate-limit → Nginx `limit_req`/Redis si scale-out.
- **Produit à confirmer** : le **trial app-level** (sans subscription Stripe) n'a pas d'accès MCP (activation « par plan », le trial n'a pas de plan). Reco : ouvrir le MCP en trial pour l'adoption si souhaité (sinon statu quo cohérent).
- **Ops** : `syncOrgSeatsLimit`/resync god-mode (`admin/billing-actions.ts`) ne réappelle pas `syncMcpAddonForPlan` — à compléter si « force resync » doit reposer l'entitlement.
