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>
109 lines
3.4 KiB
Python
109 lines
3.4 KiB
Python
"""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
|