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
+102
View File
@@ -9,6 +9,7 @@ without changing the surface.
from __future__ import annotations
import asyncio
import sys
from datetime import UTC, datetime
from pathlib import Path
@@ -18,7 +19,18 @@ from rich.console import Console
from rich.table import Table
from cerbero_bite import __version__
from cerbero_bite.clients import HttpToolClient, McpError
from cerbero_bite.clients.deribit import DeribitClient
from cerbero_bite.clients.hyperliquid import HyperliquidClient
from cerbero_bite.clients.macro import MacroClient
from cerbero_bite.clients.portfolio import PortfolioClient
from cerbero_bite.clients.sentiment import SentimentClient
from cerbero_bite.config.loader import compute_config_hash, load_strategy
from cerbero_bite.config.mcp_endpoints import (
DEFAULT_ENDPOINTS,
load_endpoints,
load_token,
)
from cerbero_bite.logging import configure as configure_logging
from cerbero_bite.logging import get_logger
from cerbero_bite.safety.audit_log import AuditChainError, AuditLog
@@ -225,6 +237,96 @@ def kill_switch_status(db: Path) -> None:
)
@main.command()
@click.option(
"--token-file",
type=click.Path(dir_okay=False, path_type=Path),
default=None,
help="Path to the bearer token file (default: secrets/core_token).",
)
@click.option(
"--timeout",
type=float,
default=4.0,
show_default=True,
help="Per-service timeout in seconds for the ping call.",
)
def ping(token_file: Path | None, timeout: float) -> None:
"""Print health status for every MCP service Cerbero Bite uses."""
try:
token = load_token(path=token_file)
except (FileNotFoundError, ValueError) as exc:
console.print(f"[red]token error[/red]: {exc}")
sys.exit(1)
endpoints = load_endpoints()
rows = asyncio.run(_ping_all(endpoints, token=token, timeout=timeout))
table = Table(title="MCP services")
table.add_column("service")
table.add_column("url")
table.add_column("status")
table.add_column("detail")
for service, url, status, detail in rows:
colour = {"ok": "green", "fail": "red", "skipped": "yellow"}.get(status, "white")
table.add_row(service, url, f"[{colour}]{status.upper()}[/{colour}]", detail)
console.print(table)
async def _ping_one(
*,
service: str,
url: str,
token: str,
timeout: float,
) -> tuple[str, str]:
"""Return ``(status, detail)`` for one service health check."""
http = HttpToolClient(
service=service,
base_url=url,
token=token,
retry_max=1,
timeout_s=timeout,
)
try:
if service == "deribit":
info = await DeribitClient(http).environment_info()
return "ok", f"environment={info.environment}"
if service == "macro":
await MacroClient(http).get_calendar(days=1, importance_min="high")
return "ok", "calendar reachable"
if service == "sentiment":
await SentimentClient(http).funding_cross_median_annualized("ETH")
return "ok", "funding reachable"
if service == "hyperliquid":
await HyperliquidClient(http).funding_rate_annualized("ETH")
return "ok", "ETH-PERP reachable"
if service == "portfolio":
await PortfolioClient(http).total_equity_eur()
return "ok", "portfolio reachable"
if service == "telegram":
# Notify-only: no read tool. Skip without hitting the bot.
return "skipped", "notify-only client (no health probe)"
return "skipped", "no probe defined" # pragma: no cover
except McpError as exc:
return "fail", f"{type(exc).__name__}: {exc}"
except Exception as exc: # surface any unexpected error for the operator
return "fail", f"{type(exc).__name__}: {exc}"
async def _ping_all(
endpoints: object, *, token: str, timeout: float
) -> list[tuple[str, str, str, str]]:
rows: list[tuple[str, str, str, str]] = []
for service in DEFAULT_ENDPOINTS:
url = endpoints.for_service(service) # type: ignore[attr-defined]
status, detail = await _ping_one(
service=service, url=url, token=token, timeout=timeout
)
rows.append((service, url, status, detail))
return rows
@main.command()
def gui() -> None:
"""Launch the Streamlit dashboard."""