Files
Cerbero-mcp/docs/superpowers/specs/2026-04-30-V2.0.0-unified-image-token-routing-design.md
T
AdrianoDev 9a137563e8 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>
2026-04-30 17:45:26 +02:00

563 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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