diff --git a/docs/superpowers/specs/2026-04-30-V2.0.0-unified-image-token-routing-design.md b/docs/superpowers/specs/2026-04-30-V2.0.0-unified-image-token-routing-design.md new file mode 100644 index 0000000..0a503b7 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-V2.0.0-unified-image-token-routing-design.md @@ -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 `) +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