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>
21 KiB
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
.envrende ovvio cosa è configurato. - DX dev: oggi serve
docker compose upanche per iterare. V2 punta auv run cerbero-mcpdiretto su laptop senza Docker. - Discovery API: senza Swagger l'unica fonte sulle tool è il codice.
/apidocsrende le tool esplorabili dal browser, e/openapi.jsonle 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.ymlminimo mantenuto per chi vuole usare Docker localmente;docker-compose.{prod,traefik,local}.ymleliminati.
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 dasrc/cerbero_mcp/)gateway/(Caddy + Caddyfile + landing page)secrets/(8 file JSON + 2 token file)docker/(7 Dockerfile separati, sostituiti daDockerfilein root)docker-compose.prod.yml,docker-compose.local.yml,docker-compose.traefik.ymlDEPLOYMENT.md(i contenuti ancora validi confluiscono inREADME.md)mcp_common.environment(resolver boot-time, sostituito daauth.pyruntime)mcp_common.env_validation(sostituito da Pydantic Settings)mcp_common.app_factory(boilerplate boot, integrato inserver.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-TimestampeX-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 inshutdown(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→/healthderibit,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
PORTda 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 cherequest.state.environmentsia settato correttamente.auth.py: confronto token usasecrets.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 taskget(deribit, testnet)in parallelo,_buildchiamato 1 sola volta.client_registry.py: chiavi diverse istanziano client diversi (deribit/testnet ≠ deribit/mainnet ≠ bybit/testnet).settings.py:.envvalido 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_TOKENcolpisceDERIBIT_URL_TESTNET - request con
Bearer MAINNET_TOKENcolpisceDERIBIT_URL_LIVE
- request con
- Stesso pattern per bybit, hyperliquid, alpaca.
- Macro e sentiment: request con bearer testnet o mainnet entrambe funzionanti, request senza bearer → 401.
- Swagger UI:
GET /apidocsritorna HTML con securityScheme BearerAuth presente. - OpenAPI:
GET /openapi.jsonritorna 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:
- Backup:
cp -r secrets/ secrets.v1.bak/. - 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))' - Compila
.env: mappa V1 → V2 (i campi nei JSON V1 hanno nomi leggermente diversi; vedi tabella in README V2 sezione "Migrazione"). - Aggiorna bot client:
- URL invariato (
/mcp-{exchange}/tools/{tool}non cambia) - Bearer cambia: dove prima usavi
core.tokenoobserver.token, ora usiTESTNET_TOKENoMAINNET_TOKENa seconda dell'ambiente desiderato per quella richiesta.
- URL invariato (
- Spegni V1:
docker compose down(vecchio compose). - Avvia V2:
docker compose up -d(nuovo compose minimo) + verificacurl /healthecurl /apidocs. - 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 (
slowapio 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.auditviene 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 -davvia 1 container che risponde su/healthentro 5 secondi - ✅
curl /apidocsrende 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