From 839285d75bf1d20f3aabde75cc962b63dbf01416 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 29 May 2026 19:55:04 +0000 Subject: [PATCH] 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) --- docker-compose.yml | 3 + src/cerbero_bite/clients/deribit.py | 79 ++++++++++++++++++------ src/cerbero_bite/config/mcp_endpoints.py | 13 ++++ src/cerbero_bite/gui/live_data.py | 2 +- src/cerbero_bite/runtime/dependencies.py | 2 +- 5 files changed, 77 insertions(+), 22 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7eadf3e..b8f3b73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/cerbero_bite/clients/deribit.py b/src/cerbero_bite/clients/deribit.py index cd5b30b..9a92ca4 100644 --- a/src/cerbero_bite/clients/deribit.py +++ b/src/cerbero_bite/clients/deribit.py @@ -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": , "+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]: diff --git a/src/cerbero_bite/config/mcp_endpoints.py b/src/cerbero_bite/config/mcp_endpoints.py index b08efc9..262d655 100644 --- a/src/cerbero_bite/config/mcp_endpoints.py +++ b/src/cerbero_bite/config/mcp_endpoints.py @@ -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) diff --git a/src/cerbero_bite/gui/live_data.py b/src/cerbero_bite/gui/live_data.py index 63b96bc..553257b 100644 --- a/src/cerbero_bite/gui/live_data.py +++ b/src/cerbero_bite/gui/live_data.py @@ -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")) diff --git a/src/cerbero_bite/runtime/dependencies.py b/src/cerbero_bite/runtime/dependencies.py index 634fab9..bbe8466 100644 --- a/src/cerbero_bite/runtime/dependencies.py +++ b/src/cerbero_bite/runtime/dependencies.py @@ -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"))