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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user