b8753afad2
Plan a 12 phase per implementare V2.0.0: - Phase 0: bootstrap struttura nuova - Phase 1: settings + .env consolidato - Phase 2: auth bearer middleware - Phase 3: migrazione common/ - Phase 4: client_registry lazy - Phase 5: build_app + swagger /apidocs - Phase 6: migrazione 6 exchange (deribit template + 5 ripetizioni) - Phase 7: __main__ entrypoint con lifespan - Phase 8: integration test env routing - Phase 9: Dockerfile + docker-compose minimo - Phase 10: pulizia V1 (services/, gateway/, secrets/, docker/) - Phase 11: README riscritto, DEPLOYMENT eliminato - Phase 12: quality gate finale Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2875 lines
80 KiB
Markdown
2875 lines
80 KiB
Markdown
# 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 <TOKEN>:
|
||
# - 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/<NAME>")` (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/<name>", response_model=...Response)
|
||
# async def <name>(params: ...Request, client = Depends(get_deribit_client)):
|
||
# return await deribit_tools.<name>(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 <TOKEN>`: 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: <http://localhost:9000/apidocs>
|
||
|
||
## 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='<PAT con scope write:package>'
|
||
./scripts/build-push.sh
|
||
```
|
||
|
||
Lo script tagga `:2.0.0`, `:latest` e `:sha-<short>` 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 <vecchio compose> 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
|