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:
Adriano Dal Pastro
2026-05-29 19:55:04 +00:00
parent 16d73c309a
commit 839285d75b
5 changed files with 77 additions and 22 deletions
+3
View File
@@ -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_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_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} 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: services:
cerbero-bite: cerbero-bite:
+59 -20
View File
@@ -120,12 +120,29 @@ def _to_decimal(value: Any) -> Decimal | None:
class DeribitClient: class DeribitClient:
SERVICE = "deribit" SERVICE = "deribit"
def __init__(self, http: HttpToolClient) -> None: def __init__(
self, http: HttpToolClient, unified: HttpToolClient | None = None
) -> None:
if http.service != self.SERVICE: if http.service != self.SERVICE:
raise ValueError( raise ValueError(
f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'" f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'"
) )
self._http = http 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 # Environment / health
@@ -206,7 +223,16 @@ class DeribitClient:
limit: int = 500, limit: int = 500,
) -> list[InstrumentMeta]: ) -> list[InstrumentMeta]:
"""Return option instruments matching the filters as typed metadata.""" """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: if expiry_from is not None:
body["expiry_from"] = expiry_from.date().isoformat() body["expiry_from"] = expiry_from.date().isoformat()
if expiry_to is not None: if expiry_to is not None:
@@ -214,28 +240,31 @@ class DeribitClient:
if min_open_interest is not None: if min_open_interest is not None:
body["min_open_interest"] = min_open_interest 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 [] instruments = raw.get("instruments") or []
out: list[InstrumentMeta] = [] out: list[InstrumentMeta] = []
for entry in instruments: for entry in instruments:
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
name = entry.get("name") name = entry.get("symbol")
if not isinstance(name, str): if not isinstance(name, str):
continue continue
try: try:
strike, expiry, option_type = _parse_instrument(name) strike, expiry, option_type = _parse_instrument(name)
except McpDataAnomalyError: except McpDataAnomalyError:
continue continue
native = entry.get("native") if isinstance(entry.get("native"), dict) else {}
out.append( out.append(
InstrumentMeta( InstrumentMeta(
name=name, name=name,
strike=strike, strike=strike,
expiry=expiry, expiry=expiry,
option_type=option_type, 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")), 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 return out
@@ -291,13 +320,17 @@ class DeribitClient:
in :func:`compute_bias`. Returns ``None`` when the chain has no in :func:`compute_bias`. Returns ``None`` when the chain has no
data in the window. 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", "get_historical",
{ {
"exchange": self.SERVICE,
"instrument": instrument, "instrument": instrument,
"start_date": start.date().isoformat(), "start_date": start.date().isoformat(),
"end_date": end.date().isoformat(), "end_date": end.date().isoformat(),
"resolution": resolution, "interval": resolution.lower(),
}, },
) )
candles = (raw or {}).get("candles") or [] candles = (raw or {}).get("candles") or []
@@ -422,28 +455,34 @@ class DeribitClient:
resolution: str = "1h", resolution: str = "1h",
) -> Decimal | None: ) -> Decimal | None:
"""Return the most recent ADX(14) value, or ``None`` when missing.""" """Return the most recent ADX(14) value, or ``None`` when missing."""
raw = await self._http.call( # MCP V2: indicators are computed by the unified ``get_indicators``
"get_technical_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, "instrument": instrument,
"indicators": ["adx"], "indicators": ["adx"],
"start_date": start.date().isoformat(), "start_date": start.date().isoformat(),
"end_date": end.date().isoformat(), "end_date": end.date().isoformat(),
"resolution": resolution, "interval": resolution.lower(),
}, },
) )
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None return None
# The MCP server returns either a top-level dict with the indicators = raw.get("indicators")
# indicator keyed by name, or a list of points. Be tolerant. if not isinstance(indicators, dict):
adx_payload = raw.get("adx") or raw.get("ADX") or raw.get("indicators", {}) return None
if isinstance(adx_payload, list) and adx_payload: adx_block = indicators.get("adx")
tail = adx_payload[-1] if isinstance(adx_block, dict):
value = tail.get("value") if isinstance(tail, dict) else tail value = adx_block.get("adx")
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")
return None if value is None else Decimal(str(value)) 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 return None
async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]: async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
+13
View File
@@ -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) @dataclass(frozen=True)
class McpEndpoints: class McpEndpoints:
"""Resolved per-service URLs.""" """Resolved per-service URLs."""
@@ -70,6 +78,7 @@ class McpEndpoints:
hyperliquid: str hyperliquid: str
macro: str macro: str
sentiment: str sentiment: str
unified: str = _DEFAULT_UNIFIED_URL
def for_service(self, name: str) -> str: def for_service(self, name: str) -> str:
try: 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(): for name, (host, port, env_var) in MCP_SERVICES.items():
override = e.get(env_var) override = e.get(env_var)
resolved[name] = override.rstrip("/") if override else _default_url(host, port) 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) return McpEndpoints(**resolved)
+1 -1
View File
@@ -191,7 +191,7 @@ async def _fetch_balances_async(*, timeout_s: float = 8.0) -> BalancesSnapshot:
client=http_client, client=http_client,
) )
deribit = DeribitClient(_client("deribit")) deribit = DeribitClient(_client("deribit"), _client("unified"))
hl = HyperliquidClient(_client("hyperliquid")) hl = HyperliquidClient(_client("hyperliquid"))
macro = MacroClient(_client("macro")) macro = MacroClient(_client("macro"))
+1 -1
View File
@@ -158,7 +158,7 @@ def build_runtime(
telegram=telegram, audit_log=audit_log, kill_switch=kill_switch telegram=telegram, audit_log=audit_log, kill_switch=kill_switch
) )
deribit = DeribitClient(_client("deribit")) deribit = DeribitClient(_client("deribit"), _client("unified"))
macro = MacroClient(_client("macro")) macro = MacroClient(_client("macro"))
sentiment = SentimentClient(_client("sentiment")) sentiment = SentimentClient(_client("sentiment"))
hyperliquid = HyperliquidClient(_client("hyperliquid")) hyperliquid = HyperliquidClient(_client("hyperliquid"))