fix(mcp): allinea il client all'interfaccia unificata MCP V2
Cerbero MCP V2 ha spostato get_instruments/get_historical/get_indicators sul router unificato /mcp (rimossi dagli endpoint per-exchange) e ha rinominato get_technical_indicators in get_indicators. Il client chiamava ancora questi tool su /mcp-deribit -> 404 -> kill-switch armato, raccolta option-chain ferma e bias direzionale rotto. - mcp_endpoints: nuovo endpoint `unified` (CERBERO_BITE_MCP_UNIFIED_URL, default http://cerbero-mcp:9000/mcp) - deribit: i 3 tool dati passano dal client unificato con exchange="deribit", interval minuscolo (1D->1d) e parsing del nuovo shape (symbol + native.*); adx_14 usa get_indicators (indicators.adx.adx) - dependencies/gui: iniettano il client unificato in DeribitClient - docker-compose: default in-cluster per l'endpoint /mcp Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@ x-bite-env: &bite-env
|
||||
CERBERO_BITE_MCP_HYPERLIQUID_URL: ${CERBERO_BITE_MCP_HYPERLIQUID_URL:-http://cerbero-mcp:9000/mcp-hyperliquid}
|
||||
CERBERO_BITE_MCP_MACRO_URL: ${CERBERO_BITE_MCP_MACRO_URL:-http://cerbero-mcp:9000/mcp-macro}
|
||||
CERBERO_BITE_MCP_SENTIMENT_URL: ${CERBERO_BITE_MCP_SENTIMENT_URL:-http://cerbero-mcp:9000/mcp-sentiment}
|
||||
# MCP V2 unified interface (/mcp): get_instruments, get_historical,
|
||||
# get_indicators — removed from the per-exchange routers.
|
||||
CERBERO_BITE_MCP_UNIFIED_URL: ${CERBERO_BITE_MCP_UNIFIED_URL:-http://cerbero-mcp:9000/mcp}
|
||||
|
||||
services:
|
||||
cerbero-bite:
|
||||
|
||||
@@ -120,12 +120,29 @@ def _to_decimal(value: Any) -> Decimal | None:
|
||||
class DeribitClient:
|
||||
SERVICE = "deribit"
|
||||
|
||||
def __init__(self, http: HttpToolClient) -> None:
|
||||
def __init__(
|
||||
self, http: HttpToolClient, unified: HttpToolClient | None = None
|
||||
) -> None:
|
||||
if http.service != self.SERVICE:
|
||||
raise ValueError(
|
||||
f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'"
|
||||
)
|
||||
self._http = http
|
||||
# Cerbero MCP V2 moved the data tools (get_instruments,
|
||||
# get_historical, get_indicators) to the unified ``/mcp`` router;
|
||||
# they are no longer on ``/mcp-deribit``. This second client points
|
||||
# at ``/mcp`` and is only needed by those three methods — diagnostic
|
||||
# paths (e.g. ``environment_info`` for ping) leave it ``None``.
|
||||
self._unified = unified
|
||||
|
||||
def _unified_client(self, tool: str) -> HttpToolClient:
|
||||
if self._unified is None:
|
||||
raise McpDataAnomalyError(
|
||||
f"unified MCP client not configured; '{tool}' lives on /mcp",
|
||||
service=self.SERVICE,
|
||||
tool=tool,
|
||||
)
|
||||
return self._unified
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Environment / health
|
||||
@@ -206,7 +223,16 @@ class DeribitClient:
|
||||
limit: int = 500,
|
||||
) -> list[InstrumentMeta]:
|
||||
"""Return option instruments matching the filters as typed metadata."""
|
||||
body: dict[str, Any] = {"currency": currency, "kind": "option", "limit": limit}
|
||||
# MCP V2: get_instruments is unified-only (/mcp). The venue is
|
||||
# selected via ``exchange`` and each row is normalized — the native
|
||||
# symbol is ``symbol`` and Deribit-specific fields live under
|
||||
# ``native``.
|
||||
body: dict[str, Any] = {
|
||||
"exchange": self.SERVICE,
|
||||
"currency": currency,
|
||||
"kind": "option",
|
||||
"limit": limit,
|
||||
}
|
||||
if expiry_from is not None:
|
||||
body["expiry_from"] = expiry_from.date().isoformat()
|
||||
if expiry_to is not None:
|
||||
@@ -214,28 +240,31 @@ class DeribitClient:
|
||||
if min_open_interest is not None:
|
||||
body["min_open_interest"] = min_open_interest
|
||||
|
||||
raw = await self._http.call("get_instruments", body)
|
||||
raw = await self._unified_client("get_instruments").call(
|
||||
"get_instruments", body
|
||||
)
|
||||
instruments = raw.get("instruments") or []
|
||||
out: list[InstrumentMeta] = []
|
||||
for entry in instruments:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name")
|
||||
name = entry.get("symbol")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
try:
|
||||
strike, expiry, option_type = _parse_instrument(name)
|
||||
except McpDataAnomalyError:
|
||||
continue
|
||||
native = entry.get("native") if isinstance(entry.get("native"), dict) else {}
|
||||
out.append(
|
||||
InstrumentMeta(
|
||||
name=name,
|
||||
strike=strike,
|
||||
expiry=expiry,
|
||||
option_type=option_type,
|
||||
open_interest=_to_decimal(entry.get("open_interest")),
|
||||
open_interest=_to_decimal(native.get("open_interest")),
|
||||
tick_size=_to_decimal(entry.get("tick_size")),
|
||||
min_trade_amount=_to_decimal(entry.get("min_trade_amount")),
|
||||
min_trade_amount=_to_decimal(native.get("min_trade_amount")),
|
||||
)
|
||||
)
|
||||
return out
|
||||
@@ -291,13 +320,17 @@ class DeribitClient:
|
||||
in :func:`compute_bias`. Returns ``None`` when the chain has no
|
||||
data in the window.
|
||||
"""
|
||||
raw = await self._http.call(
|
||||
# MCP V2: get_historical is unified-only (/mcp); it takes
|
||||
# ``exchange`` + lowercase ``interval`` (e.g. "1d", "1h") instead of
|
||||
# the old per-exchange ``resolution``.
|
||||
raw = await self._unified_client("get_historical").call(
|
||||
"get_historical",
|
||||
{
|
||||
"exchange": self.SERVICE,
|
||||
"instrument": instrument,
|
||||
"start_date": start.date().isoformat(),
|
||||
"end_date": end.date().isoformat(),
|
||||
"resolution": resolution,
|
||||
"interval": resolution.lower(),
|
||||
},
|
||||
)
|
||||
candles = (raw or {}).get("candles") or []
|
||||
@@ -422,28 +455,34 @@ class DeribitClient:
|
||||
resolution: str = "1h",
|
||||
) -> Decimal | None:
|
||||
"""Return the most recent ADX(14) value, or ``None`` when missing."""
|
||||
raw = await self._http.call(
|
||||
"get_technical_indicators",
|
||||
# MCP V2: indicators are computed by the unified ``get_indicators``
|
||||
# tool (/mcp), which replaced the per-exchange
|
||||
# ``get_technical_indicators``. Body takes ``exchange`` + lowercase
|
||||
# ``interval``; the response nests each indicator under
|
||||
# ``indicators`` (ADX as ``{"adx": <float>, "+di":…, "-di":…}``).
|
||||
raw = await self._unified_client("get_indicators").call(
|
||||
"get_indicators",
|
||||
{
|
||||
"exchange": self.SERVICE,
|
||||
"instrument": instrument,
|
||||
"indicators": ["adx"],
|
||||
"start_date": start.date().isoformat(),
|
||||
"end_date": end.date().isoformat(),
|
||||
"resolution": resolution,
|
||||
"interval": resolution.lower(),
|
||||
},
|
||||
)
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
# The MCP server returns either a top-level dict with the
|
||||
# indicator keyed by name, or a list of points. Be tolerant.
|
||||
adx_payload = raw.get("adx") or raw.get("ADX") or raw.get("indicators", {})
|
||||
if isinstance(adx_payload, list) and adx_payload:
|
||||
tail = adx_payload[-1]
|
||||
value = tail.get("value") if isinstance(tail, dict) else tail
|
||||
return None if value is None else Decimal(str(value))
|
||||
if isinstance(adx_payload, dict):
|
||||
value = adx_payload.get("latest") or adx_payload.get("value")
|
||||
indicators = raw.get("indicators")
|
||||
if not isinstance(indicators, dict):
|
||||
return None
|
||||
adx_block = indicators.get("adx")
|
||||
if isinstance(adx_block, dict):
|
||||
value = adx_block.get("adx")
|
||||
return None if value is None else Decimal(str(value))
|
||||
# Tolerate a flattened scalar shape just in case.
|
||||
if adx_block is not None and not isinstance(adx_block, list):
|
||||
return Decimal(str(adx_block))
|
||||
return None
|
||||
|
||||
async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
|
||||
|
||||
@@ -62,6 +62,14 @@ DEFAULT_ENDPOINTS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# Cerbero MCP V2 unified interface (``/mcp``). The data tools
|
||||
# ``get_instruments`` / ``get_historical`` / ``get_indicators`` live here
|
||||
# ONLY — they were removed from the per-exchange routers in MCP V2. The
|
||||
# caller passes ``exchange="deribit"`` in the body to target one venue.
|
||||
_UNIFIED_ENV = "CERBERO_BITE_MCP_UNIFIED_URL"
|
||||
_DEFAULT_UNIFIED_URL = "http://cerbero-mcp:9000/mcp"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpEndpoints:
|
||||
"""Resolved per-service URLs."""
|
||||
@@ -70,6 +78,7 @@ class McpEndpoints:
|
||||
hyperliquid: str
|
||||
macro: str
|
||||
sentiment: str
|
||||
unified: str = _DEFAULT_UNIFIED_URL
|
||||
|
||||
def for_service(self, name: str) -> str:
|
||||
try:
|
||||
@@ -85,6 +94,10 @@ def load_endpoints(env: dict[str, str] | None = None) -> McpEndpoints:
|
||||
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)
|
||||
unified_override = e.get(_UNIFIED_ENV)
|
||||
resolved["unified"] = (
|
||||
unified_override.rstrip("/") if unified_override else _DEFAULT_UNIFIED_URL
|
||||
)
|
||||
return McpEndpoints(**resolved)
|
||||
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ async def _fetch_balances_async(*, timeout_s: float = 8.0) -> BalancesSnapshot:
|
||||
client=http_client,
|
||||
)
|
||||
|
||||
deribit = DeribitClient(_client("deribit"))
|
||||
deribit = DeribitClient(_client("deribit"), _client("unified"))
|
||||
hl = HyperliquidClient(_client("hyperliquid"))
|
||||
macro = MacroClient(_client("macro"))
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ def build_runtime(
|
||||
telegram=telegram, audit_log=audit_log, kill_switch=kill_switch
|
||||
)
|
||||
|
||||
deribit = DeribitClient(_client("deribit"))
|
||||
deribit = DeribitClient(_client("deribit"), _client("unified"))
|
||||
macro = MacroClient(_client("macro"))
|
||||
sentiment = SentimentClient(_client("sentiment"))
|
||||
hyperliquid = HyperliquidClient(_client("hyperliquid"))
|
||||
|
||||
Reference in New Issue
Block a user