466e63dc19
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>
229 lines
7.5 KiB
Python
229 lines
7.5 KiB
Python
"""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)
|