Files
Cerbero-mcp/docs/superpowers/specs/2026-04-30-V2.0.0-unified-image-token-routing-design.md
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

21 KiB
Raw Permalink Blame History

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

# 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

# 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.

# 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

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

# 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

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):

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:
    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