# 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