Contents

Post-mortem: mcp-installer regenerated tokens on every rerun

The bug

mcp-installer is a bash script I wrote to automate the installation of mcp-oauth-proxy (FastAPI + nginx + systemd) on a new host. Standard workflow: clone, run, done.

Except when re-running the script on an already installed host (e.g. to update the version), I discovered an idempotence bug: all secrets were regenerated.

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

If you already have a .env with tokens in service, the installer overwrites them. All already-registered OAuth clients (Claude.ai in my case) end up with invalid credentials. Connection breaks.

Why it happened

The script used this logic:

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

The “generate then write” pattern is correct for first install. But on rerun, it completely ignores the existing .env.

It’s the classic mistake: the author (me) tested the script only on a fresh host. The “reinstall on existing host” mode was never tested.

Bug reproduction

$ 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...   ← DIFFERENT

Reproducible test in 30 seconds. Almost comical I didn’t catch it sooner.

The lesson: “idempotent” is a contract, not a hope

An installation script must be idempotent by default. Meaning: ./install.sh once, twice, five times → system in the same stable state.

I had this contract in my head. I didn’t have it in the code.

The fix: pre-flight checks + explicit force flag

Diagram Diagram

Fixed version in 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 explicit modes now:

  1. First install (.env absent) → generation
  2. Reinstall (.env present) → tokens preserved (security by default)
  3. Explicit rotation (--force-rotate-tokens) → intentional regeneration

Bonus: dry-run

While modifying the installer, I added a --dry-run that shows what would be done without changing anything:

$ 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

Useful to verify an upgrade won’t break anything before executing in prod.

Reproducible tests

To prevent a similar regression, I added a test script:

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

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

# Second install (should preserve)
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 (should regenerate)
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"

Three cases covered. If someone (future me included) modifies generate_or_load_secrets(), this test catches the regression.

Lessons learned

1. Test on a pre-existing host, not just fresh

That’s the first thing to do to validate script idempotence. VM snapshot, run install, snapshot, restore, run install again, compare state.

2. Secrets deserve special treatment

All other files (configs, scripts, units) can be confidently rewritten — their value is in the repo. Secrets are unique and persistent per instance. Overwriting them = breaking something downstream.

Rule: any secret generated by an installer must have a “preserve if exists” mode by default, and an explicit flag to regenerate.

3. Dry-run always pays

Developing a --dry-run mode takes ~30 minutes. ROI is immediate on the first sudo ./install.sh --dry-run on a prod host: you see exactly what’s about to happen before committing.

Conclusion

Bug found and fixed in mcp-installer v1.2.0. GPG-signed tag, GitHub release published. Dry-run has become for me a systematic practice on all scripts touching /etc/, /usr/local/, or secrets.

The bug lasted ~3 weeks in prod without anyone catching it (I was the only user). But if the installer had been used by others, it could have broken multiple deployments at once. Cost of late fix would have been much higher.

Banal but true conclusion: a script “works” until you rerun it.