Phase 3: MCP HTTP clients + Dockerization

Wrapper async tipizzati sui sei servizi MCP HTTP che Cerbero Bite
consuma in autonomia. 277 test pass, copertura clients 93%, mypy
strict pulito, ruff clean.

Base layer:
- clients/_base.py: HttpToolClient con httpx + tenacity (retry
  esponenziale 3x, timeout 8s, mapping HTTP→eccezioni tipizzate).
- clients/_exceptions.py: McpAuthError, McpServerError, McpToolError,
  McpDataAnomalyError, McpNotFoundError, McpTimeoutError.
- config/mcp_endpoints.py: risoluzione URL via Docker DNS
  (mcp-deribit:9011, ...) con override per servizio via env var;
  caricamento bearer token da secrets/core.token o
  CERBERO_BITE_CORE_TOKEN_FILE.

Wrapper:
- clients/macro.py: next_high_severity_within() per filtro entry §2.5.
- clients/sentiment.py: funding_cross_median_annualized() con
  annualizzazione per period nativo per exchange (Binance/Bybit/OKX
  1095, Hyperliquid 8760).
- clients/hyperliquid.py: funding_rate_annualized() per filtro §2.6.
- clients/portfolio.py: total_equity_eur(), asset_pct_of_portfolio()
  per sizing engine + filtro §2.7.
- clients/telegram.py: notify-only (no callback queue, no
  conferme — Bite auto-execute).
- clients/deribit.py: environment_info, index_price_eth,
  latest_dvol, options_chain, get_tickers, orderbook_depth_top3,
  get_account_summary, get_positions, place_combo_order (combo
  atomico), cancel_order.

CLI:
- cerbero-bite ping: health-check parallelo di tutti gli MCP con
  tabella rich (OK/FAIL/SKIPPED).

Docker:
- Dockerfile multi-stage Python 3.13 + uv, user non-root.
- docker-compose.yml con rete external "cerbero-suite", secret
  core_token montato a /run/secrets/core_token, env per ogni MCP.
- secrets/README.md documenta il setup del token.

Documentazione di intervento:
- docs/12-mcp-deribit-changes.md: spec delle modifiche apportate
  al server mcp-deribit (place_combo_order + override testnet via
  DERIBIT_TESTNET).

Dipendenze:
- aggiunto pytest-httpx per i test HTTP.
- rimosso mcp>=1.0 (non usiamo l'SDK MCP, parliamo via HTTP REST).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 23:36:30 +02:00
parent 263470786d
commit 466e63dc19
29 changed files with 2988 additions and 235 deletions
+108
View File
@@ -0,0 +1,108 @@
"""Resolve MCP service URLs and the bearer token.
Cerbero Bite runs in its own Docker container that joins the
``cerbero-suite`` network: every MCP service is reachable by the
container DNS name plus its internal port (``mcp-deribit:9011`` etc.).
The resolver supports two layers of override:
1. Per-service environment variables (``CERBERO_BITE_MCP_DERIBIT_URL``,
``CERBERO_BITE_MCP_MACRO_URL``…). Useful for dev when running
outside Docker — point at ``http://localhost:9011`` etc.
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var: path to the file that
stores the bearer token (default
``/run/secrets/core_token``). The file is read at boot, the
trailing whitespace is stripped, and the value is *not* logged.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
__all__ = [
"DEFAULT_ENDPOINTS",
"MCP_SERVICES",
"McpEndpoints",
"load_endpoints",
"load_token",
]
# Service identifier → (default Docker DNS host, default port, env var name)
MCP_SERVICES: dict[str, tuple[str, int, str]] = {
"deribit": ("mcp-deribit", 9011, "CERBERO_BITE_MCP_DERIBIT_URL"),
"hyperliquid": ("mcp-hyperliquid", 9012, "CERBERO_BITE_MCP_HYPERLIQUID_URL"),
"macro": ("mcp-macro", 9013, "CERBERO_BITE_MCP_MACRO_URL"),
"sentiment": ("mcp-sentiment", 9014, "CERBERO_BITE_MCP_SENTIMENT_URL"),
"telegram": ("mcp-telegram", 9017, "CERBERO_BITE_MCP_TELEGRAM_URL"),
"portfolio": ("mcp-portfolio", 9018, "CERBERO_BITE_MCP_PORTFOLIO_URL"),
}
def _default_url(host: str, port: int) -> str:
return f"http://{host}:{port}"
DEFAULT_ENDPOINTS: dict[str, str] = {
name: _default_url(host, port) for name, (host, port, _) in MCP_SERVICES.items()
}
@dataclass(frozen=True)
class McpEndpoints:
"""Resolved per-service URLs."""
deribit: str
hyperliquid: str
macro: str
sentiment: str
telegram: str
portfolio: str
def for_service(self, name: str) -> str:
try:
return getattr(self, name) # type: ignore[no-any-return]
except AttributeError as exc:
raise KeyError(f"unknown MCP service '{name}'") from exc
def load_endpoints(env: dict[str, str] | None = None) -> McpEndpoints:
"""Build an :class:`McpEndpoints` honouring env-var overrides."""
e = env if env is not None else os.environ
resolved: dict[str, str] = {}
for name, (host, port, env_var) in MCP_SERVICES.items():
override = e.get(env_var)
resolved[name] = override.rstrip("/") if override else _default_url(host, port)
return McpEndpoints(**resolved)
_DEFAULT_TOKEN_FILE = "/run/secrets/core_token"
_TOKEN_FILE_ENV = "CERBERO_BITE_CORE_TOKEN_FILE"
def load_token(
*,
path: str | Path | None = None,
env: dict[str, str] | None = None,
) -> str:
"""Read the bearer token from disk and return it stripped.
Resolution order:
1. explicit ``path`` argument;
2. ``CERBERO_BITE_CORE_TOKEN_FILE`` env var;
3. ``/run/secrets/core_token`` (Docker secrets default).
"""
e = env if env is not None else os.environ
target = (
Path(path)
if path is not None
else Path(e.get(_TOKEN_FILE_ENV, _DEFAULT_TOKEN_FILE))
)
if not target.is_file():
raise FileNotFoundError(f"core token file not found: {target}")
token = target.read_text(encoding="utf-8").strip()
if not token:
raise ValueError(f"core token file is empty: {target}")
return token