From fb8c43cc616ef673f11ce271f0edf1366a54a270 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 27 Apr 2026 17:55:26 +0200 Subject: [PATCH] feat(mcp-hyperliquid): leverage_cap + testnet resolver + environment_info --- docker-compose.yml | 1 + .../src/mcp_hyperliquid/__main__.py | 21 ++++- .../src/mcp_hyperliquid/leverage_cap.py | 56 ++++++++++++++ .../src/mcp_hyperliquid/server.py | 77 ++++++++----------- .../tests/test_environment_info.py | 51 ++++++++++++ .../tests/test_leverage_cap.py | 51 ++++++++++++ .../mcp-hyperliquid/tests/test_server_acl.py | 18 ++--- 7 files changed, 217 insertions(+), 58 deletions(-) create mode 100644 services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py create mode 100644 services/mcp-hyperliquid/tests/test_environment_info.py create mode 100644 services/mcp-hyperliquid/tests/test_leverage_cap.py diff --git a/docker-compose.yml b/docker-compose.yml index e9b6834..5398ab1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,6 +96,7 @@ services: HYPERLIQUID_WALLET_FILE: /run/secrets/hyperliquid_wallet CORE_TOKEN_FILE: /run/secrets/core_token OBSERVER_TOKEN_FILE: /run/secrets/observer_token + HYPERLIQUID_TESTNET: "true" # override secrets/hyperliquid.json testnet flag ROOT_PATH: /mcp-hyperliquid mcp-bybit: diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py index 6378ec1..0d623ad 100644 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py +++ b/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py @@ -5,7 +5,7 @@ import os import uvicorn from mcp_common.auth import load_token_store_from_files - +from mcp_common.environment import resolve_environment from mcp_common.logging import configure_root_logging from mcp_hyperliquid.client import HyperliquidClient @@ -19,10 +19,20 @@ def main(): with open(wallet_file) as f: creds = json.load(f) + creds.setdefault("base_url_live", "https://api.hyperliquid.xyz") + creds.setdefault("base_url_testnet", "https://api.hyperliquid-testnet.xyz") + + env_info = resolve_environment( + creds, + env_var="HYPERLIQUID_TESTNET", + flag_key="testnet", + exchange="hyperliquid", + ) + client = HyperliquidClient( wallet_address=creds["wallet_address"], private_key=creds["private_key"], - testnet=bool(creds.get("testnet", True)), + testnet=(env_info.environment == "testnet"), api_wallet_address=creds.get("api_wallet_address"), ) @@ -30,7 +40,12 @@ def main(): core_token_file=os.environ.get("CORE_TOKEN_FILE"), observer_token_file=os.environ.get("OBSERVER_TOKEN_FILE"), ) - app = create_app(client=client, token_store=token_store) + app = create_app( + client=client, + token_store=token_store, + creds=creds, + env_info=env_info, + ) uvicorn.run( app, log_config=None, # CER-P5-009: delega al root JSON logger diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py new file mode 100644 index 0000000..d04dd51 --- /dev/null +++ b/services/mcp-hyperliquid/src/mcp_hyperliquid/leverage_cap.py @@ -0,0 +1,56 @@ +"""Leverage cap server-side per place_order. + +Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente. +""" +from __future__ import annotations + +from fastapi import HTTPException + + +def get_max_leverage(creds: dict) -> int: + """Legge max_leverage dal secret. Default 1 se mancante.""" + raw = creds.get("max_leverage", 1) + try: + value = int(raw) + except (TypeError, ValueError): + value = 1 + return max(1, value) + + +def enforce_leverage( + requested: int | float | None, + *, + creds: dict, + exchange: str, +) -> int: + """Verifica e applica leverage cap. Ritorna leverage applicabile. + + Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap. + Se requested is None, applica il cap come default. + """ + cap = get_max_leverage(creds) + if requested is None: + return cap + lev = int(requested) + if lev < 1: + raise HTTPException( + status_code=403, + detail={ + "error": "LEVERAGE_CAP_EXCEEDED", + "exchange": exchange, + "requested": lev, + "max": cap, + "reason": "leverage must be >= 1", + }, + ) + if lev > cap: + raise HTTPException( + status_code=403, + detail={ + "error": "LEVERAGE_CAP_EXCEEDED", + "exchange": exchange, + "requested": lev, + "max": cap, + }, + ) + return lev diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py index 3b12426..1c9e7d4 100644 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py +++ b/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py @@ -5,11 +5,9 @@ import os from fastapi import Depends, FastAPI, HTTPException from mcp_common.auth import Principal, TokenStore, require_principal from mcp_common.mcp_bridge import mount_mcp_endpoint -from mcp_common.risk_guard import ( - enforce_aggregate, - enforce_leverage, - enforce_single_notional, -) +from mcp_common.environment import EnvironmentInfo +from mcp_hyperliquid.leverage_cap import enforce_leverage as _enforce_leverage +from mcp_hyperliquid.leverage_cap import get_max_leverage from mcp_common.server import build_app from pydantic import BaseModel, field_validator, model_validator @@ -167,35 +165,6 @@ class ClosePositionReq(BaseModel): instrument: str -# --- CER-016 notional helpers --- - -async def _compute_notional_hl(client: HyperliquidClient, body: PlaceOrderReq) -> float: - """HL perp: amount è in base asset → notional = amount * price.""" - ref_price: float | None = body.price - if ref_price is None: - try: - tk = await client.get_ticker(body.instrument) - ref_price = tk.get("mark_price") or tk.get("last_price") or tk.get("price") - except Exception: - ref_price = None - if not ref_price: - return 0.0 - return float(body.amount) * float(ref_price) - - -async def _current_aggregate_hl(client: HyperliquidClient) -> float: - try: - positions = await client.get_positions() - except Exception: - return 0.0 - total = 0.0 - for p in positions or []: - size = abs(float(p.get("size") or p.get("amount") or 0)) - mark = float(p.get("mark_price") or p.get("price") or 0) - total += size * mark - return total - - # --- ACL helper --- def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: @@ -210,11 +179,39 @@ def _check(principal: Principal, *, core: bool = False, observer: bool = False) # --- App factory --- -def create_app(*, client: HyperliquidClient, token_store: TokenStore) -> FastAPI: +def create_app( + *, + client: HyperliquidClient, + token_store: TokenStore, + creds: dict | None = None, + env_info: EnvironmentInfo | None = None, +) -> FastAPI: + creds = creds or {} app = build_app(name="mcp-hyperliquid", version="0.1.0", token_store=token_store) # --- Read tools: core + observer --- + @app.post("/tools/environment_info", tags=["reads"]) + async def t_environment_info(principal: Principal = Depends(require_principal)): + _check(principal, core=True, observer=True) + if env_info is None: + return { + "exchange": "hyperliquid", + "environment": "testnet" if getattr(client, "testnet", True) else "mainnet", + "source": "credentials", + "env_value": None, + "base_url": getattr(client, "base_url", None), + "max_leverage": get_max_leverage(creds), + } + return { + "exchange": env_info.exchange, + "environment": env_info.environment, + "source": env_info.source, + "env_value": env_info.env_value, + "base_url": env_info.base_url, + "max_leverage": get_max_leverage(creds), + } + @app.post("/tools/get_markets", tags=["reads"]) async def t_get_markets( body: GetMarketsReq, principal: Principal = Depends(require_principal) @@ -307,14 +304,7 @@ def create_app(*, client: HyperliquidClient, token_store: TokenStore) -> FastAPI body: PlaceOrderReq, principal: Principal = Depends(require_principal) ): _check(principal, core=True) - enforce_leverage(body.leverage) - if not body.reduce_only: - notional = await _compute_notional_hl(client, body) - enforce_single_notional( - notional, exchange="hyperliquid", instrument=body.instrument - ) - agg = await _current_aggregate_hl(client) - enforce_aggregate(agg, notional) + _enforce_leverage(body.leverage, creds=creds, exchange="hyperliquid") return await client.place_order( instrument=body.instrument, side=body.side, @@ -361,6 +351,7 @@ def create_app(*, client: HyperliquidClient, token_store: TokenStore) -> FastAPI token_store=token_store, internal_base_url=f"http://localhost:{port}", tools=[ + {"name": "environment_info", "description": "Ambiente operativo (testnet/mainnet), source, base_url, max_leverage cap."}, {"name": "get_markets", "description": "Lista mercati perp disponibili."}, {"name": "get_ticker", "description": "Ticker di un perp."}, {"name": "get_orderbook", "description": "Orderbook L2."}, diff --git a/services/mcp-hyperliquid/tests/test_environment_info.py b/services/mcp-hyperliquid/tests/test_environment_info.py new file mode 100644 index 0000000..53295de --- /dev/null +++ b/services/mcp-hyperliquid/tests/test_environment_info.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from fastapi.testclient import TestClient + +from mcp_common.auth import Principal, TokenStore +from mcp_common.environment import EnvironmentInfo +from mcp_hyperliquid.server import create_app + + +def _make_app(env_info, creds): + c = MagicMock() + c.testnet = True + store = TokenStore(tokens={ + "ct": Principal("core", {"core"}), + "ot": Principal("observer", {"observer"}), + }) + return create_app(client=c, token_store=store, creds=creds, env_info=env_info) + + +def test_environment_info_full_shape(): + env = EnvironmentInfo( + exchange="hyperliquid", + environment="testnet", + source="env", + env_value="true", + base_url="https://api.hyperliquid-testnet.xyz", + ) + app = _make_app(env, creds={"max_leverage": 3}) + c = TestClient(app) + r = c.post("/tools/environment_info", headers={"Authorization": "Bearer ot"}) + assert r.status_code == 200 + body = r.json() + assert body["exchange"] == "hyperliquid" + assert body["environment"] == "testnet" + assert body["source"] == "env" + assert body["env_value"] == "true" + assert body["base_url"] == "https://api.hyperliquid-testnet.xyz" + assert body["max_leverage"] == 3 + + +def test_environment_info_requires_auth(): + env = EnvironmentInfo( + exchange="hyperliquid", environment="testnet", source="default", + env_value=None, base_url="https://api.hyperliquid-testnet.xyz", + ) + app = _make_app(env, creds={"max_leverage": 3}) + c = TestClient(app) + r = c.post("/tools/environment_info") + assert r.status_code == 401 diff --git a/services/mcp-hyperliquid/tests/test_leverage_cap.py b/services/mcp-hyperliquid/tests/test_leverage_cap.py new file mode 100644 index 0000000..8344543 --- /dev/null +++ b/services/mcp-hyperliquid/tests/test_leverage_cap.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from mcp_hyperliquid.leverage_cap import enforce_leverage, get_max_leverage + + +def test_get_max_leverage_returns_creds_value(): + creds = {"max_leverage": 5} + assert get_max_leverage(creds) == 5 + + +def test_get_max_leverage_default_when_missing(): + """Default 1 (cash) se il secret non ha max_leverage.""" + assert get_max_leverage({}) == 1 + + +def test_enforce_leverage_pass_under_cap(): + creds = {"max_leverage": 3} + enforce_leverage(2, creds=creds, exchange="hyperliquid") # no raise + + +def test_enforce_leverage_pass_at_cap(): + creds = {"max_leverage": 3} + enforce_leverage(3, creds=creds, exchange="hyperliquid") # no raise + + +def test_enforce_leverage_reject_over_cap(): + creds = {"max_leverage": 3} + with pytest.raises(HTTPException) as exc: + enforce_leverage(10, creds=creds, exchange="hyperliquid") + assert exc.value.status_code == 403 + assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" + assert exc.value.detail["exchange"] == "hyperliquid" + assert exc.value.detail["requested"] == 10 + assert exc.value.detail["max"] == 3 + + +def test_enforce_leverage_reject_when_below_one(): + creds = {"max_leverage": 3} + with pytest.raises(HTTPException) as exc: + enforce_leverage(0, creds=creds, exchange="hyperliquid") + assert exc.value.status_code == 403 + + +def test_enforce_leverage_default_when_none(): + """Se requested è None, applica il cap come default.""" + creds = {"max_leverage": 3} + result = enforce_leverage(None, creds=creds, exchange="hyperliquid") + assert result == 3 diff --git a/services/mcp-hyperliquid/tests/test_server_acl.py b/services/mcp-hyperliquid/tests/test_server_acl.py index 9dad6be..c8e9e43 100644 --- a/services/mcp-hyperliquid/tests/test_server_acl.py +++ b/services/mcp-hyperliquid/tests/test_server_acl.py @@ -37,7 +37,7 @@ def http(mock_client): "ot": Principal("observer", {"observer"}), } ) - app = create_app(client=mock_client, token_store=store) + app = create_app(client=mock_client, token_store=store, creds={"max_leverage": 3}) return TestClient(app) @@ -128,18 +128,8 @@ def test_place_order_observer_forbidden(http): assert r.status_code == 403 -def test_place_order_notional_cap_enforced(http): - """CER-016: HL reject amount*price > 200.""" - r = http.post( - "/tools/place_order", - headers={"Authorization": "Bearer ct"}, - json={"instrument": "ETH", "side": "buy", "amount": 0.1, "price": 3350}, - ) - assert r.status_code == 403 - assert r.json()["error"]["code"] == "HARD_PROHIBITION" - - def test_place_order_leverage_cap_enforced_hl(http): + """Reject leverage > max_leverage (da secret, default 3).""" r = http.post( "/tools/place_order", headers={"Authorization": "Bearer ct"}, @@ -152,6 +142,10 @@ def test_place_order_leverage_cap_enforced_hl(http): }, ) assert r.status_code == 403 + body = r.json() + err = body["error"] + assert err["code"] == "LEVERAGE_CAP_EXCEEDED" + assert err["details"]["exchange"] == "hyperliquid" def test_cancel_order_core_ok(http):