AUTH_ENCRYPTION_SECRET RotationΒΆ
AUTH_ENCRYPTION_SECRET is the key used to encrypt stored credentials in the ContextForge database (OAuth tokens, SSO secrets, tool/agent/LLM auth values). Unlike most configuration secrets β which take effect immediately on restart β rotating AUTH_ENCRYPTION_SECRET requires a one-time re-encryption pass over the database before the new key is used to start the gateway. This document covers how to do that safely.
βΉοΈ For most other ContextForge secrets (e.g.
JWT_SECRET_KEY,BASIC_AUTH_PASSWORD), rotation is as simple as updating the value in your config source and restarting the gateway.AUTH_ENCRYPTION_SECRETis the exception because it protects data at rest.πΌ Upgrading to a new release and seeing a startup failure or auth errors? You are in the right place. The sequence is: run this rotation first, then start the new gateway version. Do not start the new gateway before Step 5 is complete β doing so will make stored credentials permanently unreadable.
Rotation at a glanceΒΆ
π Before you start β read the right section for your situation:
- Using ContextForge as a Python package? Read the Python package section first and plan your rotation accordingly before following the steps below.
- Helm / Kubernetes, custom
MIN_SECRET_LENGTH, started the gateway with the wrong key, or rolling back a rotation? Check Special cases first β some scenarios require a different procedure before or instead of the standard steps.- Hit an error? Jump to Troubleshooting.
- Need deployment-specific commands? See the Appendix.
| Step | Action |
|---|---|
| 0a | Back up the database |
| 0b | Securely store the old encryption key |
| 1 | Stop the ContextForge gateway |
| 2 | Get the latest code or package |
| 3 | Generate a new strong key |
| 4 | Dry-run the rotation (recommended) |
| 5 | Execute the live data rotation |
| 6 | Start the ContextForge gateway |
| 7 | Confirm everything is running as expected |
What gets re-encryptedΒΆ
The rotation script touches exactly these columns β everything else is untouched:
EncryptionService path (v2:{...} format β OAuth/SSO credentials):
| Table | Column(s) | Type |
|---|---|---|
oauth_tokens | access_token, refresh_token | Direct AES ciphertext |
registered_oauth_clients | client_secret_encrypted, registration_access_token_encrypted | Direct AES ciphertext |
sso_providers | client_secret_encrypted | Direct AES ciphertext |
gateways | oauth_config | JSON β sensitive keys re-encrypted recursively |
servers | oauth_config | JSON β sensitive keys re-encrypted recursively |
a2a_agent_auth | oauth_config | JSON β sensitive keys re-encrypted recursively |
services_auth path (base64url(nonce+ciphertext) format β tool/agent/LLM credentials):
| Table | Column(s) | Type |
|---|---|---|
gateways | auth_value | AES-GCM blob (JSON-quoted scalar β see note below) |
tools | auth_value | AES-GCM blob |
a2a_agents | auth_value, auth_query_params | AES-GCM blob / JSON dict of blobs |
a2a_agent_auth | auth_value, auth_query_params | AES-GCM blob / JSON dict of blobs |
a2a_push_notification_configs | auth_token | AES-GCM blob |
gateways | auth_query_params | JSON dict of AES-GCM blobs |
llm_providers | api_key | AES-GCM blob |
Note β
gateways.auth_valuestorage format: this column ismapped_column(JSON)indb.py, so SQLAlchemy stores the blob JSON-quoted on disk (e.g.'"<base64url>"'). The rotation script handles this transparently via a dedicated helper that strips the outer JSON quotes on read and re-applies them on write. No special handling is needed from operators.
Tables that don't exist (optional features not deployed) are silently skipped. NULL values and plaintext values are also skipped. The script is idempotent β running it twice is safe; already-rotated values are detected and skipped.
PrerequisitesΒΆ
- Python environment with
uvavailable (uv --version) - Access to the configuration source your deployment reads from (
.envfile, Docker Compose environment block, Kubernetes Secret, Helm values, or your secret manager) - Access to the database (
DATABASE_URLβ SQLite file path or PostgreSQL connection string) - The current value of
AUTH_ENCRYPTION_SECRETβ retrieve it before stopping
Rotation sequenceΒΆ
Step 0 β Back up the database and securely store the old keyΒΆ
Back up the databaseΒΆ
π‘ Using your own processes, create a backup of the database before making any changes. This is your safety net β if anything goes wrong during rotation you can restore and retry with the correct keys. Keep the backup until you have confirmed the rotated gateway starts and all OAuth / SSO / tool auth flows work correctly.
Securely store the old encryption keyΒΆ
Record the current value of AUTH_ENCRYPTION_SECRET and store it securely (a secrets manager, a password vault, or an encrypted note). You will need it as --old-key in Step 5.
β οΈ Once Step 3 sets a new value, the old one is no longer in your config source. See the Appendix for deployment-specific retrieval commands.
Step 1 β Stop the ContextForge gatewayΒΆ
Stop the ContextForge gateway using whichever method your deployment uses, leaving the database running. No writes must happen to the database between key generation and rotation. See the Appendix for deployment-specific commands.
Step 2 β Get the latest code or packageΒΆ
You need the codebase or package that contains the rotation script (mcpgateway/scripts/migrate_enc_secret.py) available to run it.
β οΈ If upgrading: pull the new version's code or package now so you can run the rotation script. Do not deploy or start the new gateway image/process yet β that happens in Step 6, after the rotation is complete.
For git-based deployments: git pull origin main or check out your target version. For Docker / Kubernetes / Helm: run the script from a local checkout or pip install --upgrade mcp-contextforge-gateway on any machine that can reach the database, before the new image is deployed.
Step 3 β Generate a new strong keyΒΆ
Generate a strong new AUTH_ENCRYPTION_SECRET (β₯ 32 chars, high-entropy) and set it in your config source. The value must be in place before you run Step 5 β the script reads it as --new-key.
For repository-based deployments, make init-secrets generates a ready-to-use value in .env.secrets. For package consumers or environments without make, generate one with Python: python3 -c "import secrets; print(secrets.token_urlsafe(32))".
π Note the new value β you will pass it as
--new-keyin Step 5. See the Appendix for how to set the value in each deployment type.
Step 4 β Dry-run the rotation (recommended)ΒΆ
Reads every affected row and reports what would be re-encrypted. No writes are made. This ensures that no unforeseen issues occur with rotating the encrypted data before committing to the live run.
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key <old-key> \
--new-key <new-strong-key> \
--dry-run
Expected output:
============================================================
DRY RUN SUMMARY (no changes written):
Rows scanned : 12
Values migrated: 8
Values skipped : 4
Errors : 0
============================================================
β If
Errors > 0: some rows are encrypted with a key that is neither--old-keynor--new-key(a third key from a previous rotation). Stop and investigate before proceeding β do not run the live rotation until errors are 0.
Step 5 β Run the live rotationΒΆ
Re-encrypts all affected rows under the new key in a single database transaction. The transaction is rolled back automatically if any row fails β the database is always left in its original state on failure.
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key <old-key> \
--new-key <new-strong-key>
Non-default database URL (PostgreSQL etc.):
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key <old-key> \
--new-key <new-strong-key> \
--database-url postgresql+psycopg://user:pass@host/dbname
Environment variable fallback (useful in CI / secret managers):
OLD_AUTH_ENCRYPTION_SECRET=<old> \
NEW_AUTH_ENCRYPTION_SECRET=<new> \
uv run python3 -m mcpgateway.scripts.migrate_enc_secret
Expected output on success:
============================================================
ROTATION SUMMARY:
Rows scanned : 12
Values migrated: 8
Values skipped : 4
Errors : 0
============================================================
β
Rotation complete β 8 value(s) re-encrypted successfully.
| Exit code | Meaning |
|---|---|
0 | All rows rotated (or nothing to rotate). Proceed to Step 6. |
1 | One or more rows failed. Transaction rolled back. Check stderr before retrying. |
β οΈ Do not discard the old key or the database backup until Step 7 is complete. Exit code
0confirms the script completed without errors, but it does not verify that the gateway actually starts and serves traffic. Keep the old key and backup available until you have confirmed the gateway is healthy in Step 7.
Step 6 β Start the ContextForge gatewayΒΆ
The guardrail now passes (strong key in your config) and all database rows are encrypted under the new key. Start the gateway using whichever method your deployment uses. See the Appendix for deployment-specific commands.
Step 7 β Verify the rotation is completeΒΆ
Before discarding the old key or the database backup, confirm the gateway is serving traffic correctly:
- Check startup logs β no
SecurityConfigurationErrorshould appear. - Hit the gateway list endpoint and confirm HTTP 200:
- Spot-check that MCP Servers appear in the Admin UI.
- Confirm SSO / OAuth flows, tool auth, and agent credentials work correctly.
Only after these checks pass is it safe to discard the old AUTH_ENCRYPTION_SECRET and the database backup.
Using ContextForge as a Python packageΒΆ
If you embed ContextForge in your own application via pip install mcp-contextforge-gateway rather than running the server directly, the rotation script is still available as a module and can be driven programmatically or from a plain python invocation β no uv, make, or repository checkout required.
πΌ Upgrading the package and seeing a startup failure or auth errors?
AUTH_ENCRYPTION_SECRETis now required to be a strong secret (β₯ 32 chars, high-entropy). If your application previously set a weak value, you must: 1. Generate a strong new key (Step 1 below). 2. Run the rotation script to re-encrypt stored credentials (Steps 2β3 below). 3. UpdateAUTH_ENCRYPTION_SECRETto the new value in your config and restart.Do not update the config and restart first β the gateway will start but all stored credentials will be unreadable until the rotation script is run.
Why this path is differentΒΆ
When installed as a package:
uvandmakeare not available (they are dev tools, not runtime dependencies).- The
.envfile convention does not apply β your application owns the configuration surface. - You generate strong secrets with Python's
secretsmodule rather thanmake init-secrets. - The rotation script is invoked either as a CLI entry-point or imported directly.
Step-by-step for package consumersΒΆ
1. Generate a strong new key in Python:
Store the output wherever your application reads AUTH_ENCRYPTION_SECRET from β an environment variable, a secrets manager (HashiCorp Vault, AWS Secrets Manager, IBM Key Protect), or an injected config file. Keep the old key available until Step 3 completes.
2. Dry-run via the installed entry-point:
# If mcp-contextforge-gateway is installed in the active environment:
python -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_AUTH_ENCRYPTION_SECRET" \
--new-key "$NEW_AUTH_ENCRYPTION_SECRET" \
--database-url "$DATABASE_URL" \
--dry-run
3. Run the live rotation:
python -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_AUTH_ENCRYPTION_SECRET" \
--new-key "$NEW_AUTH_ENCRYPTION_SECRET" \
--database-url "$DATABASE_URL"
4. Invoke programmatically from your own code:
import sys
from mcpgateway.scripts.migrate_enc_secret import main
sys.argv = [
"migrate_enc_secret",
"--old-key", old_key,
"--new-key", new_key,
"--database-url", database_url,
]
main() # raises SystemExit(0) on success, SystemExit(1) on failure
Or, to capture the result without raising:
import subprocess, sys
result = subprocess.run(
[
sys.executable, "-m", "mcpgateway.scripts.migrate_enc_secret",
"--old-key", old_key,
"--new-key", new_key,
"--database-url", database_url,
],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"Secret rotation failed:\n{result.stderr}")
5. Update your application config and restart:
After a returncode == 0, update AUTH_ENCRYPTION_SECRET to the new value in whatever config surface your app uses, then restart the gateway process.
Using environment variables instead of CLI flagsΒΆ
The script reads OLD_AUTH_ENCRYPTION_SECRET and NEW_AUTH_ENCRYPTION_SECRET from the environment if the corresponding CLI flags are not passed β convenient when your secrets manager injects environment variables at runtime:
import os, subprocess, sys
os.environ["OLD_AUTH_ENCRYPTION_SECRET"] = old_key
os.environ["NEW_AUTH_ENCRYPTION_SECRET"] = new_key
os.environ["DATABASE_URL"] = database_url
result = subprocess.run(
[sys.executable, "-m", "mcpgateway.scripts.migrate_enc_secret"],
capture_output=True,
text=True,
)
β οΈ Clear
OLD_AUTH_ENCRYPTION_SECRETfrom the environment after the rotation completes β it is a live credential.
Special casesΒΆ
Never had any OAuth tokens / SSO / gateways with credentialsΒΆ
The dry-run will report Rows scanned: 0, Values migrated: 0. Still run the live rotation (Step 5) to confirm exit 0, but it is a no-op. Steps 3 and 6 are the only ones that matter for you.
Helm / Kubernetes deploymentsΒΆ
charts/mcp-stack/values.yaml ships with AUTH_ENCRYPTION_SECRET: "" (must be set explicitly). Provide the new strong value via your values-override.yaml or a Kubernetes Secret, then run the rotation as a pre-upgrade Job or init container before the gateway Deployment rolls over to the new version.
MIN_SECRET_LENGTH raised above 32ΒΆ
If you have MIN_SECRET_LENGTH=64 set in your configuration, make init-secrets will generate a token of β₯ 64 chars (the script respects the configured floor). Set that full value in your config source and pass it as --new-key. The rotation script validates --new-key against the absolute 32-char minimum only β the gateway will re-apply the higher floor at startup.
If you started the gateway with the wrong keyΒΆ
β This is a data-loss scenario. Any row written after the gateway started is encrypted under the new key. Any row still encrypted under the old key is now unreadable by the running gateway. You need to identify which rows each key owns before re-running the rotation. Use
--dry-runwith both key orders to see the error counts, then contact the team before proceeding.
Rolling back to a previous (weaker) key β --forceΒΆ
β οΈ Use only when you intentionally need to decrypt back to a key that does not meet the strength requirements (e.g. reverting a key rotation in a test environment, or undoing a rotation before re-doing it with a different key). After a
--forcerollback the database will contain credentials encrypted under a weak key β the gateway will refuse to start until a strong key is in place.
By default the script rejects --new-key values that are too short, known-weak, or low-entropy β the same guardrail the gateway enforces at startup. Pass --force to bypass all three checks:
# Roll back from a strong key to a previously-used weak key
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key <current-strong-key> \
--new-key <previous-weak-key> \
--force
# Re-encrypt in the opposite direction again (back to strong) β --force not needed
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key <previous-weak-key> \
--new-key <current-strong-key>
--force also works when both keys are weak (e.g. re-keying between two legacy short keys):
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key <weak-key-a> \
--new-key <weak-key-b> \
--force
A warning is always printed to stderr when --force is active:
--force can be combined with --dry-run to preview what would be re-encrypted before committing:
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key <strong-key> \
--new-key <weak-key> \
--force \
--dry-run
TroubleshootingΒΆ
| Error message | Cause | Fix |
|---|---|---|
SecurityConfigurationError: auth_encryption_secret: too short | Gateway started before generating a strong key | Stop gateway. Run Step 3, then Step 5, then restart. |
β --new-key is too short | Weak value passed as --new-key | Generate a strong value (see Step 3), pass it as --new-key. Or pass --force if a weak target key is intentional (rollback scenario). |
β --old-key and --new-key are identical | Both arguments have the same value | Verify you are using the old key as --old-key and the newly generated value as --new-key. |
Errors: N in dry-run output | Some rows encrypted with a third key | Identify previous key rotation history; re-run with the correct --old-key for each batch. |
AUTH_ENCRYPTION_SECRET: shell environment holds a weak/non-compliant value⦠| AUTH_ENCRYPTION_SECRET=changeme exported in shell, strong value already in .env | Run unset AUTH_ENCRYPTION_SECRET, then retry. |
| OAuth flows break after rotation | Rotation ran with wrong --old-key; rows not actually re-encrypted | Confirm exit code was 0 and Values migrated > 0. Re-run rotation with correct keys. |
| Tool / agent / LLM auth fails after rotation | services_auth blobs not re-encrypted (separate path from OAuth) | Same fix β re-run rotation with correct --old-key; confirm Values migrated > 0 for those tables. |
Appendix β Deployment-specific rotation commandsΒΆ
Self-contained command sequences for each deployment type. Substitute <old-key> and <new-key> with your actual values before running.
Docker ComposeΒΆ
# 0. Record the old key
OLD_KEY=$(docker inspect mcpgateway | python3 -c \
"import sys,json; envs=json.load(sys.stdin)[0]['Config']['Env']; \
print(next(e.split('=',1)[1] for e in envs if e.startswith('AUTH_ENCRYPTION_SECRET=')))")
# 1. Stop
docker compose down
# 2. Pull latest code
git pull origin main
# 3. Generate a new strong key and update compose config
NEW_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# Edit docker-compose.override.yml or .env to set AUTH_ENCRYPTION_SECRET=$NEW_KEY
# 4. Dry-run (from local checkout)
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" --new-key "$NEW_KEY" --dry-run
# 5. Live rotation
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY"
# 6. Start
docker compose up -d
Kubernetes (kubectl)ΒΆ
# 0. Record the old key from the Secret
OLD_KEY=$(kubectl get secret mcpgateway-secrets \
-o jsonpath='{.data.AUTH_ENCRYPTION_SECRET}' | base64 -d)
# 1. Stop β scale to zero
kubectl scale deployment mcpgateway --replicas=0
kubectl rollout status deployment/mcpgateway # wait for scale-down
# 2. Get latest code or package
git pull origin main # or: pip install --upgrade mcp-contextforge-gateway
# 3. Generate a new key and patch the Secret
NEW_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
kubectl create secret generic mcpgateway-secrets \
--from-literal=AUTH_ENCRYPTION_SECRET="$NEW_KEY" \
--dry-run=client -o yaml | kubectl apply -f -
# 4. Dry-run (connect to DB via port-forward or from a pod with DB access)
kubectl port-forward svc/postgres 5432:5432 &
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--database-url "postgresql+psycopg://user:pass@localhost:5432/mcpgateway" \
--dry-run
# 5. Live rotation
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--database-url "postgresql+psycopg://user:pass@localhost:5432/mcpgateway"
# 6. Restore replicas
kubectl scale deployment mcpgateway --replicas=2
kubectl rollout status deployment/mcpgateway
HelmΒΆ
# 0. Record the old key
OLD_KEY=$(kubectl get secret mcpgateway-secrets \
-o jsonpath='{.data.AUTH_ENCRYPTION_SECRET}' | base64 -d)
# 1. Scale to zero via Helm
helm upgrade mcpgateway ./charts/mcp-stack \
--reuse-values \
--set replicaCount=0
# 2. Pull latest chart + app
git pull origin main
# 3. Generate a new key and write it to your values override
NEW_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# In values-override.yaml set:
# auth:
# encryptionSecret: "<new-key>"
# 4. Dry-run
kubectl port-forward svc/postgres 5432:5432 &
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--database-url "postgresql+psycopg://user:pass@localhost:5432/mcpgateway" \
--dry-run
# 5. Live rotation
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--database-url "postgresql+psycopg://user:pass@localhost:5432/mcpgateway"
# 6. Roll out with the new key
helm upgrade mcpgateway ./charts/mcp-stack -f values-override.yaml
systemd (bare-metal)ΒΆ
# 0. Record the old key
OLD_KEY=$(grep AUTH_ENCRYPTION_SECRET /etc/mcpgateway/mcpgateway.env | cut -d= -f2-)
# 1. Stop the service
sudo systemctl stop mcpgateway
# 2. Pull latest code
cd /opt/mcpgateway
git pull origin main
# 3. Generate a new key and update the environment file
NEW_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
sudo sed -i "s|^AUTH_ENCRYPTION_SECRET=.*|AUTH_ENCRYPTION_SECRET=$NEW_KEY|" \
/etc/mcpgateway/mcpgateway.env
# 4. Dry-run
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--dry-run
# 5. Live rotation
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY"
# 6. Start the service
sudo systemctl start mcpgateway
sudo systemctl status mcpgateway
Python package (pip-installed)ΒΆ
# 0. Retrieve the old key from your secrets manager / environment
OLD_KEY="$AUTH_ENCRYPTION_SECRET"
# 1. Stop your application process (app-specific)
# 2. Install or upgrade to the latest package
pip install --upgrade mcp-contextforge-gateway
# 3. Generate a new strong key
NEW_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
# Store NEW_KEY in your secrets manager / config before proceeding
# 4. Dry-run
python -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--database-url "$DATABASE_URL" \
--dry-run
# 5. Live rotation
python -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--database-url "$DATABASE_URL"
# 6. Update AUTH_ENCRYPTION_SECRET to NEW_KEY in your config / secrets manager
# and restart your application
Programmatic rotation (call from your own Python code):
import secrets
import subprocess
import sys
old_key = "my-old-secret" # retrieved from your secrets manager
new_key = secrets.token_urlsafe(32) # generate once, store immediately
database_url = "postgresql+psycopg://user:pass@host/dbname"
# Dry-run first
result = subprocess.run(
[
sys.executable, "-m", "mcpgateway.scripts.migrate_enc_secret",
"--old-key", old_key,
"--new-key", new_key,
"--database-url", database_url,
"--dry-run",
],
capture_output=True,
text=True,
)
print(result.stdout)
if result.returncode != 0:
raise RuntimeError(f"Dry-run failed:\n{result.stderr}")
# Live rotation
result = subprocess.run(
[
sys.executable, "-m", "mcpgateway.scripts.migrate_enc_secret",
"--old-key", old_key,
"--new-key", new_key,
"--database-url", database_url,
],
capture_output=True,
text=True,
)
print(result.stdout)
if result.returncode != 0:
raise RuntimeError(f"Rotation failed:\n{result.stderr}")
# Update your config to use new_key, then restart the gateway
Local development (SQLite + .env)ΒΆ
# 0. Record the old key
OLD_KEY=$(grep AUTH_ENCRYPTION_SECRET .env | cut -d= -f2-)
# 1. Stop the dev server (Ctrl-C or kill the process)
# 2. Pull latest code
git pull origin main
# 3. Generate a new key and update .env
make init-secrets
NEW_KEY=$(grep AUTH_ENCRYPTION_SECRET .env.secrets | cut -d= -f2-)
sed -i "s|^AUTH_ENCRYPTION_SECRET=.*|AUTH_ENCRYPTION_SECRET=$NEW_KEY|" .env
# 4. Dry-run
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY" \
--dry-run
# 5. Live rotation
uv run python3 -m mcpgateway.scripts.migrate_enc_secret \
--old-key "$OLD_KEY" \
--new-key "$NEW_KEY"
# 6. Restart
make dev