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
+228
View File
@@ -0,0 +1,228 @@
"""HTTP tool client common to every MCP wrapper.
Each MCP service exposes ``POST <base_url>/tools/<tool_name>`` with a
JSON body and a ``Bearer <core_token>`` header. ``HttpToolClient`` is a
thin wrapper around :class:`httpx.AsyncClient` that:
* Adds the auth header.
* Applies the project-wide timeout (default 8 s, see
``docs/10-config-spec.md`` ``mcp.call_timeout_s``).
* Retries the call on transient failures with exponential backoff
(1 s, 5 s, 30 s) — at most 3 attempts in total.
* Maps HTTP errors and ``state == "error"`` envelopes into the typed
exceptions in :mod:`cerbero_bite.clients._exceptions`.
The wrapper does *not* hold a long-lived ``AsyncClient`` by default:
each call opens and closes its own connection so a transient DNS issue
on one MCP server does not corrupt connection pooling for the others.
A shared pool can still be passed in via ``transport`` / ``client``
when the orchestrator wants connection reuse.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import httpx
from tenacity import (
AsyncRetrying,
RetryError,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from cerbero_bite.clients._exceptions import (
McpAuthError,
McpError,
McpNotFoundError,
McpServerError,
McpTimeoutError,
McpToolError,
)
__all__ = ["HttpToolClient"]
_log = logging.getLogger("cerbero_bite.clients")
_RETRYABLE: tuple[type[BaseException], ...] = (
McpTimeoutError,
McpServerError,
)
class HttpToolClient:
"""Async client for ``POST <base>/tools/<tool>`` style MCP services.
Args:
service: short service identifier (``"deribit"``, ``"macro"`` …).
base_url: e.g. ``"http://mcp-deribit:9011"``. Trailing slash
is stripped.
token: bearer token for the ``Authorization`` header.
timeout_s: per-request timeout, default 8 seconds.
retry_max: max number of attempts (1 = no retry).
retry_base_delay: base delay for exponential backoff.
sleep: hook for tests to skip real waits.
"""
def __init__(
self,
*,
service: str,
base_url: str,
token: str,
timeout_s: float = 8.0,
retry_max: int = 3,
retry_base_delay: float = 1.0,
sleep: Callable[[int | float], Awaitable[None] | None] | None = None,
) -> None:
self._service = service
self._base_url = base_url.rstrip("/")
self._token = token
self._timeout = httpx.Timeout(timeout_s)
self._retry_max = max(1, retry_max)
self._retry_base_delay = retry_base_delay
self._sleep = sleep
@property
def service(self) -> str:
return self._service
@property
def base_url(self) -> str:
return self._base_url
async def call(
self,
tool: str,
body: dict[str, Any] | None = None,
*,
client: httpx.AsyncClient | None = None,
) -> Any:
"""Invoke ``tool`` with ``body`` and return the parsed JSON response.
Returns whatever shape the server replies with (typically ``dict``,
sometimes ``list``). The wrapper checks ``state == "error"`` only on
``dict`` responses; list/scalar responses are passed through unchanged.
"""
url = f"{self._base_url}/tools/{tool}"
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
payload = body or {}
async def _attempt() -> Any:
return await self._do_request(
url=url,
headers=headers,
payload=payload,
tool=tool,
client=client,
)
if self._retry_max <= 1:
return await _attempt()
retry_kwargs: dict[str, Any] = {
"stop": stop_after_attempt(self._retry_max),
"wait": wait_exponential(multiplier=self._retry_base_delay, min=1, max=30),
"retry": retry_if_exception_type(_RETRYABLE),
"reraise": True,
}
if self._sleep is not None:
retry_kwargs["sleep"] = self._sleep
retrier = AsyncRetrying(**retry_kwargs)
try:
async for attempt in retrier:
with attempt:
return await _attempt()
except RetryError as exc: # pragma: no cover — reraise=True covers it
raise exc.last_attempt.exception() or McpError(
"retry exhausted", service=self._service, tool=tool
) from exc
# mypy needs an explicit fall-through — retry never falls out of the loop
raise McpError(
"unreachable retry loop exit", service=self._service, tool=tool
) # pragma: no cover
async def _do_request(
self,
*,
url: str,
headers: dict[str, str],
payload: dict[str, Any],
tool: str,
client: httpx.AsyncClient | None,
) -> Any:
request_client = client or httpx.AsyncClient(timeout=self._timeout)
owned = client is None
try:
try:
response = await request_client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise McpTimeoutError(
f"timeout calling {self._service}.{tool}",
service=self._service,
tool=tool,
) from exc
except httpx.HTTPError as exc:
raise McpServerError(
f"HTTP error calling {self._service}.{tool}: {exc}",
service=self._service,
tool=tool,
) from exc
self._raise_for_status(response, tool=tool)
try:
data: Any = response.json()
except json.JSONDecodeError as exc:
raise McpServerError(
f"{self._service}.{tool}: response is not JSON",
service=self._service,
tool=tool,
) from exc
if isinstance(data, dict) and data.get("state") == "error":
raise McpToolError(
f"{self._service}.{tool} returned error: "
f"{data.get('error', 'unknown')}",
service=self._service,
tool=tool,
payload=data,
)
return data
finally:
if owned:
await request_client.aclose()
def _raise_for_status(self, response: httpx.Response, *, tool: str) -> None:
status = response.status_code
if 200 <= status < 300:
return
if status in (401, 403):
raise McpAuthError(
f"{self._service}.{tool} authentication failed (HTTP {status})",
service=self._service,
tool=tool,
)
if status == 404:
raise McpNotFoundError(
f"{self._service}.{tool} not found (HTTP 404)",
service=self._service,
tool=tool,
)
# 4xx other than auth/404 → tool error from server side; do not retry.
# 5xx → server fault, retry-eligible.
message = (
f"{self._service}.{tool} HTTP {status}: "
f"{(response.text or '')[:200]!r}"
)
if 500 <= status < 600:
raise McpServerError(message, service=self._service, tool=tool)
raise McpToolError(message, service=self._service, tool=tool)