docs(spec): V2.0.0 unified image + token-based env routing

Spec architetturale per V2.0.0: collassa 7 immagini Docker (gateway Caddy
+ 6 servizi MCP) in una singola immagine multi-router. Switch
testnet/mainnet diventa runtime per-request via bearer token (TESTNET_TOKEN
/ MAINNET_TOKEN). Configurazione consolidata in singolo .env, secret JSON
eliminati. Swagger UI esposto a /apidocs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AdrianoDev
2026-04-30 17:45:26 +02:00
parent 7fa269de14
commit 9a137563e8
@@ -0,0 +1,562 @@
# Cerbero MCP — V2.0.0: Unified Image, Token-Based Environment Routing
**Branch:** `V2.0.0`
**Data spec:** 2026-04-30
**Autore:** Adriano
## Sommario esecutivo
Riscrittura architetturale di Cerbero MCP per ridurre superficie operativa e
semplificare la gestione di testnet/mainnet. Si passa da 7 immagini Docker
(gateway Caddy + 6 servizi MCP) a una **singola immagine** che ospita tutti
i router exchange in un unico processo FastAPI. La distinzione fra ambiente
testnet e mainnet, oggi decisa al boot del container tramite variabili di
override e flag nei secret JSON, viene spostata **a runtime per-request**:
il bearer token presentato dal client (`Authorization: Bearer <token>`)
determina quale endpoint upstream viene contattato per quella specifica
chiamata. Tutta la configurazione confluisce in un singolo file `.env`,
eliminando i secret JSON separati. Viene infine esposta documentazione
OpenAPI interattiva via Swagger UI all'indirizzo `/apidocs`.
## Motivazioni
- **Operatività**: 7 container, 8 secret file, 4 docker-compose overlay e un
gateway Caddy con plugin di rate-limit sono troppo per il volume di
traffico atteso. Un singolo container è sufficiente.
- **Flessibilità ambiente**: oggi un bot che vuole leggere mainnet e
scrivere testnet deve coordinare due deploy. Con il routing per-request
basta scegliere il bearer giusto per ogni chiamata.
- **Configurazione**: 8 secret JSON + 2 token file + variabili di override
in 4 compose file = stato distribuito difficile da auditare. Un singolo
`.env` rende ovvio cosa è configurato.
- **DX dev**: oggi serve `docker compose up` anche per iterare. V2 punta a
`uv run cerbero-mcp` diretto su laptop senza Docker.
- **Discovery API**: senza Swagger l'unica fonte sulle tool è il codice.
`/apidocs` rende le tool esplorabili dal browser, e `/openapi.json` le
rende leggibili a LLM senza bisogno del protocollo MCP completo.
## Decisioni di design
| # | Domanda | Decisione |
|---|---|---|
| 1 | Significato di "passare il token alla funzione" | Routing per-request via bearer: lo stesso container serve testnet e mainnet contemporaneamente, decide URL upstream a runtime |
| 2 | Granularità token | 2 token globali (`TESTNET_TOKEN`, `MAINNET_TOKEN`) validi per tutti gli exchange |
| 3 | ACL core/observer (read-only) | Eliminata. Protezione write rimane via leverage cap server-side e via firewall (Traefik su VPS) |
| 4 | Scope "single image" | Un'unica immagine, **un solo container** con multi-router interno (un processo Python) |
| 5 | Gateway L7 | Eliminato dal repo. Su VPS prod c'è Traefik gestito esternamente. In dev nessun gateway |
| 6 | Formato configurazione | Tutto in `.env`. Nessun JSON. La porta è in `.env` |
| 7 | Swagger | `/apidocs` ON, `/openapi.json` ON, `/redoc` OFF, `/docs` OFF |
| 8a | Dispatch exchange | Path-based: `/mcp-{exchange}/tools/{tool}` (backward-compat con bot V1) |
| 8b | Lifecycle client exchange | Cache lazy `(exchange, env) → client`, max 8 client (4 exchange × 2 env) + 2 client read-only (macro, sentiment) |
Decisioni esplicite anche su:
- **Macro e sentiment richiedono token valido**: anche le tool puramente
read-only passano dal middleware auth. Bearer assente o non riconosciuto
→ 401. Il valore del token (testnet o mainnet) è ignorato per macro e
sentiment perché non hanno endpoint testnet, ma uno dei due deve essere
presente.
- **Nessuna policy `ALLOW_MAINNET`**: la protezione contro uso accidentale
di mainnet è demandata a (a) custodia dei token (mainnet token solo a bot
autorizzati), (b) Traefik IP allowlist sul VPS, (c) leverage cap
server-side già esistente per ogni exchange.
- **`docker-compose.yml` minimo** mantenuto per chi vuole usare Docker
localmente; `docker-compose.{prod,traefik,local}.yml` eliminati.
## Architettura
### Stack runtime
```
Prod (VPS):
Traefik (TLS, allowlist) ──▶ container cerbero-mcp:9000
Dev (laptop):
uv run cerbero-mcp ──▶ http://localhost:9000
```
Nessun gateway nel repo. Traefik è gestito fuori da questo progetto, con
label aggiunte tramite override compose esterno. In dev FastAPI è esposto
direttamente via uvicorn.
### Struttura sorgenti
```
Cerbero_mcp/
├── pyproject.toml # singolo package "cerbero-mcp"
├── uv.lock
├── Dockerfile # multi-stage builder + runtime slim
├── docker-compose.yml # minimo: 1 servizio, env_file: .env
├── .env.example # template completo, versionato
├── .gitignore # .env escluso
├── README.md # riscritto V2
├── docs/
│ └── superpowers/
│ ├── specs/
│ │ ├── 2026-04-27-cot-report-design.md (storico)
│ │ └── 2026-04-30-V2.0.0-unified-image-...-design.md
│ └── plans/
│ ├── 2026-04-27-cot-report.md (storico)
│ └── 2026-04-30-V2.0.0-unified-image-...-plan.md
├── src/cerbero_mcp/
│ ├── __init__.py
│ ├── __main__.py # entrypoint: uvicorn.run(app, ...)
│ ├── settings.py # Pydantic Settings per .env
│ ├── auth.py # bearer → environment
│ ├── server.py # build_app(): FastAPI + middleware + handlers + swagger
│ ├── client_registry.py # cache lazy {(exchange, env): client}
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── deribit.py
│ │ ├── bybit.py
│ │ ├── hyperliquid.py
│ │ ├── alpaca.py
│ │ ├── macro.py
│ │ └── sentiment.py
│ ├── exchanges/
│ │ ├── deribit/{client.py, tools.py, leverage_cap.py}
│ │ ├── bybit/{client.py, tools.py, leverage_cap.py}
│ │ ├── hyperliquid/{client.py, tools.py, leverage_cap.py}
│ │ ├── alpaca/{client.py, tools.py, leverage_cap.py}
│ │ ├── macro/{client.py, tools.py}
│ │ └── sentiment/{client.py, tools.py}
│ └── common/
│ ├── indicators.py
│ ├── options.py
│ ├── microstructure.py
│ ├── stats.py
│ ├── http.py
│ ├── audit.py
│ ├── logging.py
│ └── errors.py # error envelope
├── tests/
│ ├── unit/
│ ├── integration/
│ └── smoke/
└── scripts/
└── build-push.sh # build di 1 sola immagine
```
**Cosa viene eliminato:**
- `services/` (intera struttura monorepo a 7 sub-package, sostituita da
`src/cerbero_mcp/`)
- `gateway/` (Caddy + Caddyfile + landing page)
- `secrets/` (8 file JSON + 2 token file)
- `docker/` (7 Dockerfile separati, sostituiti da `Dockerfile` in root)
- `docker-compose.prod.yml`, `docker-compose.local.yml`,
`docker-compose.traefik.yml`
- `DEPLOYMENT.md` (i contenuti ancora validi confluiscono in `README.md`)
- `mcp_common.environment` (resolver boot-time, sostituito da `auth.py`
runtime)
- `mcp_common.env_validation` (sostituito da Pydantic Settings)
- `mcp_common.app_factory` (boilerplate boot, integrato in `server.py`)
**Cosa resta uguale:**
- Path layout `/mcp-{exchange}/tools/{tool}` (backward-compat con bot V1)
- Tool MCP individuali: firme, response shape, error envelope, header
`X-Data-Timestamp` e `X-Duration-Ms`
- Logica indicatori quantitativi (`indicators`, `options`,
`microstructure`, `stats`)
- Healthcheck `/health` (formato identico)
- Leverage cap server-side per exchange
- Tool MCP-bridge (se in uso) preservato in `common/mcp_bridge.py`
### Flusso request
```
1. Bot HTTP request
POST /mcp-deribit/tools/place_order
Authorization: Bearer tk_test_xxx
{ "symbol":"BTC-PERPETUAL", "side":"buy", "qty": 0.1 }
2. Middleware AuthBearer (auth.py)
- whitelist path: /apidocs, /openapi.json, /health → bypass
- estrae bearer
- confronta con settings.testnet_token / settings.mainnet_token
- match testnet → request.state.environment = "testnet"
- match mainnet → request.state.environment = "mainnet"
- nessun match → 401 UNAUTHORIZED
3. Router deribit (routers/deribit.py)
- FastAPI valida body con Pydantic schema
- dependency get_env(request) -> "testnet"|"mainnet"
- dependency get_client(env) -> DeribitClient
4. Client Registry (client_registry.py)
- chiave (exchange="deribit", env="testnet")
- cache hit → return; miss → costruisce client lazy + auth iniziale + cache
5. Tool impl (exchanges/deribit/tools.py)
- leverage_cap.enforce(qty, max_leverage)
- client.place_order(...)
- response shape standard con data_timestamp, request_id
6. Response middleware
- X-Duration-Ms header
- data_timestamp injection se mancante
7. 200 JSON
```
### Auth
```python
# src/cerbero_mcp/auth.py (esempio sintetico)
from fastapi import Request, HTTPException, status
from typing import Literal
Environment = Literal["testnet", "mainnet"]
WHITELIST_PATHS = {"/health", "/apidocs", "/openapi.json"}
async def auth_middleware(request: Request, call_next):
if request.url.path in WHITELIST_PATHS:
return await call_next(request)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(401, "missing bearer token")
token = auth[len("Bearer "):].strip()
settings = request.app.state.settings
if token == settings.testnet_token.get_secret_value():
request.state.environment = "testnet"
elif token == settings.mainnet_token.get_secret_value():
request.state.environment = "mainnet"
else:
raise HTTPException(401, "invalid token")
return await call_next(request)
```
Confronto token con `secrets.compare_digest` per evitare timing attack.
### Client registry
```python
# src/cerbero_mcp/client_registry.py (sintesi)
class ClientRegistry:
def __init__(self, settings):
self._settings = settings
self._clients: dict[tuple[str, Environment], Any] = {}
self._locks: dict[tuple[str, Environment], asyncio.Lock] = defaultdict(asyncio.Lock)
async def get(self, exchange: str, env: Environment) -> Any:
key = (exchange, env)
if key in self._clients:
return self._clients[key]
async with self._locks[key]:
if key in self._clients:
return self._clients[key]
client = await self._build(exchange, env)
self._clients[key] = client
return client
async def aclose(self):
for c in self._clients.values():
await c.aclose()
```
- Costruzione **lazy** al primo uso → boot rapido, no auth verso exchange
non usati
- **Lock per chiave** evita doppia istanziazione in caso di race
- Macro e sentiment usano stesso client per testnet e mainnet (l'env è
ignorato), ma per uniformità API ricevono ugualmente la chiave
`(exchange, env)`
- Lifespan FastAPI: registry creato in `startup`, chiuso in `shutdown`
(chiude HTTP pool, websocket eventuali, sessioni)
### Configurazione: `.env`
Singola sorgente di verità, letta da Pydantic Settings al boot. File
versionato come `.env.example` con placeholder vuoti; `.env` reale
gitignored.
```bash
# SERVER
HOST=0.0.0.0
PORT=9000
LOG_LEVEL=info
# AUTH
TESTNET_TOKEN=
MAINNET_TOKEN=
# DERIBIT
DERIBIT_CLIENT_ID=
DERIBIT_CLIENT_SECRET=
DERIBIT_URL_LIVE=https://www.deribit.com/api/v2
DERIBIT_URL_TESTNET=https://test.deribit.com/api/v2
DERIBIT_MAX_LEVERAGE=3
# BYBIT
BYBIT_API_KEY=
BYBIT_API_SECRET=
BYBIT_URL_LIVE=https://api.bybit.com
BYBIT_URL_TESTNET=https://api-testnet.bybit.com
BYBIT_MAX_LEVERAGE=3
# HYPERLIQUID
HYPERLIQUID_WALLET_ADDRESS=
HYPERLIQUID_API_WALLET_ADDRESS=
HYPERLIQUID_PRIVATE_KEY=
HYPERLIQUID_URL_LIVE=https://api.hyperliquid.xyz
HYPERLIQUID_URL_TESTNET=https://api.hyperliquid-testnet.xyz
HYPERLIQUID_MAX_LEVERAGE=3
# ALPACA
ALPACA_API_KEY_ID=
ALPACA_SECRET_KEY=
ALPACA_URL_LIVE=https://api.alpaca.markets
ALPACA_URL_TESTNET=https://paper-api.alpaca.markets
ALPACA_MAX_LEVERAGE=1
# MACRO
FRED_API_KEY=
FINNHUB_API_KEY=
# SENTIMENT
CRYPTOPANIC_KEY=
LUNARCRUSH_KEY=
```
Pydantic Settings con `SecretStr` per i valori sensibili evita leak nei
log/repr. `extra="ignore"` ammette env aggiuntive (variabili di sistema,
Docker) senza crash. Validation fail-fast al boot.
### Swagger / OpenAPI
```python
app = FastAPI(
title="Cerbero MCP",
version="2.0.0",
description="Multi-exchange MCP server. Bearer token decides environment (testnet/mainnet).",
docs_url="/apidocs",
redoc_url=None,
openapi_url="/openapi.json",
swagger_ui_parameters={
"persistAuthorization": True,
"displayRequestDuration": True,
"filter": True,
"tryItOutEnabled": True,
"tagsSorter": "alpha",
"operationsSorter": "alpha",
},
)
```
Aggiunto `securityScheme = BearerAuth` su tutti gli endpoint sotto `/mcp-*`.
Click su Authorize in Swagger → input bearer → tutte le richieste "Try it
out" mandano il header. Cambio token = cambio ambiente senza ricaricare.
Tag organizzati per exchange:
- `system``/health`
- `deribit`, `bybit`, `hyperliquid`, `alpaca`, `macro`, `sentiment` → tool
rispettive
Ogni request body Pydantic include `examples=[...]` con almeno un esempio
realistico. Per response shape complesse (gamma profile, orderbook
imbalance) anche le response Pydantic includono examples.
### Dockerfile e immagine
```dockerfile
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential curl && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir "uv>=0.5,<0.7"
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src ./src
RUN uv sync --frozen --no-dev
FROM python:3.11-slim AS runtime
LABEL org.opencontainers.image.title="cerbero-mcp" \
org.opencontainers.image.version="2.0.0"
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH" \
HOST=0.0.0.0 \
PORT=9000 \
PYTHONUNBUFFERED=1
RUN useradd -m -u 1000 app && chown -R app:app /app
USER app
EXPOSE 9000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9000\")}/health', timeout=3).close()"
CMD ["cerbero-mcp"]
```
- 1 sola immagine `cerbero-mcp:2.0.0` (+ `:latest`)
- Build attesa: ~2-3 min (vs ~12 min × 7 immagini in V1)
- Image size attesa: ~200 MB
- Non-root user `app:1000`
- Healthcheck legge `PORT` da env (rispetta override `.env`)
### docker-compose.yml minimo
```yaml
services:
cerbero-mcp:
image: cerbero-mcp:2.0.0
build: .
container_name: cerbero-mcp
ports:
- "${PORT:-9000}:${PORT:-9000}"
env_file: .env
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c",
"import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\",\"9000\")}/health', timeout=3).close()"]
interval: 30s
timeout: 5s
retries: 3
```
Su VPS si estende con override esterno per applicare label Traefik (file
non versionato in questo repo).
## Errori
| Caso | Status | Code |
|---|---|---|
| Manca header Authorization | 401 | UNAUTHORIZED |
| Token non in `{testnet, mainnet}` | 401 | UNAUTHORIZED |
| Body invalido (Pydantic) | 422 | INVALID_INPUT |
| Exchange upstream 5xx | 502 | UPSTREAM_ERROR |
| Rate limit upstream | 429 | RATE_LIMIT |
| Eccezione non gestita | 500 | UNHANDLED_EXCEPTION |
Error envelope identico a V1: campi `error.{type, code, message,
retryable, suggested_fix?, details?}`, `request_id`, `data_timestamp`.
## Test plan
**Unit:**
- `auth.py`: 4 casi (no header, header malformato, token testnet, token
mainnet, token invalido). Verifica che `request.state.environment` sia
settato correttamente.
- `auth.py`: confronto token usa `secrets.compare_digest` (verifica con
test che attivi entrambi i rami).
- `auth.py`: path whitelist (`/health`, `/apidocs`, `/openapi.json`)
bypassano il middleware.
- `client_registry.py`: concorrenza — 10 task `get(deribit, testnet)` in
parallelo, `_build` chiamato 1 sola volta.
- `client_registry.py`: chiavi diverse istanziano client diversi
(deribit/testnet ≠ deribit/mainnet ≠ bybit/testnet).
- `settings.py`: `.env` valido carica senza errori; campo mandatory
mancante solleva ValidationError al boot.
- `common/`: tutti i test esistenti su indicators, options,
microstructure, stats migrano 1:1.
- Per ogni `exchanges/{exchange}/tools.py`: tool con stub client
restituisce response shape attesa (test esistenti V1 migrati).
**Integration:**
- Stub HTTP che intercetta richieste verso URL deribit:
- request con `Bearer TESTNET_TOKEN` colpisce `DERIBIT_URL_TESTNET`
- request con `Bearer MAINNET_TOKEN` colpisce `DERIBIT_URL_LIVE`
- Stesso pattern per bybit, hyperliquid, alpaca.
- Macro e sentiment: request con bearer testnet o mainnet entrambe
funzionanti, request senza bearer → 401.
- Swagger UI: `GET /apidocs` ritorna HTML con securityScheme BearerAuth
presente.
- OpenAPI: `GET /openapi.json` ritorna schema valido OpenAPI 3.1 con tag
per ogni exchange.
**Smoke (post-deploy):**
```bash
curl -s http://localhost:9000/health
curl -s http://localhost:9000/apidocs | grep -q "Cerbero MCP"
curl -s -H "Authorization: Bearer $TESTNET_TOKEN" \
http://localhost:9000/mcp-deribit/tools/get_ticker \
-d '{"instrument": "BTC-PERPETUAL"}'
```
## Migrazione da V1
Per chi è in produzione su V1:
1. **Backup**: `cp -r secrets/ secrets.v1.bak/`.
2. **Genera token V2**:
```bash
python -c 'import secrets; print("TESTNET_TOKEN=" + secrets.token_urlsafe(32))'
python -c 'import secrets; print("MAINNET_TOKEN=" + secrets.token_urlsafe(32))'
```
3. **Compila `.env`**: mappa V1 → V2 (i campi nei JSON V1 hanno nomi
leggermente diversi; vedi tabella in README V2 sezione "Migrazione").
4. **Aggiorna bot client**:
- URL invariato (`/mcp-{exchange}/tools/{tool}` non cambia)
- Bearer cambia: dove prima usavi `core.token` o `observer.token`, ora
usi `TESTNET_TOKEN` o `MAINNET_TOKEN` a seconda dell'ambiente
desiderato per quella richiesta.
5. **Spegni V1**: `docker compose down` (vecchio compose).
6. **Avvia V2**: `docker compose up -d` (nuovo compose minimo) +
verifica `curl /health` e `curl /apidocs`.
7. **Rollback** disponibile pullando i tag immagine V1 (`cerbero-mcp-*:1.x`)
se necessario, ma .env e secrets/ sono incompatibili tra V1 e V2 —
tenere backup separati.
## Pulizia documentazione
| File | V1 | Azione V2 |
|---|---|---|
| `README.md` | descrive 6 servizi + Caddy + 8 secret + build push 8 immagini | Riscritto da zero |
| `DEPLOYMENT.md` | runbook 7 immagini, gateway Caddy, allowlist Caddy, deploy no-clone | **Eliminato**. Contenuti utili in `README.md` |
| `docs/superpowers/specs/2026-04-27-cot-report-design.md` | spec feature passata | Mantenuto (storico) |
| `docs/superpowers/plans/2026-04-27-cot-report.md` | plan feature passata | Mantenuto (storico) |
| `docs/superpowers/specs/2026-04-30-V2.0.0-...-design.md` | (nuovo) | Creato |
| `docs/superpowers/plans/2026-04-30-V2.0.0-...-plan.md` | (nuovo) | Creato dopo writing-plans |
Razionale eliminazione `DEPLOYMENT.md`: 16 KB di doc su build-push 8
immagini, gateway Caddy, secret mounts, IP allowlist Caddy, deploy
no-clone — tutto obsoleto in V2. Le 30 righe ancora valide (smoke test,
rollback Watchtower) sono integrate nella sezione "Deploy" del nuovo
`README.md`.
## Out of scope
Per evitare scope creep nello stesso sprint, restano fuori:
- **HSTS / security headers** custom: in V1 li gestiva Caddy. In V2 si
delegano a Traefik su VPS (gestito esternamente). Aggiunta a livello
applicativo non in V2.
- **Rate limit applicativo** (`slowapi` o simile): demandato a Traefik.
- **Metriche Prometheus**: rinviate a iterazione successiva.
- **Token rotation automatica**: fuori scope V2. Rotation manuale
modificando `.env` + restart container.
- **Telemetria audit trail**: `mcp_common.audit` viene preservato, ma
evoluzioni (struttura log, sink) rinviate.
- **Multi-account per exchange**: V2 supporta 1 account per exchange. Più
account = future iterazione.
## Rischi e mitigazioni
| Rischio | Probabilità | Mitigazione |
|---|---|---|
| Bug auth → token testnet finisce su URL mainnet | Bassa, alto impatto | Test integration con stub HTTP per ogni exchange; leverage cap server-side resta secondo livello di difesa |
| Cache `client_registry` non rilascia connessioni → leak fd | Media | Lifespan FastAPI chiama `aclose()` su shutdown; healthcheck monitora processo |
| Boot fail per env var mancante in `.env` | Alta in dev | Pydantic Settings fail-fast con messaggio chiaro; `.env.example` versionato |
| Migrazione V1→V2 disallineata bot | Media | Path API invariato; documentare mapping V1→V2 in README |
| Concorrenza: prima request mainnet e testnet sullo stesso exchange in parallelo | Media | Lock per chiave nel registry impedisce race su `_build` |
| Image size cresce inattesa | Bassa | Multi-stage slim, base python:3.11-slim, no dipendenze inutili |
## Criteri di successo
- ✅ `docker compose up -d` avvia 1 container che risponde su `/health`
entro 5 secondi
- ✅ `curl /apidocs` rende Swagger UI navigabile
- ✅ Bot V1 funziona con cambio bearer e basta (path identici)
- ✅ Stesso bot può alternare testnet e mainnet su request consecutive
cambiando solo bearer
- ✅ Tutti i test V1 migrati passano in V2
- ✅ Tempo di build immagine ridotto da ~12 min a ~3 min
- ✅ `services/`, `gateway/`, `secrets/`, `docker/`, `DEPLOYMENT.md`,
3 docker-compose overlay rimossi dal repo