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_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:
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user