Contenu

Post-mortem : mcp-installer régénérait les tokens à chaque relance

Le bug

mcp-installer est un script bash que j’ai écrit pour automatiser l’installation du mcp-oauth-proxy (FastAPI + nginx + systemd) sur un nouveau host. Workflow standard : clone, run, c’est tout.

Sauf qu’en relançant le script sur un host déjà installé (par exemple pour mettre à jour la version), j’ai découvert un bug d’idempotence : tous les secrets étaient régénérés.

$ sudo ./install.sh
[+] Generating MCP_TOKEN...
[+] Generating CLIENT_ID...
[+] Generating CLIENT_SECRET...
[+] Generating TOKEN_SECRET...
[+] Writing /etc/mcp-oauth-proxy/.env...

Si tu as déjà un .env avec des tokens en service, l’installer les écrase. Tous les clients OAuth déjà enregistrés (Claude.ai dans mon cas) se retrouvent avec des credentials invalides. La connexion casse.

Pourquoi c’est arrivé

Le script utilisait cette logique :

MCP_TOKEN=$(openssl rand -hex 32)
CLIENT_ID=$(openssl rand -hex 16)
CLIENT_SECRET=$(openssl rand -hex 32)
TOKEN_SECRET=$(openssl rand -hex 32)

cat > /etc/mcp-oauth-proxy/.env <<EOF
MCP_TOKEN=$MCP_TOKEN
CLIENT_ID=$CLIENT_ID
CLIENT_SECRET=$CLIENT_SECRET
TOKEN_SECRET=$TOKEN_SECRET
EOF

Le pattern « génère puis écris » est correct pour une première install. Mais en relance, il ignore complètement le .env existant.

C’est l’erreur classique : l’auteur (moi) a testé le script uniquement sur un host fresh. Le mode “réinstall sur host existant” n’a jamais été testé.

Reproduction du bug

$ sudo ./install.sh
[OK] Installation complete
$ cat /etc/mcp-oauth-proxy/.env | head -1
MCP_TOKEN=a7f3e9d2c4b8a1...

$ sudo ./install.sh
[OK] Installation complete
$ cat /etc/mcp-oauth-proxy/.env | head -1
MCP_TOKEN=8c2f6a1b9e4d7c...   ← DIFFÉRENT

Test reproductible en 30 secondes. C’est presque comique que je ne l’aie pas vu plus tôt.

La leçon : “idempotent” est un contrat, pas un espoir

Un script d’installation doit être idempotent par défaut. C’est-à-dire : ./install.sh 1 fois, 2 fois, 5 fois → le système est dans le même état stable.

J’avais ce contrat dans ma tête. Je ne l’avais pas dans le code.

Le fix : pre-flight checks + force flag explicite

Diagram Diagram

Version corrigée en v1.2.0 :

preflight_check_existing_install() {
    if [ -f /etc/mcp-oauth-proxy/.env ]; then
        echo "[!] Existing installation detected at /etc/mcp-oauth-proxy/.env"
        echo "[!] Tokens will be PRESERVED (use --force-rotate-tokens to regenerate)"
        return 0
    fi
    return 1
}

generate_or_load_secrets() {
    if [ "$FORCE_ROTATE_TOKENS" = "true" ]; then
        echo "[!] --force-rotate-tokens : regenerating all secrets"
        MCP_TOKEN=$(openssl rand -hex 32)
        CLIENT_ID=$(openssl rand -hex 16)
        CLIENT_SECRET=$(openssl rand -hex 32)
        TOKEN_SECRET=$(openssl rand -hex 32)
    elif [ -f /etc/mcp-oauth-proxy/.env ]; then
        echo "[+] Loading existing secrets from /etc/mcp-oauth-proxy/.env"
        source /etc/mcp-oauth-proxy/.env
    else
        echo "[+] Generating new secrets..."
        MCP_TOKEN=$(openssl rand -hex 32)
        CLIENT_ID=$(openssl rand -hex 16)
        CLIENT_SECRET=$(openssl rand -hex 32)
        TOKEN_SECRET=$(openssl rand -hex 32)
    fi
}

3 modes désormais explicites :

  1. Première install (.env absent) → génération
  2. Réinstall (.env présent) → tokens préservés (sécurité par défaut)
  3. Rotation explicite (--force-rotate-tokens) → régénération volontaire

Bonus : dry-run

Tant que je modifiais l’installer, j’ai ajouté un --dry-run qui montre ce qui serait fait sans rien modifier :

$ sudo ./install.sh --dry-run
[DRY-RUN] Would create directory /etc/mcp-oauth-proxy/
[DRY-RUN] Would copy oauth_proxy.py to /usr/local/lib/mcp-oauth-proxy/
[DRY-RUN] Would write systemd unit /etc/systemd/system/mcp-oauth-proxy.service
[DRY-RUN] Existing .env detected — tokens would be PRESERVED
[DRY-RUN] Would reload systemd and restart mcp-oauth-proxy.service

Utile pour vérifier qu’un upgrade ne va rien casser avant de l’exécuter en prod.

Tests reproductibles

Pour éviter une régression similaire, j’ai ajouté un script de test :

#!/bin/bash
# tests/test-idempotence.sh

# Première install
sudo ./install.sh
TOKEN1=$(grep MCP_TOKEN /etc/mcp-oauth-proxy/.env | cut -d= -f2)

# Seconde install (devrait préserver)
sudo ./install.sh
TOKEN2=$(grep MCP_TOKEN /etc/mcp-oauth-proxy/.env | cut -d= -f2)

if [ "$TOKEN1" != "$TOKEN2" ]; then
    echo "FAIL: tokens regenerated on second install"
    exit 1
fi

# Force rotate (devrait régénérer)
sudo ./install.sh --force-rotate-tokens
TOKEN3=$(grep MCP_TOKEN /etc/mcp-oauth-proxy/.env | cut -d= -f2)

if [ "$TOKEN3" = "$TOKEN1" ]; then
    echo "FAIL: --force-rotate-tokens didn't regenerate"
    exit 1
fi

echo "PASS"

Trois cas couverts. Si quelqu’un (futur moi inclus) modifie generate_or_load_secrets(), ce test attrape la régression.

Lessons learned

1. Tester sur un host pré-existant, pas seulement sur un fresh

C’est la première chose à faire pour valider l’idempotence d’un script. Snapshot VM, run install, snapshot, restore, run install à nouveau, compare l’état.

2. Les secrets méritent un traitement spécial

Tous les autres fichiers (config, scripts, units) peuvent être réécrits en confiance — leur valeur est dans le repo. Les secrets sont uniques et persistants par instance. Les écraser = casser quelque chose en aval.

Règle : tout secret généré par un installer doit avoir un mode “préserve si existe” par défaut, et un flag explicite pour régénérer.

3. Dry-run paye toujours

Le dev d’un mode --dry-run prend ~30 minutes. Le ROI est immédiat dès le premier sudo ./install.sh --dry-run sur un host de prod : tu vois exactement ce qui va se passer avant de t’engager.

Conclusion

Bug trouvé et fixé en v1.2.0 du mcp-installer. Tag GPG-signé, release GitHub publiée. Le dry-run est devenu pour moi une pratique systématique sur tous les scripts qui touchent à /etc/, /usr/local/, ou des secrets.

Le bug a duré ~3 semaines en prod sans qu’aucun tier ne s’en rende compte (j’étais le seul user). Mais si l’installer avait été utilisé par d’autres, ça aurait pu casser plusieurs déploiements en une fois. Le coût d’un fix tardif aurait été beaucoup plus élevé.

Conclusion banale mais vraie : un script “marche” jusqu’à ce qu’on le relance.