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>
This commit is contained in:
@@ -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 <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
|
||||||
Reference in New Issue
Block a user