# Cerbero MCP V2.0.0 — Unified Image, Token-Based Env Routing — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Collassare 7 immagini Docker in una unica, sostituire la risoluzione testnet/mainnet boot-time con routing per-request via bearer token, consolidare tutta la configurazione in `.env` ed esporre Swagger UI a `/apidocs`. **Architecture:** Singolo package Python `cerbero_mcp` in `src/`. Boot legge `.env` via Pydantic Settings, costruisce un app FastAPI con un router per exchange (`/mcp-{exchange}/tools/{tool}`), middleware bearer-auth che setta `request.state.environment` da `TESTNET_TOKEN`/`MAINNET_TOKEN`, e `ClientRegistry` lazy che mantiene un client per coppia `(exchange, env)`. Swagger UI esposto a `/apidocs` con securityScheme BearerAuth. Build Docker singola, deploy con `docker compose up -d`. Su VPS Traefik gestisce TLS/allowlist esternamente. **Tech Stack:** Python 3.11, FastAPI, uvicorn, pydantic-settings, httpx, uv (package manager), pytest, pytest-asyncio, pytest-httpx, ruff, mypy, Docker. --- ## Spec di riferimento `docs/superpowers/specs/2026-04-30-V2.0.0-unified-image-token-routing-design.md` ## Pre-requisiti - Branch `V2.0.0` già creato e attivo - `git status` pulito (solo lo spec committato) - `uv >= 0.5` installato - Docker installato (per task finali di build) --- # PHASE 0 — Bootstrap struttura nuova L'obiettivo di questa phase è avere una nuova struttura `src/cerbero_mcp/` che convive con la vecchia `services/` finché non migrate tutto il codice. La vecchia struttura viene rimossa solo nelle phase finali. ## Task 0.1: Aggiornare `pyproject.toml` per singolo package **Files:** - Modify: `pyproject.toml` - [ ] **Step 1: Sostituisci interamente `pyproject.toml`** Sovrascrivi con questo contenuto (rimuove workspace, aggiunge `[project]`): ```toml [project] name = "cerbero-mcp" version = "2.0.0" description = "Unified multi-exchange MCP server with token-based testnet/mainnet routing" requires-python = ">=3.11" authors = [{ name = "Adriano", email = "adrianodalpastro@tielogic.com" }] dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.32", "pydantic>=2.9", "pydantic-settings>=2.6", "httpx>=0.27", "python-json-logger>=2.0", "websockets>=13", "numpy>=1.26", "scipy>=1.13", "statsmodels>=0.14", "pandas>=2.2", "pybit>=5.7", "alpaca-py>=0.30", "hyperliquid-python-sdk>=0.6", ] [project.scripts] cerbero-mcp = "cerbero_mcp.__main__:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/cerbero_mcp"] [tool.ruff] line-length = 100 target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "W", "UP", "B", "SIM"] ignore = ["E501", "E741"] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = [ "fastapi.Depends", "fastapi.Query", "fastapi.Body", "fastapi.Header", "fastapi.Path", "fastapi.Cookie", "fastapi.Form", "fastapi.File", "fastapi.Security", ] [tool.ruff.lint.per-file-ignores] "**/test_*.py" = ["B008"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] addopts = "--import-mode=importlib" [tool.mypy] python_version = "3.11" strict = false warn_return_any = true warn_unused_ignores = true warn_redundant_casts = true check_untyped_defs = true ignore_missing_imports = true [[tool.mypy.overrides]] module = ["pybit.*", "alpaca.*", "hyperliquid.*", "pythonjsonlogger.*"] ignore_missing_imports = true [dependency-groups] dev = [ "pytest>=9.0.3", "pytest-asyncio>=1.3.0", "pytest-httpx>=0.36.2", "mypy>=1.13", "ruff>=0.5,<0.6", ] ``` - [ ] **Step 2: Verifica con `uv sync` (lock pulito)** ```bash rm -f uv.lock uv sync ``` Expected: nessun errore, `.venv/` aggiornato, `uv.lock` rigenerato. - [ ] **Step 3: Commit** ```bash git add pyproject.toml uv.lock git commit -m "chore(V2): pyproject singolo package cerbero-mcp, rimosso workspace" ``` --- ## Task 0.2: Creare scheletro `src/cerbero_mcp/` **Files:** - Create: `src/cerbero_mcp/__init__.py` - Create: `src/cerbero_mcp/__main__.py` - Create: `src/cerbero_mcp/common/__init__.py` - Create: `src/cerbero_mcp/exchanges/__init__.py` - Create: `src/cerbero_mcp/routers/__init__.py` - Create: `tests/__init__.py` - Create: `tests/unit/__init__.py` - Create: `tests/integration/__init__.py` - [ ] **Step 1: Crea le directory e file vuoti** ```bash mkdir -p src/cerbero_mcp/{common,exchanges,routers} tests/{unit,integration,smoke} touch src/cerbero_mcp/__init__.py touch src/cerbero_mcp/common/__init__.py touch src/cerbero_mcp/exchanges/__init__.py touch src/cerbero_mcp/routers/__init__.py touch tests/__init__.py tests/unit/__init__.py tests/integration/__init__.py ``` - [ ] **Step 2: Crea `src/cerbero_mcp/__main__.py` placeholder funzionante** ```python """Entrypoint cerbero-mcp.""" from __future__ import annotations def main() -> None: raise NotImplementedError("server da implementare nelle phase successive") if __name__ == "__main__": main() ``` - [ ] **Step 3: Verifica console script registrato** ```bash uv sync uv run cerbero-mcp 2>&1 || true ``` Expected: NotImplementedError sollevato (script registrato correttamente). - [ ] **Step 4: Commit** ```bash git add src/ tests/ git commit -m "chore(V2): scheletro src/cerbero_mcp + tests/" ``` --- # PHASE 1 — Settings e configurazione ## Task 1.1: `.env.example` consolidato **Files:** - Create: `.env.example` - Modify: `.gitignore` - [ ] **Step 1: Scrivi `.env.example`** ```bash # ============================================================ # CERBERO MCP — V2.0.0 # Copy to .env and fill in values. .env is gitignored. # Generate tokens: python -c 'import secrets; print(secrets.token_urlsafe(32))' # ============================================================ # ─── SERVER ───────────────────────────────────────────────── HOST=0.0.0.0 PORT=9000 LOG_LEVEL=info # ─── AUTH — token bearer per env routing ────────────────── # Bot manda Authorization: Bearer : # - TESTNET_TOKEN → request va a base_url_testnet # - MAINNET_TOKEN → request va a base_url_live TESTNET_TOKEN= MAINNET_TOKEN= # ─── EXCHANGE — 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 # ─── EXCHANGE — 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 # ─── EXCHANGE — 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 # ─── EXCHANGE — 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 # ─── DATA PROVIDERS — MACRO ─────────────────────────────── FRED_API_KEY= FINNHUB_API_KEY= # ─── DATA PROVIDERS — SENTIMENT ─────────────────────────── CRYPTOPANIC_KEY= LUNARCRUSH_KEY= ``` - [ ] **Step 2: Aggiorna `.gitignore`** Aggiungi (se non presente): ``` .env secrets/ ``` Rimuovi righe legate a strutture V1 obsolete se presenti (`docker-compose.local.yml` esiste già committato, ma `.gitignore` resta valido). - [ ] **Step 3: Commit** ```bash git add .env.example .gitignore git commit -m "chore(V2): .env.example consolidato, .env gitignored" ``` --- ## Task 1.2: Test Pydantic Settings **Files:** - Create: `tests/unit/test_settings.py` - [ ] **Step 1: Scrivi i test** ```python from __future__ import annotations import pytest from pydantic import ValidationError def _minimal_env(**overrides) -> dict: base = { "TESTNET_TOKEN": "t_test_123", "MAINNET_TOKEN": "t_live_456", "DERIBIT_CLIENT_ID": "id", "DERIBIT_CLIENT_SECRET": "secret", "DERIBIT_URL_LIVE": "https://www.deribit.com/api/v2", "DERIBIT_URL_TESTNET": "https://test.deribit.com/api/v2", "BYBIT_API_KEY": "k", "BYBIT_API_SECRET": "s", "BYBIT_URL_LIVE": "https://api.bybit.com", "BYBIT_URL_TESTNET": "https://api-testnet.bybit.com", "HYPERLIQUID_WALLET_ADDRESS": "0xabc", "HYPERLIQUID_API_WALLET_ADDRESS": "0xdef", "HYPERLIQUID_PRIVATE_KEY": "0x123", "HYPERLIQUID_URL_LIVE": "https://api.hyperliquid.xyz", "HYPERLIQUID_URL_TESTNET": "https://api.hyperliquid-testnet.xyz", "ALPACA_API_KEY_ID": "k", "ALPACA_SECRET_KEY": "s", "ALPACA_URL_LIVE": "https://api.alpaca.markets", "ALPACA_URL_TESTNET": "https://paper-api.alpaca.markets", "FRED_API_KEY": "x", "FINNHUB_API_KEY": "y", "CRYPTOPANIC_KEY": "z", "LUNARCRUSH_KEY": "w", } base.update(overrides) return base def test_settings_load_minimal(monkeypatch): for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) monkeypatch.setenv("PORT", "9123") from cerbero_mcp.settings import Settings s = Settings() assert s.port == 9123 assert s.host == "0.0.0.0" assert s.testnet_token.get_secret_value() == "t_test_123" assert s.mainnet_token.get_secret_value() == "t_live_456" assert s.deribit.url_testnet.endswith("test.deribit.com/api/v2") assert s.bybit.max_leverage == 3 assert s.alpaca.max_leverage == 1 def test_settings_missing_token_fails(monkeypatch): env = _minimal_env() env.pop("TESTNET_TOKEN") for k, v in env.items(): monkeypatch.setenv(k, v) monkeypatch.delenv("TESTNET_TOKEN", raising=False) from cerbero_mcp.settings import Settings with pytest.raises(ValidationError): Settings() def test_settings_extras_ignored(monkeypatch): for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) monkeypatch.setenv("UNRELATED_VAR", "ignored") from cerbero_mcp.settings import Settings s = Settings() assert s.testnet_token.get_secret_value() == "t_test_123" def test_settings_secret_str_no_leak(monkeypatch): for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) from cerbero_mcp.settings import Settings s = Settings() assert "t_test_123" not in repr(s) assert "t_live_456" not in repr(s) ``` - [ ] **Step 2: Verifica fail (modulo non esiste ancora)** ```bash uv run pytest tests/unit/test_settings.py -v ``` Expected: ImportError su `cerbero_mcp.settings`. - [ ] **Step 3: Implementa `src/cerbero_mcp/settings.py`** ```python """Pydantic Settings: legge .env e variabili d'ambiente.""" from __future__ import annotations from pydantic import Field, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict class _Sub(BaseSettings): """Base per sub-settings, condivide model_config con env_file.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) class DeribitSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="DERIBIT_", extra="ignore", ) client_id: str client_secret: SecretStr url_live: str url_testnet: str max_leverage: int = 3 class BybitSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="BYBIT_", extra="ignore", ) api_key: str api_secret: SecretStr url_live: str url_testnet: str max_leverage: int = 3 class HyperliquidSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="HYPERLIQUID_", extra="ignore", ) wallet_address: str api_wallet_address: str private_key: SecretStr url_live: str url_testnet: str max_leverage: int = 3 class AlpacaSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="ALPACA_", extra="ignore", ) api_key_id: str secret_key: SecretStr url_live: str url_testnet: str max_leverage: int = 1 class MacroSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) fred_api_key: SecretStr finnhub_api_key: SecretStr class SentimentSettings(_Sub): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", ) cryptopanic_key: SecretStr lunarcrush_key: SecretStr class Settings(_Sub): host: str = "0.0.0.0" port: int = 9000 log_level: str = "info" testnet_token: SecretStr mainnet_token: SecretStr deribit: DeribitSettings = Field(default_factory=DeribitSettings) bybit: BybitSettings = Field(default_factory=BybitSettings) hyperliquid: HyperliquidSettings = Field(default_factory=HyperliquidSettings) alpaca: AlpacaSettings = Field(default_factory=AlpacaSettings) macro: MacroSettings = Field(default_factory=MacroSettings) sentiment: SentimentSettings = Field(default_factory=SentimentSettings) ``` - [ ] **Step 4: Verifica test pass** ```bash uv run pytest tests/unit/test_settings.py -v ``` Expected: 4 PASSED. - [ ] **Step 5: Commit** ```bash git add src/cerbero_mcp/settings.py tests/unit/test_settings.py git commit -m "feat(V2): pydantic settings con secret str + test" ``` --- # PHASE 2 — Auth bearer ## Task 2.1: Test middleware auth **Files:** - Create: `tests/unit/test_auth.py` - [ ] **Step 1: Scrivi i test** ```python from __future__ import annotations import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @pytest.fixture def app(): from cerbero_mcp.auth import install_auth_middleware fa = FastAPI() install_auth_middleware( fa, testnet_token="tk_test_aaa", mainnet_token="tk_live_bbb", ) @fa.get("/health") def health(): return {"ok": True} @fa.get("/mcp-deribit/tools/get_ticker") def ticker(request): # workaround: FastAPI inietta Request via dependency return {"env": "?"} return fa def test_health_no_auth_required(): from cerbero_mcp.auth import install_auth_middleware fa = FastAPI() install_auth_middleware(fa, testnet_token="t", mainnet_token="m") @fa.get("/health") def h(): return {"ok": True} c = TestClient(fa) r = c.get("/health") assert r.status_code == 200 def test_apidocs_no_auth_required(): from cerbero_mcp.auth import install_auth_middleware fa = FastAPI(docs_url="/apidocs") install_auth_middleware(fa, testnet_token="t", mainnet_token="m") c = TestClient(fa) r = c.get("/apidocs") assert r.status_code == 200 r = c.get("/openapi.json") assert r.status_code == 200 def test_missing_authorization_header_401(): from cerbero_mcp.auth import install_auth_middleware fa = FastAPI() install_auth_middleware(fa, testnet_token="t", mainnet_token="m") @fa.get("/mcp-deribit/health") def h(): return {"ok": True} c = TestClient(fa) r = c.get("/mcp-deribit/health") assert r.status_code == 401 def test_invalid_bearer_401(): from cerbero_mcp.auth import install_auth_middleware fa = FastAPI() install_auth_middleware(fa, testnet_token="t", mainnet_token="m") @fa.get("/mcp-deribit/health") def h(): return {"ok": True} c = TestClient(fa) r = c.get("/mcp-deribit/health", headers={"Authorization": "Bearer wrong"}) assert r.status_code == 401 def test_testnet_token_sets_env_testnet(): from cerbero_mcp.auth import install_auth_middleware fa = FastAPI() install_auth_middleware(fa, testnet_token="tk_test", mainnet_token="tk_live") captured = {} @fa.get("/mcp-deribit/peek") def peek(request): from fastapi import Request as R # Recupera Request via dependency-injection return {} from fastapi import Request @fa.get("/mcp-deribit/peek2") def peek2(request: Request): captured["env"] = request.state.environment return {"env": request.state.environment} c = TestClient(fa) r = c.get("/mcp-deribit/peek2", headers={"Authorization": "Bearer tk_test"}) assert r.status_code == 200 assert r.json() == {"env": "testnet"} def test_mainnet_token_sets_env_mainnet(): from cerbero_mcp.auth import install_auth_middleware from fastapi import Request fa = FastAPI() install_auth_middleware(fa, testnet_token="tk_test", mainnet_token="tk_live") @fa.get("/mcp-deribit/peek") def peek(request: Request): return {"env": request.state.environment} c = TestClient(fa) r = c.get("/mcp-deribit/peek", headers={"Authorization": "Bearer tk_live"}) assert r.status_code == 200 assert r.json() == {"env": "mainnet"} def test_uses_compare_digest(): """Verifica che _check_token usi secrets.compare_digest (timing-safe).""" import inspect from cerbero_mcp import auth src = inspect.getsource(auth) assert "compare_digest" in src, "auth.py deve usare secrets.compare_digest" ``` - [ ] **Step 2: Verifica fail** ```bash uv run pytest tests/unit/test_auth.py -v ``` Expected: ImportError su `cerbero_mcp.auth`. - [ ] **Step 3: Implementa `src/cerbero_mcp/auth.py`** ```python """Bearer auth middleware: bearer token → request.state.environment.""" from __future__ import annotations import secrets from typing import Literal from fastapi import FastAPI, HTTPException, Request, status from fastapi.responses import JSONResponse Environment = Literal["testnet", "mainnet"] WHITELIST_PATHS = frozenset({"/health", "/apidocs", "/openapi.json"}) def _extract_bearer(auth_header: str) -> str | None: if not auth_header.startswith("Bearer "): return None token = auth_header[len("Bearer "):].strip() return token or None def _check_token( candidate: str, testnet_token: str, mainnet_token: str ) -> Environment | None: if secrets.compare_digest(candidate, testnet_token): return "testnet" if secrets.compare_digest(candidate, mainnet_token): return "mainnet" return None def install_auth_middleware( app: FastAPI, *, testnet_token: str, mainnet_token: str, ) -> None: """Registra middleware di auth bearer sull'app FastAPI.""" @app.middleware("http") async def auth_middleware(request: Request, call_next): if request.url.path in WHITELIST_PATHS: return await call_next(request) token = _extract_bearer(request.headers.get("Authorization", "")) if token is None: return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={"error": {"code": "UNAUTHORIZED", "message": "missing or malformed bearer token"}}, ) env = _check_token(token, testnet_token, mainnet_token) if env is None: return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={"error": {"code": "UNAUTHORIZED", "message": "invalid token"}}, ) request.state.environment = env return await call_next(request) ``` - [ ] **Step 4: Verifica test pass** ```bash uv run pytest tests/unit/test_auth.py -v ``` Expected: 7 PASSED. - [ ] **Step 5: Commit** ```bash git add src/cerbero_mcp/auth.py tests/unit/test_auth.py git commit -m "feat(V2): bearer auth middleware con compare_digest" ``` --- # PHASE 3 — Migrazione `common/` (codice riutilizzabile) L'obiettivo di questa phase è copiare i moduli `mcp_common.{indicators,options,microstructure,stats,http,audit,logging,mcp_bridge}` da `services/common/src/mcp_common/` a `src/cerbero_mcp/common/`, aggiornando soltanto gli import. Il codice di logica resta identico. ## Task 3.1: Copia moduli common 1:1 **Files:** - Create: `src/cerbero_mcp/common/{indicators,options,microstructure,stats,http,audit,logging,mcp_bridge}.py` - [ ] **Step 1: Copia indicators.py** ```bash cp services/common/src/mcp_common/indicators.py src/cerbero_mcp/common/indicators.py ``` Modifica gli import in cima al file: sostituisci ogni `from mcp_common.X` con `from cerbero_mcp.common.X`. Usa: ```bash sed -i 's|from mcp_common\.|from cerbero_mcp.common.|g' src/cerbero_mcp/common/indicators.py sed -i 's|^import mcp_common\.|import cerbero_mcp.common.|g' src/cerbero_mcp/common/indicators.py ``` - [ ] **Step 2: Copia options.py, microstructure.py, stats.py** ```bash for f in options microstructure stats; do cp "services/common/src/mcp_common/$f.py" "src/cerbero_mcp/common/$f.py" sed -i 's|from mcp_common\.|from cerbero_mcp.common.|g' "src/cerbero_mcp/common/$f.py" done ``` - [ ] **Step 3: Copia http.py, audit.py, logging.py, mcp_bridge.py** ```bash for f in http audit logging mcp_bridge; do cp "services/common/src/mcp_common/$f.py" "src/cerbero_mcp/common/$f.py" sed -i 's|from mcp_common\.|from cerbero_mcp.common.|g' "src/cerbero_mcp/common/$f.py" done ``` - [ ] **Step 4: Verifica import puliti** ```bash grep -rn "mcp_common" src/cerbero_mcp/common/ || echo "OK: nessun residuo mcp_common" ``` Expected: output `OK: nessun residuo mcp_common`. - [ ] **Step 5: Smoke import** ```bash uv run python -c "from cerbero_mcp.common import indicators, options, microstructure, stats, http, audit, logging, mcp_bridge; print('all imports OK')" ``` Expected: `all imports OK`. - [ ] **Step 6: Commit** ```bash git add src/cerbero_mcp/common/ git commit -m "feat(V2): migrazione common/ (indicators, options, microstructure, stats, http, audit, logging, mcp_bridge)" ``` --- ## Task 3.2: Migra test del common **Files:** - Create: `tests/unit/common/test_indicators.py` (e analoghi) - [ ] **Step 1: Copia tutti i test esistenti del common** ```bash mkdir -p tests/unit/common cp -r services/common/tests/* tests/unit/common/ ``` - [ ] **Step 2: Aggiorna gli import nei test** ```bash find tests/unit/common -name '*.py' -exec sed -i 's|from mcp_common\.|from cerbero_mcp.common.|g' {} \; find tests/unit/common -name '*.py' -exec sed -i 's|^import mcp_common\.|import cerbero_mcp.common.|g' {} \; ``` - [ ] **Step 3: Esegui i test** ```bash uv run pytest tests/unit/common/ -v ``` Expected: tutti i test PASS (stessa percentuale che passavano in V1). Se qualche test fallisce per import non aggiornato, ripeti `sed`. Se fallisce per dipendenza mancante, aggiungila a `[project].dependencies` e `uv sync`. - [ ] **Step 4: Commit** ```bash git add tests/unit/common/ git commit -m "test(V2): migrazione test common/" ``` --- ## Task 3.3: Estrai `errors.py` (error envelope) da `mcp_common.server` **Files:** - Create: `src/cerbero_mcp/common/errors.py` - [ ] **Step 1: Crea `errors.py` con la logica di error envelope** ```python """Error envelope standard per tutti i tool MCP.""" from __future__ import annotations import uuid from datetime import UTC, datetime from typing import Any def error_envelope( *, type_: str, code: str, message: str, retryable: bool, suggested_fix: str | None = None, details: dict | None = None, request_id: str | None = None, ) -> dict: env: dict[str, Any] = { "error": { "type": type_, "code": code, "message": message, "retryable": retryable, }, "request_id": request_id or uuid.uuid4().hex, "data_timestamp": datetime.now(UTC).isoformat(), } if suggested_fix: env["error"]["suggested_fix"] = suggested_fix if details: env["error"]["details"] = details return env HTTP_CODE_MAP = { 400: "BAD_REQUEST", 401: "UNAUTHORIZED", 403: "FORBIDDEN", 404: "NOT_FOUND", 408: "TIMEOUT", 409: "CONFLICT", 422: "VALIDATION_ERROR", 429: "RATE_LIMIT", 500: "INTERNAL_ERROR", 502: "UPSTREAM_ERROR", 503: "UNAVAILABLE", 504: "GATEWAY_TIMEOUT", } RETRYABLE_STATUSES = frozenset({408, 429, 502, 503, 504}) ``` - [ ] **Step 2: Test envelope** Crea `tests/unit/common/test_errors.py`: ```python from __future__ import annotations from cerbero_mcp.common.errors import error_envelope, HTTP_CODE_MAP def test_envelope_minimal(): e = error_envelope(type_="x", code="C", message="m", retryable=False) assert e["error"]["code"] == "C" assert e["error"]["retryable"] is False assert "request_id" in e assert "data_timestamp" in e def test_envelope_with_suggested_fix_and_details(): e = error_envelope( type_="validation_error", code="INVALID_INPUT", message="bad", retryable=False, suggested_fix="check field x", details={"field": "x"}, ) assert e["error"]["suggested_fix"] == "check field x" assert e["error"]["details"] == {"field": "x"} def test_http_code_map_has_common_codes(): assert HTTP_CODE_MAP[401] == "UNAUTHORIZED" assert HTTP_CODE_MAP[502] == "UPSTREAM_ERROR" ``` ```bash uv run pytest tests/unit/common/test_errors.py -v ``` Expected: 3 PASSED. - [ ] **Step 3: Commit** ```bash git add src/cerbero_mcp/common/errors.py tests/unit/common/test_errors.py git commit -m "feat(V2): error envelope module estratto da server.py" ``` --- # PHASE 4 — Client registry ## Task 4.1: Test ClientRegistry **Files:** - Create: `tests/unit/test_client_registry.py` - [ ] **Step 1: Scrivi i test** ```python from __future__ import annotations import asyncio import pytest @pytest.mark.asyncio async def test_registry_lazy_build(): from cerbero_mcp.client_registry import ClientRegistry builds: list[tuple[str, str]] = [] async def fake_build(exchange: str, env: str): builds.append((exchange, env)) class C: async def aclose(self): pass return C() reg = ClientRegistry(builder=fake_build) c = await reg.get("deribit", "testnet") assert builds == [("deribit", "testnet")] assert (await reg.get("deribit", "testnet")) is c # cached assert builds == [("deribit", "testnet")] @pytest.mark.asyncio async def test_registry_different_keys_different_clients(): from cerbero_mcp.client_registry import ClientRegistry async def fake_build(exchange: str, env: str): class C: tag = (exchange, env) async def aclose(self): ... return C() reg = ClientRegistry(builder=fake_build) a = await reg.get("deribit", "testnet") b = await reg.get("deribit", "mainnet") c = await reg.get("bybit", "testnet") assert a is not b assert a.tag == ("deribit", "testnet") assert b.tag == ("deribit", "mainnet") assert c.tag == ("bybit", "testnet") @pytest.mark.asyncio async def test_registry_concurrent_get_one_build(): from cerbero_mcp.client_registry import ClientRegistry counter = {"calls": 0} async def fake_build(exchange: str, env: str): counter["calls"] += 1 await asyncio.sleep(0.05) class C: async def aclose(self): ... return C() reg = ClientRegistry(builder=fake_build) results = await asyncio.gather( *[reg.get("deribit", "testnet") for _ in range(10)] ) assert counter["calls"] == 1 assert all(r is results[0] for r in results) @pytest.mark.asyncio async def test_registry_aclose_calls_all(): from cerbero_mcp.client_registry import ClientRegistry closed: list[tuple[str, str]] = [] async def fake_build(exchange: str, env: str): class C: async def aclose(self): closed.append((exchange, env)) return C() reg = ClientRegistry(builder=fake_build) await reg.get("deribit", "testnet") await reg.get("bybit", "mainnet") await reg.aclose() assert sorted(closed) == sorted([("deribit", "testnet"), ("bybit", "mainnet")]) ``` - [ ] **Step 2: Verifica fail** ```bash uv run pytest tests/unit/test_client_registry.py -v ``` Expected: ImportError su `cerbero_mcp.client_registry`. - [ ] **Step 3: Implementa `src/cerbero_mcp/client_registry.py`** ```python """Cache lazy di client exchange, una istanza per (exchange, env).""" from __future__ import annotations import asyncio from collections import defaultdict from collections.abc import Awaitable, Callable from typing import Any, Literal Environment = Literal["testnet", "mainnet"] Builder = Callable[[str, Environment], Awaitable[Any]] class ClientRegistry: def __init__(self, *, builder: Builder) -> None: self._builder = builder 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: # double-check return self._clients[key] client = await self._builder(exchange, env) self._clients[key] = client return client async def aclose(self) -> None: for client in self._clients.values(): close = getattr(client, "aclose", None) if close is None: continue try: await close() except Exception: pass self._clients.clear() ``` - [ ] **Step 4: Verifica test pass** ```bash uv run pytest tests/unit/test_client_registry.py -v ``` Expected: 4 PASSED. - [ ] **Step 5: Commit** ```bash git add src/cerbero_mcp/client_registry.py tests/unit/test_client_registry.py git commit -m "feat(V2): ClientRegistry lazy con lock per chiave" ``` --- # PHASE 5 — Server skeleton + Swagger ## Task 5.1: `build_app` con Swagger **Files:** - Create: `src/cerbero_mcp/server.py` - Create: `tests/unit/test_server.py` - [ ] **Step 1: Test build_app** ```python from __future__ import annotations import pytest from fastapi.testclient import TestClient @pytest.fixture def app(): from cerbero_mcp.server import build_app return build_app( testnet_token="tk_test", mainnet_token="tk_live", title="Test", version="2.0.0", ) def test_apidocs_served(app): c = TestClient(app) r = c.get("/apidocs") assert r.status_code == 200 assert "swagger" in r.text.lower() def test_openapi_json_served(app): c = TestClient(app) r = c.get("/openapi.json") assert r.status_code == 200 spec = r.json() assert spec["info"]["title"] == "Test" # securityScheme BearerAuth presente assert "BearerAuth" in spec["components"]["securitySchemes"] assert spec["components"]["securitySchemes"]["BearerAuth"]["scheme"] == "bearer" def test_redoc_disabled(app): c = TestClient(app) r = c.get("/redoc") assert r.status_code == 404 def test_default_docs_path_disabled(app): c = TestClient(app) r = c.get("/docs") assert r.status_code == 404 def test_health_endpoint(app): c = TestClient(app) r = c.get("/health") assert r.status_code == 200 j = r.json() assert j["status"] == "healthy" assert j["version"] == "2.0.0" assert "uptime_seconds" in j assert "data_timestamp" in j def test_x_duration_ms_header(app): c = TestClient(app) r = c.get("/health") assert "X-Duration-Ms" in r.headers ``` - [ ] **Step 2: Verifica fail** ```bash uv run pytest tests/unit/test_server.py -v ``` Expected: ImportError. - [ ] **Step 3: Implementa `src/cerbero_mcp/server.py`** ```python """Factory FastAPI app con middleware, swagger, exception handlers.""" from __future__ import annotations import json import time from datetime import UTC, datetime from typing import Any from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse, Response from starlette.middleware.base import BaseHTTPMiddleware from cerbero_mcp.auth import install_auth_middleware from cerbero_mcp.common.errors import ( HTTP_CODE_MAP, RETRYABLE_STATUSES, error_envelope, ) class _TimestampInjectorMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): response = await call_next(request) path = request.url.path if "/tools/" not in path: return response ctype = response.headers.get("content-type", "") if "application/json" not in ctype: return response body = b"" async for chunk in response.body_iterator: body += chunk ts = datetime.now(UTC).isoformat() try: data = json.loads(body) if body else None except Exception: headers = dict(response.headers) headers["X-Data-Timestamp"] = ts return Response( content=body, status_code=response.status_code, headers=headers, media_type=response.media_type, ) modified = False if isinstance(data, dict) and "data_timestamp" not in data: data["data_timestamp"] = ts modified = True elif isinstance(data, list): for item in data: if isinstance(item, dict) and "data_timestamp" not in item: item["data_timestamp"] = ts modified = True headers = dict(response.headers) headers["X-Data-Timestamp"] = ts if modified: new_body = json.dumps(data, default=str).encode() headers.pop("content-length", None) return Response( content=new_body, status_code=response.status_code, headers=headers, media_type="application/json", ) return Response( content=body, status_code=response.status_code, headers=headers, media_type=response.media_type, ) def build_app( *, testnet_token: str, mainnet_token: str, title: str = "Cerbero MCP", version: str = "2.0.0", description: str = ( "Multi-exchange MCP server. " "Bearer token decides environment (testnet/mainnet)." ), ) -> FastAPI: app = FastAPI( title=title, version=version, description=description, 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", }, ) app.state.boot_at = time.time() install_auth_middleware( app, testnet_token=testnet_token, mainnet_token=mainnet_token ) app.add_middleware(_TimestampInjectorMiddleware) @app.middleware("http") async def latency_header(request: Request, call_next): t0 = time.perf_counter() response = await call_next(request) dur_ms = (time.perf_counter() - t0) * 1000 response.headers["X-Duration-Ms"] = f"{dur_ms:.2f}" return response @app.exception_handler(HTTPException) async def _http_exc(request: Request, exc: HTTPException): retryable = exc.status_code in RETRYABLE_STATUSES code = HTTP_CODE_MAP.get(exc.status_code, f"HTTP_{exc.status_code}") message = "HTTP error" details: dict | None = None detail = exc.detail if isinstance(detail, dict): if isinstance(detail.get("error"), str): code = detail["error"].upper() message = str(detail.get("message") or detail.get("error") or message) details = detail elif isinstance(detail, str): message = detail return JSONResponse( status_code=exc.status_code, content=error_envelope( type_="http_error", code=code, message=message, retryable=retryable, details=details, ), ) @app.exception_handler(RequestValidationError) async def _val_exc(request: Request, exc: RequestValidationError): errs = exc.errors() first_loc = ".".join(str(x) for x in errs[0]["loc"]) if errs else "body" suggestion = ( f"check field '{first_loc}': " + (errs[0]["msg"] if errs else "invalid input") ) safe_errs: list[dict] = [] for e in errs[:5]: ne: dict = {} for k, v in e.items(): if k == "ctx" and isinstance(v, dict): ne[k] = {ck: str(cv) for ck, cv in v.items()} else: ne[k] = v safe_errs.append(ne) return JSONResponse( status_code=422, content=error_envelope( type_="validation_error", code="INVALID_INPUT", message=f"request body validation failed on {first_loc}", retryable=False, suggested_fix=suggestion, details={"errors": safe_errs}, ), ) @app.exception_handler(Exception) async def _unhandled(request: Request, exc: Exception): return JSONResponse( status_code=500, content=error_envelope( type_="internal_error", code="UNHANDLED_EXCEPTION", message=f"{type(exc).__name__}: {str(exc)[:300]}", retryable=True, ), ) @app.get("/health", tags=["system"]) def health(): return { "status": "healthy", "name": title, "version": version, "uptime_seconds": int(time.time() - app.state.boot_at), "data_timestamp": datetime.now(UTC).isoformat(), } def _custom_openapi() -> dict[str, Any]: if app.openapi_schema: return app.openapi_schema schema = get_openapi( title=app.title, version=app.version, description=app.description, routes=app.routes, ) schema.setdefault("components", {}) schema["components"]["securitySchemes"] = { "BearerAuth": { "type": "http", "scheme": "bearer", "description": ( "Use TESTNET_TOKEN for testnet routing, " "MAINNET_TOKEN for mainnet." ), } } schema["security"] = [{"BearerAuth": []}] app.openapi_schema = schema return schema app.openapi = _custom_openapi return app ``` - [ ] **Step 4: Verifica test** ```bash uv run pytest tests/unit/test_server.py -v ``` Expected: 6 PASSED. - [ ] **Step 5: Commit** ```bash git add src/cerbero_mcp/server.py tests/unit/test_server.py git commit -m "feat(V2): build_app con swagger /apidocs + middleware + handlers" ``` --- # PHASE 6 — Migrazione exchange (template + 6 ripetizioni) Per ognuno dei 6 servizi (`deribit`, `bybit`, `hyperliquid`, `alpaca`, `macro`, `sentiment`) si applica lo **stesso pattern**. Il pattern è dettagliato per `deribit` (Task 6.1–6.4); le altre task (6.5–6.10) seguono ESATTAMENTE lo stesso template, sostituendo solo nomi. **Pattern per ogni exchange:** 1. Crea `src/cerbero_mcp/exchanges/{exchange}/{__init__,client,leverage_cap,tools}.py` 2. Crea `src/cerbero_mcp/routers/{exchange}.py` 3. Migra test del servizio in `tests/unit/exchanges/{exchange}/` 4. Aggiungi entry nel client_registry builder ## Task 6.1: Migrazione `deribit` — codice **Files:** - Create: `src/cerbero_mcp/exchanges/deribit/{__init__,client,leverage_cap,tools}.py` - [ ] **Step 1: Copia file dal vecchio servizio** ```bash mkdir -p src/cerbero_mcp/exchanges/deribit touch src/cerbero_mcp/exchanges/deribit/__init__.py cp services/mcp-deribit/src/mcp_deribit/client.py src/cerbero_mcp/exchanges/deribit/client.py cp services/mcp-deribit/src/mcp_deribit/leverage_cap.py src/cerbero_mcp/exchanges/deribit/leverage_cap.py ``` - [ ] **Step 2: Aggiorna gli import** ```bash for f in src/cerbero_mcp/exchanges/deribit/*.py; do sed -i 's|from mcp_common\.|from cerbero_mcp.common.|g' "$f" sed -i 's|from mcp_deribit\.|from cerbero_mcp.exchanges.deribit.|g' "$f" sed -i 's|^import mcp_common\.|import cerbero_mcp.common.|g' "$f" sed -i 's|^import mcp_deribit\.|import cerbero_mcp.exchanges.deribit.|g' "$f" done ``` - [ ] **Step 3: Estrai le tool dal vecchio `server.py`** Il file `services/mcp-deribit/src/mcp_deribit/server.py` (695 righe) contiene sia la registrazione FastAPI sia la logica delle tool. Crea `src/cerbero_mcp/exchanges/deribit/tools.py` con SOLO la logica (funzioni async che ricevono `client` e `params`, non endpoint): Apri `services/mcp-deribit/src/mcp_deribit/server.py`. Per ogni handler decorato con `@app.post("/tools/")` (es. `place_order`, `get_ticker`, `get_dvol`, ...): - Estrai il body della funzione in una funzione async `async def NAME(client: DeribitClient, params: ToolNameRequest) -> ToolNameResponse` - Sposta i Pydantic model `ToolNameRequest`/`ToolNameResponse` in cima a `tools.py` - Aggiungi `examples=[...]` al `model_config` di ogni Request model (almeno 1 esempio realistico) Esempio (estratto): ```python # src/cerbero_mcp/exchanges/deribit/tools.py from __future__ import annotations from pydantic import BaseModel from typing import Literal from cerbero_mcp.exchanges.deribit.client import DeribitClient class PlaceOrderRequest(BaseModel): instrument_name: str side: Literal["buy", "sell"] amount: float order_type: Literal["market", "limit"] = "market" price: float | None = None model_config = { "json_schema_extra": { "examples": [ { "summary": "Market buy 0.1 BTC perpetual", "value": { "instrument_name": "BTC-PERPETUAL", "side": "buy", "amount": 0.1, }, } ] } } class PlaceOrderResponse(BaseModel): order_id: str status: str filled_amount: float | None = None async def place_order( client: DeribitClient, params: PlaceOrderRequest ) -> PlaceOrderResponse: res = await client.place_order( instrument_name=params.instrument_name, side=params.side, amount=params.amount, type_=params.order_type, price=params.price, ) return PlaceOrderResponse( order_id=res["order"]["order_id"], status=res["order"]["order_state"], filled_amount=res["order"].get("filled_amount"), ) ``` Ripeti per ogni tool deribit (la lista è in `README.md` sezione "Deribit"). - [ ] **Step 4: Verifica import** ```bash uv run python -c "from cerbero_mcp.exchanges.deribit import client, leverage_cap, tools; print('OK')" ``` Expected: `OK`. - [ ] **Step 5: Commit** ```bash git add src/cerbero_mcp/exchanges/deribit/ git commit -m "feat(V2): migrazione deribit (client, leverage_cap, tools)" ``` --- ## Task 6.2: Migrazione `deribit` — router **Files:** - Create: `src/cerbero_mcp/routers/deribit.py` - [ ] **Step 1: Implementa il router** ```python """Router /mcp-deribit/* con dependency injection per env e client.""" from __future__ import annotations from typing import Literal from fastapi import APIRouter, Depends, Request from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.exchanges.deribit import tools as deribit_tools from cerbero_mcp.exchanges.deribit.client import DeribitClient Environment = Literal["testnet", "mainnet"] def get_environment(request: Request) -> Environment: return request.state.environment async def get_deribit_client( request: Request, env: Environment = Depends(get_environment) ) -> DeribitClient: registry: ClientRegistry = request.app.state.registry return await registry.get("deribit", env) def make_router() -> APIRouter: r = APIRouter(prefix="/mcp-deribit", tags=["deribit"]) @r.post("/tools/place_order", response_model=deribit_tools.PlaceOrderResponse) async def place_order( params: deribit_tools.PlaceOrderRequest, client: DeribitClient = Depends(get_deribit_client), ): return await deribit_tools.place_order(client, params) # ... ripeti per ogni tool deribit (~28 endpoint) # OGNI endpoint segue lo stesso pattern: # @r.post("/tools/", response_model=...Response) # async def (params: ...Request, client = Depends(get_deribit_client)): # return await deribit_tools.(client, params) return r ``` - [ ] **Step 2: Test smoke router** Crea `tests/unit/routers/test_deribit.py`: ```python from __future__ import annotations from fastapi.testclient import TestClient def test_router_mounts_correctly(monkeypatch): """Verifica che il router deribit risponda 401 senza bearer e che i path esistano.""" monkeypatch.setenv("TESTNET_TOKEN", "tk_test") monkeypatch.setenv("MAINNET_TOKEN", "tk_live") # set up minimal envs per Settings (vedi test_settings.py); usa fixture comune from tests.unit.test_settings import _minimal_env for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) from cerbero_mcp.server import build_app from cerbero_mcp.routers.deribit import make_router app = build_app(testnet_token="tk_test", mainnet_token="tk_live") app.include_router(make_router()) c = TestClient(app) r = c.post("/mcp-deribit/tools/place_order", json={}) assert r.status_code == 401 ``` - [ ] **Step 3: Esegui test** ```bash uv run pytest tests/unit/routers/test_deribit.py -v ``` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add src/cerbero_mcp/routers/deribit.py tests/unit/routers/ git commit -m "feat(V2): router /mcp-deribit/tools/* con bearer auth" ``` --- ## Task 6.3: Migrazione `deribit` — test **Files:** - Create: `tests/unit/exchanges/deribit/` - [ ] **Step 1: Copia test esistenti** ```bash mkdir -p tests/unit/exchanges/deribit cp services/mcp-deribit/tests/test_client.py tests/unit/exchanges/deribit/ cp services/mcp-deribit/tests/test_leverage_cap.py tests/unit/exchanges/deribit/ # saltiamo test_environment_info (legacy boot resolver, sostituito da auth) # saltiamo test_server_acl (vecchia ACL core/observer) # saltiamo test_env_validation (legacy) ``` - [ ] **Step 2: Aggiorna import** ```bash find tests/unit/exchanges/deribit -name '*.py' -exec sed -i 's|from mcp_common\.|from cerbero_mcp.common.|g' {} \; find tests/unit/exchanges/deribit -name '*.py' -exec sed -i 's|from mcp_deribit\.|from cerbero_mcp.exchanges.deribit.|g' {} \; ``` - [ ] **Step 3: Esegui test** ```bash uv run pytest tests/unit/exchanges/deribit/ -v ``` Expected: tutti i test che passavano in V1 passano in V2. Test che riferiscono ACL core/observer eliminati. - [ ] **Step 4: Commit** ```bash git add tests/unit/exchanges/deribit/ git commit -m "test(V2): migrazione test deribit (client, leverage_cap)" ``` --- ## Task 6.4: Aggiungi `deribit` al builder ClientRegistry **Files:** - Create: `src/cerbero_mcp/exchanges/__init__.py` (builder) - [ ] **Step 1: Implementa la funzione `build_client`** Modifica `src/cerbero_mcp/exchanges/__init__.py`: ```python """Builder centralizzato di client per ClientRegistry.""" from __future__ import annotations from typing import Literal from cerbero_mcp.settings import Settings Environment = Literal["testnet", "mainnet"] async def build_client( settings: Settings, exchange: str, env: Environment ): if exchange == "deribit": from cerbero_mcp.exchanges.deribit.client import DeribitClient url = ( settings.deribit.url_testnet if env == "testnet" else settings.deribit.url_live ) return DeribitClient( client_id=settings.deribit.client_id, client_secret=settings.deribit.client_secret.get_secret_value(), base_url=url, max_leverage=settings.deribit.max_leverage, ) raise ValueError(f"unsupported exchange: {exchange}") ``` (Le altre exchange vengono aggiunte nelle task 6.5–6.10 estendendo questa funzione.) - [ ] **Step 2: Test builder smoke** Crea `tests/unit/test_exchanges_builder.py`: ```python from __future__ import annotations import pytest from tests.unit.test_settings import _minimal_env @pytest.mark.asyncio async def test_build_client_deribit_returns_correct_url(monkeypatch): for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) from cerbero_mcp.settings import Settings from cerbero_mcp.exchanges import build_client s = Settings() c_test = await build_client(s, "deribit", "testnet") c_live = await build_client(s, "deribit", "mainnet") assert "test.deribit.com" in c_test.base_url assert "test.deribit.com" not in c_live.base_url @pytest.mark.asyncio async def test_build_client_unknown_exchange_raises(monkeypatch): for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) from cerbero_mcp.settings import Settings from cerbero_mcp.exchanges import build_client s = Settings() with pytest.raises(ValueError): await build_client(s, "ftx", "testnet") ``` ```bash uv run pytest tests/unit/test_exchanges_builder.py -v ``` Expected: 2 PASSED. - [ ] **Step 3: Commit** ```bash git add src/cerbero_mcp/exchanges/__init__.py tests/unit/test_exchanges_builder.py git commit -m "feat(V2): builder client centralizzato (solo deribit per ora)" ``` --- ## Task 6.5: Migra `bybit` (stesso pattern di Task 6.1–6.4) Applica esattamente la stessa procedura di Task 6.1–6.4 per **bybit**. Sostituisci ovunque: - `deribit` → `bybit` - `mcp_deribit` → `mcp_bybit` - `DeribitClient` → `BybitClient` - prefisso router `/mcp-deribit` → `/mcp-bybit` - tag swagger `"deribit"` → `"bybit"` **Punti specifici bybit:** - Settings field: `settings.bybit.api_key`, `settings.bybit.api_secret.get_secret_value()` - Aggiungi al `build_client`: ```python elif exchange == "bybit": from cerbero_mcp.exchanges.bybit.client import BybitClient url = settings.bybit.url_testnet if env == "testnet" else settings.bybit.url_live return BybitClient( api_key=settings.bybit.api_key, api_secret=settings.bybit.api_secret.get_secret_value(), base_url=url, max_leverage=settings.bybit.max_leverage, ) ``` - Test esistenti da migrare: `services/mcp-bybit/tests/*.py` (escludi test ACL legacy) - Tools specifiche bybit: `place_order`, `place_batch_order` (combo), `get_ticker`, `get_orderbook`, `get_kline`, `get_funding_rate`, `get_funding_rate_history`, `get_open_interest`, `get_basis_term_structure`, `get_orderbook_imbalance`, indicatori tecnici 5 step: 1. Copia + sed import (Task 6.1) 2. Estrai tools (Task 6.1 step 3) 3. Router (Task 6.2) 4. Test (Task 6.3) 5. builder + commit (Task 6.4) Commit finale: `feat(V2): migrazione bybit completa`. --- ## Task 6.6: Migra `hyperliquid` Stessa procedura. Differenze: - Auth: `wallet_address` + `private_key` (firma EIP-712, non HMAC) - Settings: `settings.hyperliquid.{wallet_address, api_wallet_address, private_key, url_live, url_testnet, max_leverage}` - Aggiungi al builder: ```python elif exchange == "hyperliquid": from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient url = ( settings.hyperliquid.url_testnet if env == "testnet" else settings.hyperliquid.url_live ) return HyperliquidClient( wallet_address=settings.hyperliquid.wallet_address, api_wallet_address=settings.hyperliquid.api_wallet_address, private_key=settings.hyperliquid.private_key.get_secret_value(), base_url=url, max_leverage=settings.hyperliquid.max_leverage, ) ``` Tools specifiche: `place_order`, `cancel_order`, `close_position`, `get_account_summary`, `get_positions`, `get_open_orders`, `get_ticker`, `get_orderbook`, `get_historical`, `get_indicators`, `get_funding_rate`, `basis_spot_perp`, `set_stop_loss`, `set_take_profit`, `get_markets`, `get_trade_history`. Commit finale: `feat(V2): migrazione hyperliquid completa`. --- ## Task 6.7: Migra `alpaca` Stessa procedura. Differenze: - Settings: `settings.alpaca.{api_key_id, secret_key, url_live, url_testnet, max_leverage}` - Builder: ```python elif exchange == "alpaca": from cerbero_mcp.exchanges.alpaca.client import AlpacaClient url = ( settings.alpaca.url_testnet if env == "testnet" else settings.alpaca.url_live ) return AlpacaClient( api_key_id=settings.alpaca.api_key_id, secret_key=settings.alpaca.secret_key.get_secret_value(), base_url=url, max_leverage=settings.alpaca.max_leverage, ) ``` Tools: `place_order`, `amend_order`, `cancel_order`, `cancel_all_orders`, `close_position`, `close_all_positions`, `get_account`, `get_positions`, `get_open_orders`, `get_activities`, `get_assets`, `get_bars`, `get_calendar`, `get_clock`, `get_option_chain`, `get_snapshot`, `get_ticker`. Commit finale: `feat(V2): migrazione alpaca completa`. --- ## Task 6.8: Migra `macro` Stessa procedura. **Differenze importanti:** - Macro NON ha endpoint testnet/mainnet. Il client è uno solo. - Builder ignora `env`: ```python elif exchange == "macro": from cerbero_mcp.exchanges.macro.client import MacroClient return MacroClient( fred_api_key=settings.macro.fred_api_key.get_secret_value(), finnhub_api_key=settings.macro.finnhub_api_key.get_secret_value(), ) ``` - Comunque richiede bearer valido: il middleware auth filtra prima del router. - Cache key: `("macro", env)` istanzia 2 entry separate ma punta allo stesso client (oppure aggiungi normalizzazione del registry per macro/sentiment, vedi sotto). **Decisione implementativa**: usa entrambe le chiavi (`("macro", "testnet")` e `("macro", "mainnet")`) e lascia che il builder ritorni due istanze separate. Costo: 2 client httpx pool invece di 1, irrilevante. Vantaggio: nessun special-case nel registry. Tools: `get_asset_price`, `get_breakeven_inflation`, `get_cot_disaggregated`, `get_cot_extreme_positioning`, `get_cot_tff`, `get_economic_indicators`, `get_equity_futures`, `get_macro_calendar`, `get_market_overview`, `get_treasury_yields`, `get_yield_curve_slope`. Commit finale: `feat(V2): migrazione macro completa`. --- ## Task 6.9: Migra `sentiment` Stessa procedura di macro (no env distinction). Builder: ```python elif exchange == "sentiment": from cerbero_mcp.exchanges.sentiment.client import SentimentClient return SentimentClient( cryptopanic_key=settings.sentiment.cryptopanic_key.get_secret_value(), lunarcrush_key=settings.sentiment.lunarcrush_key.get_secret_value(), ) ``` Tools: `get_cointegration_pairs`, `get_cross_exchange_funding`, `get_crypto_news`, `get_funding_arb_spread`, `get_funding_rates`, `get_liquidation_heatmap`, `get_oi_history`, `get_social_sentiment`, `get_world_news`. Commit finale: `feat(V2): migrazione sentiment completa`. --- ## Task 6.10: Default catch-all nel builder **Files:** - Modify: `src/cerbero_mcp/exchanges/__init__.py` - [ ] **Step 1: Verifica branch builder** Apri `src/cerbero_mcp/exchanges/__init__.py`. Deve avere 6 rami `if/elif` (deribit, bybit, hyperliquid, alpaca, macro, sentiment) + raise finale. Tutti i branch implementati nelle task precedenti. - [ ] **Step 2: Esegui tutta la suite di test** ```bash uv run pytest tests/ -v ``` Expected: tutti i test PASS. - [ ] **Step 3: Commit consolidamento (se restano modifiche)** ```bash git status # se ci sono file modificati: git add -A && git commit -m "chore(V2): consolidamento builder con tutti i 6 exchange" ``` --- # PHASE 7 — Entrypoint `__main__` e wiring finale ## Task 7.1: Implementa `__main__.py` reale **Files:** - Modify: `src/cerbero_mcp/__main__.py` - [ ] **Step 1: Sostituisci il placeholder** ```python """Entrypoint cerbero-mcp. Boot: - carica Settings da .env - costruisce app FastAPI con router per ogni exchange - crea ClientRegistry con builder - monta lifespan per chiusura pulita - avvia uvicorn """ from __future__ import annotations from contextlib import asynccontextmanager import uvicorn from fastapi import FastAPI from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.common.logging import configure_root_logging from cerbero_mcp.exchanges import build_client from cerbero_mcp.routers import ( alpaca, bybit, deribit, hyperliquid, macro, sentiment, ) from cerbero_mcp.server import build_app from cerbero_mcp.settings import Settings def _make_app(settings: Settings) -> FastAPI: app = build_app( testnet_token=settings.testnet_token.get_secret_value(), mainnet_token=settings.mainnet_token.get_secret_value(), title="Cerbero MCP", version="2.0.0", ) app.state.settings = settings async def builder(exchange: str, env: str): return await build_client(settings, exchange, env) app.state.registry = ClientRegistry(builder=builder) @asynccontextmanager async def lifespan(app: FastAPI): try: yield finally: await app.state.registry.aclose() app.router.lifespan_context = lifespan app.include_router(deribit.make_router()) app.include_router(bybit.make_router()) app.include_router(hyperliquid.make_router()) app.include_router(alpaca.make_router()) app.include_router(macro.make_router()) app.include_router(sentiment.make_router()) return app def main() -> None: configure_root_logging() settings = Settings() app = _make_app(settings) uvicorn.run( app, log_config=None, host=settings.host, port=settings.port, ) if __name__ == "__main__": main() ``` - [ ] **Step 2: Smoke run senza richieste reali** Crea `.env` di sviluppo (copia `.env.example` e metti almeno `TESTNET_TOKEN=test`, `MAINNET_TOKEN=test2`, e dummy per le credenziali exchange): ```bash cp .env.example .env # riempi placeholder con valori dummy python -c 'import secrets; print(secrets.token_urlsafe(32))' # genera 2 token # edita .env manualmente con valori dummy per credenziali (solo bot per il boot, non chiamare le tool) ``` ```bash timeout 3 uv run cerbero-mcp || true ``` Expected: server parte, listening su `0.0.0.0:9000`, poi viene killato dal timeout. Nessun errore di config. - [ ] **Step 3: Test integration build** Crea `tests/integration/test_app_boot.py`: ```python from __future__ import annotations from fastapi.testclient import TestClient def test_app_boots_and_health_responds(monkeypatch): from tests.unit.test_settings import _minimal_env for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) from cerbero_mcp.__main__ import _make_app from cerbero_mcp.settings import Settings app = _make_app(Settings()) c = TestClient(app) r = c.get("/health") assert r.status_code == 200 assert r.json()["status"] == "healthy" r = c.get("/openapi.json") assert r.status_code == 200 spec = r.json() paths = spec["paths"].keys() assert any(p.startswith("/mcp-deribit/") for p in paths) assert any(p.startswith("/mcp-bybit/") for p in paths) assert any(p.startswith("/mcp-hyperliquid/") for p in paths) assert any(p.startswith("/mcp-alpaca/") for p in paths) assert any(p.startswith("/mcp-macro/") for p in paths) assert any(p.startswith("/mcp-sentiment/") for p in paths) def test_apidocs_available_after_boot(monkeypatch): from tests.unit.test_settings import _minimal_env for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) from cerbero_mcp.__main__ import _make_app from cerbero_mcp.settings import Settings c = TestClient(_make_app(Settings())) r = c.get("/apidocs") assert r.status_code == 200 assert "Cerbero MCP" in r.text ``` ```bash uv run pytest tests/integration/test_app_boot.py -v ``` Expected: 2 PASSED. - [ ] **Step 4: Commit** ```bash git add src/cerbero_mcp/__main__.py tests/integration/ git commit -m "feat(V2): __main__ con lifespan + 6 router + integration test" ``` --- # PHASE 8 — Test integration env routing ## Task 8.1: Stub HTTP per verifica routing testnet/mainnet **Files:** - Create: `tests/integration/test_env_routing.py` - [ ] **Step 1: Scrivi test pytest-httpx** ```python """Test integration: bearer testnet → URL testnet, bearer mainnet → URL live.""" from __future__ import annotations import pytest from fastapi.testclient import TestClient from pytest_httpx import HTTPXMock @pytest.fixture def app(monkeypatch): from tests.unit.test_settings import _minimal_env for k, v in _minimal_env().items(): monkeypatch.setenv(k, v) from cerbero_mcp.__main__ import _make_app from cerbero_mcp.settings import Settings return _make_app(Settings()) def test_testnet_bearer_routes_to_testnet_url(app, httpx_mock: HTTPXMock): """Una request POST /mcp-deribit/tools/get_ticker con TESTNET_TOKEN deve produrre una request httpx verso DERIBIT_URL_TESTNET.""" httpx_mock.add_response( url="https://test.deribit.com/api/v2/public/ticker", json={"result": {"last_price": 60000.0}}, ) c = TestClient(app) r = c.post( "/mcp-deribit/tools/get_ticker", headers={"Authorization": "Bearer t_test_123"}, json={"instrument_name": "BTC-PERPETUAL"}, ) # 200 o 502 entrambi accettabili — l'importante è che la URL chiamata sia testnet requests = httpx_mock.get_requests() assert any("test.deribit.com" in str(req.url) for req in requests) def test_mainnet_bearer_routes_to_live_url(app, httpx_mock: HTTPXMock): httpx_mock.add_response( url="https://www.deribit.com/api/v2/public/ticker", json={"result": {"last_price": 60000.0}}, ) c = TestClient(app) r = c.post( "/mcp-deribit/tools/get_ticker", headers={"Authorization": "Bearer t_live_456"}, json={"instrument_name": "BTC-PERPETUAL"}, ) requests = httpx_mock.get_requests() assert any("www.deribit.com" in str(req.url) for req in requests) ``` (Ripeti pattern per bybit, hyperliquid, alpaca: minimo 1 test per exchange.) - [ ] **Step 2: Esegui** ```bash uv run pytest tests/integration/test_env_routing.py -v ``` Expected: tutti PASS. Se un client exchange non usa httpx ma SDK custom (es. pybit, alpaca-py, hyperliquid SDK), pytest-httpx potrebbe non intercettare. In quel caso usa monkeypatch sul client SDK per catturare base_url al construct. Esempio fallback per SDK opachi: ```python def test_bybit_testnet_via_constructor(app, monkeypatch): from cerbero_mcp.exchanges.bybit import client as bybit_client_mod captured = {} real_init = bybit_client_mod.BybitClient.__init__ def spy_init(self, *args, **kwargs): captured["base_url"] = kwargs.get("base_url") real_init(self, *args, **kwargs) monkeypatch.setattr(bybit_client_mod.BybitClient, "__init__", spy_init) # forza ricreazione client app.state.registry._clients.clear() c = TestClient(app) c.post( "/mcp-bybit/tools/get_ticker", headers={"Authorization": "Bearer t_test_123"}, json={"symbol": "BTCUSDT"}, ) assert "testnet" in captured["base_url"] ``` - [ ] **Step 3: Commit** ```bash git add tests/integration/test_env_routing.py git commit -m "test(V2): integration env routing per ogni exchange" ``` --- # PHASE 9 — Docker e compose ## Task 9.1: Nuovo `Dockerfile` **Files:** - Create: `Dockerfile` - Delete (later): `docker/` - [ ] **Step 1: Scrivi `Dockerfile` in root** ```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" \ org.opencontainers.image.source="https://github.com/AdrianoDev/cerbero" 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"] ``` - [ ] **Step 2: Build locale** ```bash docker build -t cerbero-mcp:2.0.0 . docker images cerbero-mcp:2.0.0 ``` Expected: build succeeds, image listed (~200 MB). - [ ] **Step 3: Smoke run container** ```bash docker run --rm -d --name cerbero-test --env-file .env -p 9000:9000 cerbero-mcp:2.0.0 sleep 3 curl -s http://localhost:9000/health | python -m json.tool docker logs cerbero-test docker stop cerbero-test ``` Expected: `{"status":"healthy",...}`. - [ ] **Step 4: Commit** ```bash git add Dockerfile git commit -m "feat(V2): Dockerfile unico multi-stage in root" ``` --- ## Task 9.2: Riscrivi `docker-compose.yml` minimo **Files:** - Modify: `docker-compose.yml` - [ ] **Step 1: Sovrascrivi** ```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 ``` - [ ] **Step 2: Verifica** ```bash docker compose config -q docker compose up -d --build sleep 3 curl -s http://localhost:9000/health | python -m json.tool docker compose down ``` Expected: parse OK, container healthy. - [ ] **Step 3: Commit** ```bash git add docker-compose.yml git commit -m "feat(V2): docker-compose.yml minimo (1 servizio, env_file .env)" ``` --- ## Task 9.3: Smoke test end-to-end con bearer **Files:** - Create: `tests/smoke/run.sh` - [ ] **Step 1: Scrivi smoke script** ```bash #!/usr/bin/env bash # Smoke test V2: avvia container, verifica /health, /apidocs, e una tool con bearer testnet. set -euo pipefail PORT="${PORT:-9000}" BASE="http://localhost:${PORT}" echo "==> healthcheck" curl -fsS "${BASE}/health" | python -m json.tool echo "==> apidocs" curl -fsS "${BASE}/apidocs" | grep -q "Cerbero MCP" && echo " swagger OK" echo "==> openapi.json" curl -fsS "${BASE}/openapi.json" | python -c "import sys,json;d=json.load(sys.stdin);assert 'BearerAuth' in d['components']['securitySchemes'];print(' bearer scheme OK')" if [[ -z "${TESTNET_TOKEN:-}" ]]; then echo "==> skip tool call (TESTNET_TOKEN non settato)" exit 0 fi echo "==> tool senza bearer → 401" status=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${BASE}/mcp-deribit/tools/get_ticker" \ -H "Content-Type: application/json" -d '{"instrument_name":"BTC-PERPETUAL"}') [[ "$status" == "401" ]] && echo " 401 OK" || (echo " expected 401 got $status"; exit 1) echo "==> tool con bearer testnet → routing testnet (può fallire 5xx se testnet down)" curl -s -o /dev/null -w " http %{http_code}\n" -X POST "${BASE}/mcp-deribit/tools/get_ticker" \ -H "Authorization: Bearer ${TESTNET_TOKEN}" \ -H "Content-Type: application/json" \ -d '{"instrument_name":"BTC-PERPETUAL"}' echo "==> SMOKE OK" ``` - [ ] **Step 2: Eseguibile** ```bash chmod +x tests/smoke/run.sh ``` - [ ] **Step 3: Run su container** ```bash docker compose up -d --build sleep 3 PORT=9000 TESTNET_TOKEN=$(grep '^TESTNET_TOKEN=' .env | cut -d= -f2) bash tests/smoke/run.sh docker compose down ``` Expected: `SMOKE OK`. - [ ] **Step 4: Commit** ```bash git add tests/smoke/run.sh git commit -m "test(V2): smoke script con bearer testnet" ``` --- # PHASE 10 — Pulizia repo (V1 → V2) A questo punto la nuova struttura V2 funziona end-to-end. Si rimuove il vecchio. ## Task 10.1: Rimuovi `services/`, `gateway/`, `secrets/`, `docker/` **Files:** - Delete: `services/`, `gateway/`, `secrets/`, `docker/` - [ ] **Step 1: Verifica nessun import residuo da `services/` o `mcp_*`** ```bash grep -rn "from mcp_common\|from mcp_deribit\|from mcp_bybit\|from mcp_hyperliquid\|from mcp_alpaca\|from mcp_macro\|from mcp_sentiment" src/ tests/ || echo "OK: no residual imports" grep -rn "services/" src/ tests/ pyproject.toml || echo "OK: no path refs" ``` Expected: `OK` per entrambi. - [ ] **Step 2: Rimuovi** ```bash git rm -rf services/ gateway/ docker/ rm -rf secrets/ # non versionato in V2 (gitignored) ``` - [ ] **Step 3: Run di tutti i test** ```bash uv run pytest tests/ -v ``` Expected: tutti PASS. - [ ] **Step 4: Build immagine ancora OK** ```bash docker build -t cerbero-mcp:2.0.0 . ``` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add -A git commit -m "chore(V2): rimuovi services/, gateway/, secrets/, docker/ (legacy V1)" ``` --- ## Task 10.2: Rimuovi compose overlay V1 **Files:** - Delete: `docker-compose.prod.yml`, `docker-compose.local.yml`, `docker-compose.traefik.yml` - [ ] **Step 1: Rimuovi** ```bash git rm docker-compose.prod.yml docker-compose.local.yml docker-compose.traefik.yml ``` - [ ] **Step 2: Verifica `docker-compose.yml` ancora valido** ```bash docker compose config -q ``` Expected: nessun errore. - [ ] **Step 3: Commit** ```bash git commit -m "chore(V2): rimuovi compose overlay V1 (prod, local, traefik)" ``` --- ## Task 10.3: Aggiorna `scripts/build-push.sh` per 1 immagine **Files:** - Modify: `scripts/build-push.sh` - [ ] **Step 1: Sovrascrivi** ```bash #!/usr/bin/env bash # Build & push immagine cerbero-mcp:2.0.0 + :latest verso registry Gitea. set -euo pipefail : "${GITEA_PAT:?GITEA_PAT non settato}" : "${REGISTRY:=gitea.tielogic.xyz/adriano}" VERSION="${VERSION:-2.0.0}" SHA="$(git rev-parse --short HEAD)" echo "==> docker login" echo "${GITEA_PAT}" | docker login "${REGISTRY%%/*}" -u adriano --password-stdin echo "==> build" docker build -t cerbero-mcp:${VERSION} -t cerbero-mcp:latest -t cerbero-mcp:sha-${SHA} . echo "==> tag for registry" for tag in ${VERSION} latest sha-${SHA}; do docker tag cerbero-mcp:${tag} ${REGISTRY}/cerbero-mcp:${tag} done echo "==> push" for tag in ${VERSION} latest sha-${SHA}; do docker push ${REGISTRY}/cerbero-mcp:${tag} done echo "==> DONE" ``` - [ ] **Step 2: Eseguibile** ```bash chmod +x scripts/build-push.sh ``` - [ ] **Step 3: Commit** ```bash git add scripts/build-push.sh git commit -m "chore(V2): build-push.sh costruisce 1 sola immagine" ``` --- # PHASE 11 — Documentazione ## Task 11.1: Riscrivi `README.md` **Files:** - Modify: `README.md` - [ ] **Step 1: Sovrascrivi** ```markdown # Cerbero MCP — V2.0.0 Server MCP unificato multi-exchange per la suite Cerbero. Distribuito come singola immagine Docker; testnet e mainnet sono raggiungibili contemporaneamente attraverso un meccanismo di routing per-request basato sul token bearer fornito dal client. ## Caratteristiche - **Una singola immagine Docker** (`cerbero-mcp`) ospita tutti i router exchange in un unico processo FastAPI - **Quattro exchange** (Deribit, Bybit, Hyperliquid, Alpaca) e **due data provider** read-only (Macro, Sentiment) - **Switch testnet/mainnet per-request** via header `Authorization: Bearer `: lo stesso container serve entrambi gli ambienti senza riavvii - **Configurazione interamente in `.env`**: nessun file JSON di credenziali separato - **Documentazione interattiva** OpenAPI/Swagger esposta a `/apidocs` ## Avvio rapido (sviluppo, senza Docker) 1. Copiare il template di configurazione e compilarlo: ```bash cp .env.example .env # editare .env con le proprie credenziali e i due token ``` 2. Generare i token bearer: ```bash python -c 'import secrets; print("TESTNET_TOKEN=" + secrets.token_urlsafe(32))' python -c 'import secrets; print("MAINNET_TOKEN=" + secrets.token_urlsafe(32))' ``` 3. Installare le dipendenze e avviare: ```bash uv sync uv run cerbero-mcp ``` 4. Aprire la documentazione interattiva: ## Avvio con Docker ```bash cp .env.example .env # compilare valori docker compose up -d ``` Il container espone la porta indicata da `PORT` in `.env` (default 9000). ## Token bearer e ambienti | Token usato | Ambiente upstream | |---|---| | `Authorization: Bearer $TESTNET_TOKEN` | URL testnet di ciascun exchange | | `Authorization: Bearer $MAINNET_TOKEN` | URL mainnet (live) | | Nessun token / token sconosciuto | 401 Unauthorized | Le tool puramente read-only (`/mcp-macro/*` e `/mcp-sentiment/*`) richiedono comunque un bearer valido, ma il valore (testnet o mainnet) è indifferente perché non hanno endpoint testnet. ## Endpoint principali | Path | Descrizione | |---|---| | `GET /health` | Healthcheck (no auth) | | `GET /apidocs` | Swagger UI (no auth) | | `GET /openapi.json` | Schema OpenAPI 3.1 (no auth) | | `POST /mcp-deribit/tools/{tool}` | Tool exchange Deribit | | `POST /mcp-bybit/tools/{tool}` | Tool exchange Bybit | | `POST /mcp-hyperliquid/tools/{tool}` | Tool exchange Hyperliquid | | `POST /mcp-alpaca/tools/{tool}` | Tool exchange Alpaca | | `POST /mcp-macro/tools/{tool}` | Tool macro/market data | | `POST /mcp-sentiment/tools/{tool}` | Tool sentiment/news | ## Tool disponibili ### Common (`cerbero_mcp.common.indicators` + `options` + `microstructure` + `stats`) Tecnici (`sma`, `rsi`, `macd`, `atr`, `adx`), volatilità (`vol_cone`, `garch11_forecast`), statistici (`hurst_exponent`, `half_life_mean_reversion`, `cointegration_test`), risk (`rolling_sharpe`, `var_cvar`), microstructure (`orderbook_imbalance`), options (`oi_weighted_skew`, `smile_asymmetry`, `dealer_gamma_profile`, `vanna_charm_aggregate`). ### Deribit DVOL, GEX, P/C ratio, skew_25d, term_structure, iv_rank, realized_vol, indicatori tecnici, find_by_delta, calculate_spread_payoff, get_dealer_gamma_profile, get_vanna_charm, get_oi_weighted_skew, get_smile_asymmetry, get_atm_vs_wings_vol, get_orderbook_imbalance, place_combo_order. ### Bybit Ticker, orderbook, OHLCV, funding rate, open interest, basis spot/perp, indicatori tecnici, place_batch_order, get_orderbook_imbalance, get_basis_term_structure. ### Hyperliquid Account summary, positions, orderbook, historical, indicators, funding rate, basis spot/perp, place_order, set_stop_loss, set_take_profit. ### Alpaca Account, positions, bars, snapshot, option chain, place_order, amend_order, cancel_order, close_position. ### Macro Treasury yields, FRED indicators, equity futures, asset prices, calendar, get_yield_curve_slope, get_breakeven_inflation, get_cot_tff, get_cot_disaggregated, get_cot_extreme_positioning. ### Sentiment News (CryptoPanic/CoinDesk), social (LunarCrush), funding multi-exchange, OI history, get_funding_arb_spread, get_liquidation_heatmap, get_cointegration_pairs. ## Deploy su VPS con Traefik Sul VPS si gestisce la rete pubblica (TLS, allowlist IP, rate limit) con Traefik esterno a questo repository. Il container `cerbero-mcp` non espone porte all'esterno: si registra alla rete docker di Traefik tramite label aggiunte da un override compose esterno (es. `docker-compose.override.yml` versionato fuori da questo repo). La policy di sicurezza pubblica (allowlist IP per gli endpoint write) è responsabilità di Traefik. Esempio label minime per Traefik: ```yaml labels: - "traefik.enable=true" - "traefik.http.routers.cerbero.rule=Host(`cerbero-mcp.tielogic.xyz`)" - "traefik.http.routers.cerbero.entrypoints=websecure" - "traefik.http.routers.cerbero.tls.certresolver=letsencrypt" - "traefik.http.services.cerbero.loadbalancer.server.port=9000" ``` ## Build & deploy pipeline Build dell'immagine eseguita sulla macchina di sviluppo: ```bash export GITEA_PAT='' ./scripts/build-push.sh ``` Lo script tagga `:2.0.0`, `:latest` e `:sha-` per rollback puntuali e pubblica al registry Gitea. Sul VPS Watchtower polla `:latest` e aggiorna il container automaticamente. ## Sviluppo ```bash uv sync uv run pytest # tutta la suite uv run pytest tests/unit -v # solo unit uv run ruff check src/ uv run mypy src/cerbero_mcp ``` ## Migrazione da V1 (1.x → 2.0.0) Per chi è in produzione su V1: 1. Backup `secrets/` (V2 non li userà ma servono come fonte di copia). 2. Generare i due nuovi token bearer (vedi sopra). 3. Compilare `.env` mappando i campi V1 ai campi V2: - `secrets/deribit.json` → `DERIBIT_*` - `secrets/bybit.json` → `BYBIT_*` - `secrets/hyperliquid.json` → `HYPERLIQUID_*` - `secrets/alpaca.json` → `ALPACA_*` - `secrets/macro.json` → `FRED_API_KEY`, `FINNHUB_API_KEY` - `secrets/sentiment.json` → `CRYPTOPANIC_KEY`, `LUNARCRUSH_KEY` 4. Aggiornare i client bot: - i path API restano identici (`/mcp-{exchange}/tools/{tool}`) - sostituire `core.token` / `observer.token` con `TESTNET_TOKEN` o `MAINNET_TOKEN` a seconda dell'ambiente desiderato per la chiamata 5. Spegnere V1 (`docker compose -f down`) e avviare V2 (`docker compose up -d`). 6. Verificare `/health` e `/apidocs`. ## Licenza Privato. ``` - [ ] **Step 2: Commit** ```bash git add README.md git commit -m "docs(V2): README riscritto per architettura V2.0.0" ``` --- ## Task 11.2: Elimina `DEPLOYMENT.md` **Files:** - Delete: `DEPLOYMENT.md` - [ ] **Step 1: Rimuovi** ```bash git rm DEPLOYMENT.md ``` - [ ] **Step 2: Verifica nessun link rotto** ```bash grep -rn "DEPLOYMENT.md" . --include='*.md' --include='*.py' --include='*.yml' --include='*.yaml' || echo "OK: no refs" ``` Expected: `OK: no refs`. Se ci sono riferimenti, aggiornali al README. - [ ] **Step 3: Commit** ```bash git commit -m "docs(V2): rimuovi DEPLOYMENT.md (contenuti integrati in README)" ``` --- # PHASE 12 — Verifica finale ## Task 12.1: Quality gate completo - [ ] **Step 1: Lint + type check + test** ```bash uv run ruff check src/ tests/ uv run mypy src/cerbero_mcp uv run pytest tests/ -v ``` Expected: tutti i comandi exit code 0. - [ ] **Step 2: Build & smoke con container** ```bash docker build -t cerbero-mcp:2.0.0 . docker compose up -d sleep 5 PORT=9000 TESTNET_TOKEN=$(grep '^TESTNET_TOKEN=' .env | cut -d= -f2) bash tests/smoke/run.sh docker compose down ``` Expected: `SMOKE OK`. - [ ] **Step 3: Verifica file rimossi** ```bash test ! -d services/ && echo "services/ removed" test ! -d gateway/ && echo "gateway/ removed" test ! -d docker/ && echo "docker/ removed" test ! -f DEPLOYMENT.md && echo "DEPLOYMENT.md removed" test ! -f docker-compose.prod.yml && echo "compose.prod removed" test ! -f docker-compose.traefik.yml && echo "compose.traefik removed" test ! -f docker-compose.local.yml && echo "compose.local removed" ``` Expected: 7 conferme di rimozione. - [ ] **Step 4: Verifica file presenti** ```bash test -f Dockerfile && echo "Dockerfile present" test -f docker-compose.yml && echo "compose present" test -f .env.example && echo ".env.example present" test -d src/cerbero_mcp && echo "src package present" test -f src/cerbero_mcp/__main__.py && echo "main present" ``` Expected: 5 conferme. - [ ] **Step 5: Tag finale (opzionale, se tutto OK)** ```bash git tag -a v2.0.0 -m "V2.0.0: unified image, token-based env routing" ``` - [ ] **Step 6: Commit di chiusura (se ci sono ancora modifiche)** ```bash git status # se nulla → branch pronto ``` --- ## Criteri di successo finali - ✅ `uv run cerbero-mcp` parte su laptop senza Docker - ✅ `docker compose up -d` parte e `/health` risponde entro 5 s - ✅ `/apidocs` mostra Swagger UI con tag per ogni exchange - ✅ `/openapi.json` contiene securityScheme `BearerAuth` - ✅ Bot V1 funziona cambiando solo bearer (path identici) - ✅ Test integration: bearer testnet → URL testnet, bearer mainnet → URL live - ✅ Tutta la suite test passa - ✅ `services/`, `gateway/`, `secrets/`, `docker/`, `DEPLOYMENT.md`, 3 compose overlay rimossi - ✅ Build immagine < 5 min (vs ~12 min V1) - ✅ Image size ~200 MB