feat: import 6 MCP services + common workspace

This commit is contained in:
AdrianoDev
2026-04-27 17:34:14 +02:00
parent 9676f22a8e
commit 6fc3d1d94f
67 changed files with 10693 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
[project]
name = "mcp-hyperliquid"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"option-mcp-common",
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"httpx>=0.27",
"pydantic>=2.6",
"hyperliquid-python-sdk>=0.3",
"eth-account>=0.11",
]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "pytest-httpx>=0.30"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mcp_hyperliquid"]
[tool.uv.sources]
option-mcp-common = { workspace = true }
[project.scripts]
mcp-hyperliquid = "mcp_hyperliquid.__main__:main"
@@ -0,0 +1,43 @@
from __future__ import annotations
import json
import os
import uvicorn
from option_mcp_common.auth import load_token_store_from_files
from option_mcp_common.logging import configure_root_logging
from mcp_hyperliquid.client import HyperliquidClient
from mcp_hyperliquid.server import create_app
configure_root_logging() # CER-P5-009
def main():
wallet_file = os.environ["HYPERLIQUID_WALLET_FILE"]
with open(wallet_file) as f:
creds = json.load(f)
client = HyperliquidClient(
wallet_address=creds["wallet_address"],
private_key=creds["private_key"],
testnet=bool(creds.get("testnet", True)),
api_wallet_address=creds.get("api_wallet_address"),
)
token_store = load_token_store_from_files(
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)
uvicorn.run(
app,
log_config=None, # CER-P5-009: delega al root JSON logger
host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "9012")),
)
if __name__ == "__main__":
main()
@@ -0,0 +1,577 @@
"""Hyperliquid REST API client for perpetual futures trading."""
from __future__ import annotations
import asyncio
import datetime as _dt
from typing import Any
import httpx
from option_mcp_common import indicators as ind
BASE_LIVE = "https://api.hyperliquid.xyz"
BASE_TESTNET = "https://api.hyperliquid-testnet.xyz"
RESOLUTION_MAP = {
"1m": "1m",
"5m": "5m",
"15m": "15m",
"1h": "1h",
"4h": "4h",
"1d": "1d",
}
try:
from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.utils import constants as hl_constants
_SDK_AVAILABLE = True
except ImportError: # pragma: no cover
_SDK_AVAILABLE = False
def _to_ms(date_str: str) -> int:
try:
dt = _dt.datetime.fromisoformat(date_str)
except ValueError:
dt = _dt.datetime.strptime(date_str, "%Y-%m-%d")
return int(dt.timestamp() * 1000)
class HyperliquidClient:
"""Async client for the Hyperliquid API.
Read operations use direct HTTP calls via httpx against /info.
Write operations delegate to hyperliquid-python-sdk for EIP-712 signing.
"""
def __init__(
self,
wallet_address: str,
private_key: str,
testnet: bool = True,
api_wallet_address: str | None = None,
):
self.wallet_address = wallet_address
self.private_key = private_key
self.testnet = testnet
self.api_wallet_address = api_wallet_address or wallet_address
self.base_url = BASE_TESTNET if testnet else BASE_LIVE
self._exchange: Any | None = None
# ── SDK exchange (lazy) ────────────────────────────────────
def _get_exchange(self) -> Any:
"""Return (and cache) an SDK Exchange instance for write ops."""
if not _SDK_AVAILABLE:
raise RuntimeError(
"hyperliquid-python-sdk is not installed; write operations unavailable."
)
if self._exchange is None:
account = Account.from_key(self.private_key)
base_url = (
hl_constants.TESTNET_API_URL if self.testnet else hl_constants.MAINNET_API_URL
)
empty_spot_meta: dict[str, Any] = {"universe": [], "tokens": []}
self._exchange = Exchange(
account,
base_url,
account_address=self.wallet_address,
spot_meta=empty_spot_meta,
)
return self._exchange
# ── Internal helpers ───────────────────────────────────────
async def _post(self, payload: dict[str, Any]) -> Any:
"""POST JSON to the /info endpoint."""
async with httpx.AsyncClient(timeout=15) as http:
resp = await http.post(f"{self.base_url}/info", json=payload)
resp.raise_for_status()
return resp.json()
@staticmethod
async def _run_sync(func: Any, *args: Any, **kwargs: Any) -> Any:
"""Run a synchronous SDK call in the default executor."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, lambda: func(*args, **kwargs))
# ── Read tools ─────────────────────────────────────────────
async def get_markets(self) -> list[dict[str, Any]]:
"""List all perp markets with metadata and current stats."""
data = await self._post({"type": "metaAndAssetCtxs"})
universe = data[0]["universe"]
ctx_list = data[1]
markets = []
for meta, ctx in zip(universe, ctx_list, strict=True):
markets.append(
{
"asset": meta["name"],
"mark_price": float(ctx.get("markPx", 0)),
"funding_rate": float(ctx.get("funding", 0)),
"open_interest": float(ctx.get("openInterest", 0)),
"volume_24h": float(ctx.get("dayNtlVlm", 0)),
"max_leverage": int(meta.get("maxLeverage", 1)),
}
)
return markets
async def get_ticker(self, instrument: str) -> dict[str, Any]:
"""Get ticker information for a specific asset."""
markets = await self.get_markets()
for m in markets:
if m["asset"].upper() == instrument.upper():
return {
"asset": m["asset"],
"mark_price": m["mark_price"],
"mid_price": m["mark_price"],
"funding_rate": m["funding_rate"],
"open_interest": m["open_interest"],
"volume_24h": m["volume_24h"],
"premium": 0.0,
}
return {"error": f"Asset {instrument} not found"}
async def get_orderbook(self, instrument: str, depth: int = 10) -> dict[str, Any]:
"""Get L2 order book for an asset."""
data = await self._post({"type": "l2Book", "coin": instrument.upper()})
levels = data.get("levels", [[], []])
bids = [{"price": float(b["px"]), "size": float(b["sz"])} for b in levels[0][:depth]]
asks = [{"price": float(a["px"]), "size": float(a["sz"])} for a in levels[1][:depth]]
return {"asset": instrument, "bids": bids, "asks": asks}
async def get_positions(self) -> list[dict[str, Any]]:
"""Get open positions for the wallet."""
data = await self._post(
{"type": "clearinghouseState", "user": self.wallet_address}
)
positions = []
for ap in data.get("assetPositions", []):
pos = ap.get("position", {})
size = float(pos.get("szi", 0))
if size == 0:
continue
leverage_data = pos.get("leverage", {})
lev_value = (
leverage_data.get("value", "1")
if isinstance(leverage_data, dict)
else str(leverage_data)
)
positions.append(
{
"asset": pos.get("coin", ""),
"size": abs(size),
"direction": "long" if size > 0 else "short",
"entry_price": float(pos.get("entryPx", 0) or 0),
"unrealized_pnl": float(pos.get("unrealizedPnl", 0)),
"leverage": float(lev_value),
"liquidation_price": float(pos.get("liquidationPx", 0) or 0),
}
)
return positions
async def get_account_summary(self) -> dict[str, Any]:
"""Get account summary (equity, balance, margin) including spot balances.
Con Unified Account, spot USDC e perps condividono collaterale.
`spot_fetch_ok` / `perps_fetch_ok` indicano se i due lati sono stati
letti correttamente: se uno dei due è False il chiamante dovrebbe
considerare `equity`/`available_balance` un lower bound.
"""
perps_fetch_ok = True
perps_equity = 0.0
perps_available = 0.0
margin_used = 0.0
unrealized_pnl = 0.0
try:
data = await self._post(
{"type": "clearinghouseState", "user": self.wallet_address}
)
margin = data.get("marginSummary") or {}
perps_equity = float(margin.get("accountValue", 0) or 0)
perps_available = float(margin.get("totalRawUsd", 0) or 0)
margin_used = float(margin.get("totalMarginUsed", 0) or 0)
unrealized_pnl = float(margin.get("totalNtlPos", 0) or 0)
except Exception:
perps_fetch_ok = False
spot_fetch_ok = True
spot_usdc = 0.0
try:
spot_data = await self._post(
{"type": "spotClearinghouseState", "user": self.wallet_address}
)
for b in spot_data.get("balances", []) or []:
if b.get("coin") == "USDC":
spot_usdc = float(b.get("total", 0) or 0)
except Exception:
spot_fetch_ok = False
total_equity = perps_equity + spot_usdc
total_available = perps_available + spot_usdc
return {
"equity": total_equity,
"perps_equity": perps_equity,
"perps_available": perps_available,
"spot_usdc": spot_usdc,
"available_balance": total_available,
"margin_used": margin_used,
"unrealized_pnl": unrealized_pnl,
"perps_fetch_ok": perps_fetch_ok,
"spot_fetch_ok": spot_fetch_ok,
}
async def get_trade_history(self, limit: int = 100) -> list[dict[str, Any]]:
"""Get recent trade fills."""
data = await self._post({"type": "userFills", "user": self.wallet_address})
trades = []
for t in data[:limit]:
trades.append(
{
"asset": t.get("coin", ""),
"side": t.get("side", ""),
"size": float(t.get("sz", 0)),
"price": float(t.get("px", 0)),
"fee": float(t.get("fee", 0)),
"timestamp": t.get("time", ""),
}
)
return trades
async def get_historical(
self, instrument: str, start_date: str, end_date: str, resolution: str = "1h"
) -> dict[str, Any]:
"""Get OHLCV candles for an asset."""
start_ms = _to_ms(start_date)
end_ms = _to_ms(end_date)
interval = RESOLUTION_MAP.get(resolution, resolution)
data = await self._post(
{
"type": "candleSnapshot",
"req": {
"coin": instrument.upper(),
"interval": interval,
"startTime": start_ms,
"endTime": end_ms,
},
}
)
candles = []
for c in data:
candles.append(
{
"timestamp": c.get("t", 0),
"open": float(c.get("o", 0)),
"high": float(c.get("h", 0)),
"low": float(c.get("l", 0)),
"close": float(c.get("c", 0)),
"volume": float(c.get("v", 0)),
}
)
return {"candles": candles}
async def get_open_orders(self) -> list[dict[str, Any]]:
"""Get all open orders for the wallet."""
data = await self._post({"type": "openOrders", "user": self.wallet_address})
orders = []
for o in data:
orders.append(
{
"oid": o.get("oid"),
"asset": o.get("coin", ""),
"side": o.get("side", ""),
"size": float(o.get("sz", 0)),
"price": float(o.get("limitPx", 0)),
"order_type": o.get("orderType", ""),
}
)
return orders
async def basis_spot_perp(self, asset: str) -> dict[str, Any]:
asset = asset.upper()
# Spot reference price from Coinbase (mainnet reference, anche se HL è testnet)
spot_price: float | None = None
spot_source = "coinbase"
try:
async with httpx.AsyncClient(timeout=8) as c:
resp = await c.get(f"https://api.coinbase.com/v2/prices/{asset}-USD/spot")
if resp.status_code == 200:
spot_price = float(resp.json().get("data", {}).get("amount"))
except Exception:
spot_price = None
if spot_price is None:
try:
async with httpx.AsyncClient(timeout=8) as c:
resp = await c.get(
"https://api.kraken.com/0/public/Ticker", params={"pair": f"{asset}USD"}
)
if resp.status_code == 200:
res = resp.json().get("result") or {}
first = next(iter(res.values()), {})
price = (first.get("c") or [None])[0]
spot_price = float(price) if price else None
spot_source = "kraken"
except Exception:
pass
# Perp price + funding from HL
try:
ctx = await self._post({"type": "metaAndAssetCtxs"})
universe = ctx[0]["universe"]
ctx_list = ctx[1]
perp_price = None
funding = None
for meta, c in zip(universe, ctx_list, strict=True):
if meta["name"].upper() == asset:
perp_price = float(c.get("markPx", 0))
funding = float(c.get("funding", 0))
break
except Exception:
perp_price = None
funding = None
if spot_price is None or perp_price is None:
return {
"asset": asset,
"spot_price": spot_price,
"perp_price": perp_price,
"error": "missing spot or perp price",
}
basis_abs = perp_price - spot_price
basis_pct = round(basis_abs / spot_price * 100, 4)
basis_ann_funding = (
round(funding * 24 * 365 * 100, 2) if funding is not None else None
)
carry_opp = bool(
basis_ann_funding is not None
and basis_ann_funding > 5
and (funding or 0) > 0.0001
)
return {
"asset": asset,
"spot_price": spot_price,
"spot_source": spot_source,
"perp_price": perp_price,
"basis_absolute": round(basis_abs, 4),
"basis_pct": basis_pct,
"current_funding_hourly": funding,
"basis_annualized_funding_only": basis_ann_funding,
"carry_opportunity": carry_opp,
"testnet": self.testnet,
"data_timestamp": _dt.datetime.now(_dt.UTC).isoformat(),
}
async def get_funding_rate(self, instrument: str) -> dict[str, Any]:
"""Get current and recent historical funding rates for an asset."""
data = await self._post({"type": "metaAndAssetCtxs"})
universe = data[0]["universe"]
ctx_list = data[1]
current_rate = None
for meta, ctx in zip(universe, ctx_list, strict=True):
if meta["name"].upper() == instrument.upper():
current_rate = float(ctx.get("funding", 0))
break
if current_rate is None:
return {"error": f"Asset {instrument} not found"}
# Fetch funding history (last 7 days)
end_ms = int(_dt.datetime.utcnow().timestamp() * 1000)
start_ms = end_ms - 7 * 24 * 3600 * 1000
history_data = await self._post(
{
"type": "fundingHistory",
"coin": instrument.upper(),
"startTime": start_ms,
"endTime": end_ms,
}
)
history = []
for entry in history_data:
history.append(
{
"timestamp": entry.get("time", 0),
"funding_rate": float(entry.get("fundingRate", 0)),
}
)
return {
"asset": instrument,
"current_funding_rate": current_rate,
"history": history,
}
async def get_indicators(
self,
instrument: str,
indicators: list[str],
start_date: str,
end_date: str,
resolution: str = "1h",
) -> dict[str, Any]:
"""Compute technical indicators over OHLCV data."""
historical = await self.get_historical(instrument, start_date, end_date, resolution)
candles = historical.get("candles", [])
closes = [c["close"] for c in candles]
highs = [c["high"] for c in candles]
lows = [c["low"] for c in candles]
result: dict[str, Any] = {}
for indicator in indicators:
name = indicator.lower()
if name == "sma":
result["sma"] = ind.sma(closes, 20)
elif name == "rsi":
result["rsi"] = ind.rsi(closes)
elif name == "atr":
result["atr"] = ind.atr(highs, lows, closes)
elif name == "macd":
result["macd"] = ind.macd(closes)
elif name == "adx":
result["adx"] = ind.adx(highs, lows, closes)
else:
result[name] = None
return result
# ── Write tools (via SDK) ──────────────────────────────────
async def place_order(
self,
instrument: str,
side: str,
amount: float,
type: str = "limit",
price: float | None = None,
reduce_only: bool = False,
) -> dict[str, Any]:
"""Place an order on Hyperliquid using the SDK for EIP-712 signing."""
exchange = self._get_exchange()
is_buy = side.lower() in ("buy", "long")
coin = instrument.upper()
if type == "market":
ot: dict[str, Any] = {"limit": {"tif": "Ioc"}}
if price is None:
ticker = await self.get_ticker(coin)
mark = ticker.get("mark_price", 0)
price = round(mark * 1.03, 1) if is_buy else round(mark * 0.97, 1)
elif type in ("stop_market", "stop_loss"):
ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "sl"}}
elif type == "take_profit":
ot = {"trigger": {"triggerPx": float(price), "isMarket": True, "tpsl": "tp"}}
else:
ot = {"limit": {"tif": "Gtc"}}
if price is None:
return {"error": "price is required for limit orders"}
result = await self._run_sync(
exchange.order, coin, is_buy, amount, price, ot, reduce_only
)
status = result.get("status", "unknown")
response = result.get("response", {})
if isinstance(response, str):
return {
"status": status,
"error": response,
"order_id": "",
"filled_size": 0,
"avg_fill_price": 0,
}
statuses = response.get("data", {}).get("statuses", [{}])
first = statuses[0] if statuses else {}
if isinstance(first, str):
return {
"status": status,
"error": first,
"order_id": "",
"filled_size": 0,
"avg_fill_price": 0,
}
return {
"order_id": first.get("resting", {}).get(
"oid", first.get("filled", {}).get("oid", "")
),
"status": status,
"filled_size": float(first.get("filled", {}).get("totalSz", 0)),
"avg_fill_price": float(first.get("filled", {}).get("avgPx", 0)),
}
async def cancel_order(self, order_id: str, instrument: str) -> dict[str, Any]:
"""Cancel an existing order using the SDK."""
exchange = self._get_exchange()
result = await self._run_sync(
exchange.cancel, instrument.upper(), int(order_id)
)
status = result.get("status", "unknown")
response = result.get("response", "")
if isinstance(response, str) and status == "err":
return {"order_id": order_id, "status": status, "error": response}
return {"order_id": order_id, "status": status}
async def set_stop_loss(
self, instrument: str, stop_price: float, size: float
) -> dict[str, Any]:
"""Set a stop-loss trigger order."""
# Determine direction by checking open position
positions = await self.get_positions()
direction = "sell" # default: assume long
for pos in positions:
if pos["asset"].upper() == instrument.upper():
direction = "sell" if pos["direction"] == "long" else "buy"
if size == 0:
size = pos["size"]
break
return await self.place_order(
instrument=instrument,
side=direction,
amount=size,
type="stop_loss",
price=stop_price,
reduce_only=True,
)
async def set_take_profit(
self, instrument: str, tp_price: float, size: float
) -> dict[str, Any]:
"""Set a take-profit trigger order."""
positions = await self.get_positions()
direction = "sell" # default: assume long
for pos in positions:
if pos["asset"].upper() == instrument.upper():
direction = "sell" if pos["direction"] == "long" else "buy"
if size == 0:
size = pos["size"]
break
return await self.place_order(
instrument=instrument,
side=direction,
amount=size,
type="take_profit",
price=tp_price,
reduce_only=True,
)
async def close_position(self, instrument: str) -> dict[str, Any]:
"""Close an open position for the given asset using market_close."""
exchange = self._get_exchange()
try:
result = await self._run_sync(exchange.market_close, instrument.upper())
return {
"status": result.get("status", "unknown"),
"asset": instrument,
}
except Exception as exc:
return {"error": str(exc), "asset": instrument}
async def health(self) -> dict[str, Any]:
"""Health check — ping /info for server status."""
try:
await self._post({"type": "meta"})
return {"status": "ok", "testnet": self.testnet}
except Exception as exc:
return {"status": "error", "error": str(exc)}
@@ -0,0 +1,383 @@
from __future__ import annotations
import os
from fastapi import Depends, FastAPI, HTTPException
from option_mcp_common.auth import Principal, TokenStore, require_principal
from option_mcp_common.mcp_bridge import mount_mcp_endpoint
from option_mcp_common.risk_guard import (
enforce_aggregate,
enforce_leverage,
enforce_single_notional,
)
from option_mcp_common.server import build_app
from pydantic import BaseModel, field_validator, model_validator
from mcp_hyperliquid.client import HyperliquidClient
# --- Body models ---
class GetMarketsReq(BaseModel):
pass
class GetTickerReq(BaseModel):
instrument: str
class GetOrderbookReq(BaseModel):
instrument: str
depth: int = 10
class GetPositionsReq(BaseModel):
pass
class GetAccountSummaryReq(BaseModel):
pass
class GetTradeHistoryReq(BaseModel):
limit: int = 100
class GetHistoricalReq(BaseModel):
instrument: str | None = None
asset: str | None = None
start_date: str | None = None
end_date: str | None = None
resolution: str = "1h"
interval: str | None = None
limit: int = 50
model_config = {"extra": "allow"}
@model_validator(mode="after")
def _normalize(self):
from datetime import UTC, datetime, timedelta
sym = self.instrument or self.asset
if not sym:
raise ValueError("instrument (or asset) is required")
self.instrument = sym
if self.interval:
self.resolution = self.interval
if not self.end_date:
self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if not self.start_date:
days = max(1, self.limit // 6)
self.start_date = (
datetime.now(UTC) - timedelta(days=days)
).strftime("%Y-%m-%dT%H:%M:%S")
return self
class GetOpenOrdersReq(BaseModel):
pass
class GetFundingRateReq(BaseModel):
instrument: str
class BasisSpotPerpReq(BaseModel):
asset: str
class GetIndicatorsReq(BaseModel):
instrument: str | None = None
asset: str | None = None
indicators: list[str] = ["rsi", "atr", "macd", "adx"]
start_date: str | None = None
end_date: str | None = None
resolution: str = "1h"
interval: str | None = None
limit: int = 50
model_config = {"extra": "allow"}
@model_validator(mode="after")
def _normalize(self):
from datetime import UTC, datetime, timedelta
sym = self.instrument or self.asset
if not sym:
raise ValueError("instrument (or asset) is required")
self.instrument = sym
if self.interval:
self.resolution = self.interval
if not self.end_date:
self.end_date = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
if not self.start_date:
days = max(2, self.limit // 6)
self.start_date = (
datetime.now(UTC) - timedelta(days=days)
).strftime("%Y-%m-%dT%H:%M:%S")
return self
@field_validator("indicators", mode="before")
@classmethod
def _coerce_indicators(cls, v):
if isinstance(v, str):
import json
s = v.strip()
if s.startswith("["):
try:
parsed = json.loads(s)
if isinstance(parsed, list):
return [str(x).strip() for x in parsed if str(x).strip()]
except json.JSONDecodeError:
pass
return [x.strip() for x in s.split(",") if x.strip()]
if isinstance(v, list):
return v
raise ValueError(
"indicators must be a list like ['rsi','atr','macd'] "
"or a comma-separated string like 'rsi,atr,macd'"
)
class PlaceOrderReq(BaseModel):
instrument: str
side: str # "buy" | "sell"
amount: float
type: str = "limit"
price: float | None = None
reduce_only: bool = False
leverage: int | None = None # CER-016: None → default cap (3x)
class CancelOrderReq(BaseModel):
order_id: str
instrument: str
class SetStopLossReq(BaseModel):
instrument: str
stop_price: float
size: float
class SetTakeProfitReq(BaseModel):
instrument: str
tp_price: float
size: float
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:
allowed: set[str] = set()
if core:
allowed.add("core")
if observer:
allowed.add("observer")
if not (principal.capabilities & allowed):
raise HTTPException(403, f"capability required: {allowed}")
# --- App factory ---
def create_app(*, client: HyperliquidClient, token_store: TokenStore) -> FastAPI:
app = build_app(name="mcp-hyperliquid", version="0.1.0", token_store=token_store)
# --- Read tools: core + observer ---
@app.post("/tools/get_markets", tags=["reads"])
async def t_get_markets(
body: GetMarketsReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_markets()
@app.post("/tools/get_ticker", tags=["reads"])
async def t_get_ticker(
body: GetTickerReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_ticker(body.instrument)
@app.post("/tools/get_orderbook", tags=["reads"])
async def t_get_orderbook(
body: GetOrderbookReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_orderbook(body.instrument, body.depth)
@app.post("/tools/get_positions", tags=["reads"])
async def t_get_positions(
body: GetPositionsReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_positions()
@app.post("/tools/get_account_summary", tags=["reads"])
async def t_get_account_summary(
body: GetAccountSummaryReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_account_summary()
@app.post("/tools/get_trade_history", tags=["reads"])
async def t_get_trade_history(
body: GetTradeHistoryReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_trade_history(body.limit)
@app.post("/tools/get_historical", tags=["reads"])
async def t_get_historical(
body: GetHistoricalReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_historical(
body.instrument, body.start_date, body.end_date, body.resolution
)
@app.post("/tools/get_open_orders", tags=["reads"])
async def t_get_open_orders(
body: GetOpenOrdersReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_open_orders()
@app.post("/tools/get_funding_rate", tags=["reads"])
async def t_get_funding_rate(
body: GetFundingRateReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_funding_rate(body.instrument)
@app.post("/tools/basis_spot_perp", tags=["writes"])
async def t_basis_spot_perp(
body: BasisSpotPerpReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.basis_spot_perp(body.asset)
@app.post("/tools/get_indicators", tags=["reads"])
async def t_get_indicators(
body: GetIndicatorsReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True, observer=True)
return await client.get_indicators(
body.instrument,
body.indicators,
body.start_date,
body.end_date,
body.resolution,
)
# --- Write tools: core only ---
@app.post("/tools/place_order", tags=["writes"])
async def t_place_order(
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)
return await client.place_order(
instrument=body.instrument,
side=body.side,
amount=body.amount,
type=body.type,
price=body.price,
reduce_only=body.reduce_only,
)
@app.post("/tools/cancel_order", tags=["writes"])
async def t_cancel_order(
body: CancelOrderReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.cancel_order(body.order_id, body.instrument)
@app.post("/tools/set_stop_loss", tags=["writes"])
async def t_set_sl(
body: SetStopLossReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.set_stop_loss(body.instrument, body.stop_price, body.size)
@app.post("/tools/set_take_profit", tags=["writes"])
async def t_set_tp(
body: SetTakeProfitReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.set_take_profit(body.instrument, body.tp_price, body.size)
@app.post("/tools/close_position", tags=["writes"])
async def t_close_position(
body: ClosePositionReq, principal: Principal = Depends(require_principal)
):
_check(principal, core=True)
return await client.close_position(body.instrument)
# ───── MCP endpoint (/mcp) — bridge verso /tools/* ─────
port = int(os.environ.get("PORT", "9012"))
mount_mcp_endpoint(
app,
name="cerbero-hyperliquid",
version="0.1.0",
token_store=token_store,
internal_base_url=f"http://localhost:{port}",
tools=[
{"name": "get_markets", "description": "Lista mercati perp disponibili."},
{"name": "get_ticker", "description": "Ticker di un perp."},
{"name": "get_orderbook", "description": "Orderbook L2."},
{"name": "get_positions", "description": "Posizioni aperte."},
{"name": "get_account_summary", "description": "Account summary (spot + perp equity)."},
{"name": "get_trade_history", "description": "Storia trade."},
{"name": "get_historical", "description": "OHLCV storico."},
{"name": "get_open_orders", "description": "Ordini aperti."},
{"name": "get_funding_rate", "description": "Funding rate corrente per simbolo."},
{"name": "basis_spot_perp", "description": "Basis spot-perp annualizzato + carry opportunity detection."},
{"name": "get_indicators", "description": "Indicatori tecnici."},
{"name": "place_order", "description": "Invia ordine (CORE only)."},
{"name": "cancel_order", "description": "Cancella ordine."},
{"name": "set_stop_loss", "description": "Stop loss su posizione."},
{"name": "set_take_profit", "description": "Take profit su posizione."},
{"name": "close_position", "description": "Chiude posizione."},
],
)
return app
@@ -0,0 +1,227 @@
from __future__ import annotations
import re
import pytest
from mcp_hyperliquid.client import HyperliquidClient
from pytest_httpx import HTTPXMock
@pytest.fixture
def client():
return HyperliquidClient(
wallet_address="0xDeadBeef",
private_key="0x" + "a" * 64,
testnet=True,
)
# Shared mock responses
META_AND_CTX = [
{
"universe": [
{"name": "BTC", "maxLeverage": 50},
{"name": "ETH", "maxLeverage": 25},
]
},
[
{
"markPx": "50000.0",
"funding": "0.0001",
"openInterest": "1000.0",
"dayNtlVlm": "500000.0",
},
{
"markPx": "3000.0",
"funding": "0.00005",
"openInterest": "500.0",
"dayNtlVlm": "200000.0",
},
],
]
CLEARINGHOUSE_STATE = {
"marginSummary": {
"accountValue": "1500.0",
"totalRawUsd": "1200.0",
"totalMarginUsed": "300.0",
"totalNtlPos": "50.0",
},
"assetPositions": [
{
"position": {
"coin": "BTC",
"szi": "0.1",
"entryPx": "48000.0",
"unrealizedPnl": "200.0",
"leverage": {"value": "10"},
"liquidationPx": "40000.0",
}
}
],
}
SPOT_STATE = {"balances": [{"coin": "USDC", "total": "500.0"}]}
@pytest.mark.asyncio
async def test_get_markets(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META_AND_CTX,
)
markets = await client.get_markets()
assert len(markets) == 2
assert markets[0]["asset"] == "BTC"
assert markets[0]["mark_price"] == 50000.0
assert markets[0]["max_leverage"] == 50
@pytest.mark.asyncio
async def test_get_ticker(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META_AND_CTX,
)
result = await client.get_ticker("BTC")
assert result["asset"] == "BTC"
assert result["mark_price"] == 50000.0
assert result["funding_rate"] == 0.0001
@pytest.mark.asyncio
async def test_get_ticker_not_found(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=META_AND_CTX,
)
result = await client.get_ticker("SOL")
assert "error" in result
@pytest.mark.asyncio
async def test_get_orderbook(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json={
"levels": [
[{"px": "49990.0", "sz": "0.5"}, {"px": "49980.0", "sz": "1.0"}],
[{"px": "50010.0", "sz": "0.3"}, {"px": "50020.0", "sz": "0.8"}],
]
},
)
result = await client.get_orderbook("BTC", depth=2)
assert result["asset"] == "BTC"
assert len(result["bids"]) == 2
assert len(result["asks"]) == 2
assert result["bids"][0]["price"] == 49990.0
@pytest.mark.asyncio
async def test_get_positions(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=CLEARINGHOUSE_STATE,
)
positions = await client.get_positions()
assert len(positions) == 1
assert positions[0]["asset"] == "BTC"
assert positions[0]["direction"] == "long"
assert positions[0]["size"] == 0.1
assert positions[0]["leverage"] == 10.0
@pytest.mark.asyncio
async def test_get_account_summary(httpx_mock: HTTPXMock, client: HyperliquidClient):
# get_account_summary calls /info twice (perp + spot)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=CLEARINGHOUSE_STATE,
)
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=SPOT_STATE,
)
result = await client.get_account_summary()
assert result["perps_equity"] == 1500.0
assert result["spot_usdc"] == 500.0
assert result["equity"] == 2000.0
@pytest.mark.asyncio
async def test_get_trade_history(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"coin": "BTC", "side": "B", "sz": "0.1", "px": "50000", "fee": "0.5", "time": 1000},
{"coin": "ETH", "side": "A", "sz": "1.0", "px": "3000", "fee": "0.3", "time": 2000},
],
)
trades = await client.get_trade_history(limit=10)
assert len(trades) == 2
assert trades[0]["asset"] == "BTC"
assert trades[0]["price"] == 50000.0
@pytest.mark.asyncio
async def test_get_open_orders(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{
"oid": 12345,
"coin": "BTC",
"side": "B",
"sz": "0.05",
"limitPx": "49000",
"orderType": "Limit",
}
],
)
orders = await client.get_open_orders()
assert len(orders) == 1
assert orders[0]["oid"] == 12345
assert orders[0]["asset"] == "BTC"
@pytest.mark.asyncio
async def test_get_historical(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json=[
{"t": 1000000, "o": "49000", "h": "51000", "l": "48500", "c": "50000", "v": "100"},
],
)
result = await client.get_historical("BTC", "2024-01-01", "2024-01-02", "1h")
assert len(result["candles"]) == 1
assert result["candles"][0]["close"] == 50000.0
@pytest.mark.asyncio
async def test_health_ok(httpx_mock: HTTPXMock, client: HyperliquidClient):
httpx_mock.add_response(
url=re.compile(r"https://api\.hyperliquid-testnet\.xyz/info"),
json={"universe": []},
)
result = await client.health()
assert result["status"] in ("ok", "healthy")
assert result["testnet"] is True
@pytest.mark.asyncio
async def test_place_order_sdk_unavailable(client: HyperliquidClient):
"""place_order raises RuntimeError when SDK is not available (mocked)."""
import mcp_hyperliquid.client as mod
original = mod._SDK_AVAILABLE
mod._SDK_AVAILABLE = False
client._exchange = None
try:
result = await client.place_order("BTC", "buy", 0.1, price=50000.0)
# Should return error dict or raise RuntimeError
assert "error" in result or result.get("status") == "error"
except RuntimeError as exc:
assert "not installed" in str(exc).lower() or "sdk" in str(exc).lower()
finally:
mod._SDK_AVAILABLE = original
@@ -0,0 +1,217 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
from mcp_hyperliquid.server import create_app
from option_mcp_common.auth import Principal, TokenStore
@pytest.fixture
def mock_client():
c = MagicMock()
c.get_markets = AsyncMock(return_value=[{"asset": "BTC", "mark_price": 50000}])
c.get_ticker = AsyncMock(return_value={"asset": "BTC", "mark_price": 50000})
c.get_orderbook = AsyncMock(return_value={"bids": [], "asks": []})
c.get_positions = AsyncMock(return_value=[])
c.get_account_summary = AsyncMock(return_value={"equity": 1500, "perps_equity": 1000})
c.get_trade_history = AsyncMock(return_value=[])
c.get_historical = AsyncMock(return_value={"candles": []})
c.get_open_orders = AsyncMock(return_value=[])
c.get_funding_rate = AsyncMock(return_value={"asset": "BTC", "current_funding_rate": 0.0001})
c.get_indicators = AsyncMock(return_value={"rsi": 55.0})
c.place_order = AsyncMock(return_value={"order_id": "x", "status": "ok"})
c.cancel_order = AsyncMock(return_value={"order_id": "x", "status": "ok"})
c.set_stop_loss = AsyncMock(return_value={"order_id": "x", "status": "ok"})
c.set_take_profit = AsyncMock(return_value={"order_id": "x", "status": "ok"})
c.close_position = AsyncMock(return_value={"status": "ok", "asset": "BTC"})
return c
@pytest.fixture
def http(mock_client):
store = TokenStore(
tokens={
"ct": Principal("core", {"core"}),
"ot": Principal("observer", {"observer"}),
}
)
app = create_app(client=mock_client, token_store=store)
return TestClient(app)
# --- Health ---
def test_health(http):
assert http.get("/health").status_code == 200
# --- Read tools: both core and observer allowed ---
def test_get_markets_core_ok(http):
r = http.post("/tools/get_markets", headers={"Authorization": "Bearer ct"}, json={})
assert r.status_code == 200
def test_get_markets_observer_ok(http):
r = http.post("/tools/get_markets", headers={"Authorization": "Bearer ot"}, json={})
assert r.status_code == 200
def test_get_ticker_core_ok(http):
r = http.post(
"/tools/get_ticker",
headers={"Authorization": "Bearer ct"},
json={"instrument": "BTC"},
)
assert r.status_code == 200
assert r.json()["mark_price"] == 50000
def test_get_ticker_observer_ok(http):
r = http.post(
"/tools/get_ticker",
headers={"Authorization": "Bearer ot"},
json={"instrument": "BTC"},
)
assert r.status_code == 200
def test_get_ticker_no_auth_401(http):
r = http.post("/tools/get_ticker", json={"instrument": "BTC"})
assert r.status_code == 401
def test_get_account_summary_observer_ok(http):
r = http.post(
"/tools/get_account_summary",
headers={"Authorization": "Bearer ot"},
json={},
)
assert r.status_code == 200
assert r.json()["equity"] == 1500
def test_get_funding_rate_observer_ok(http):
r = http.post(
"/tools/get_funding_rate",
headers={"Authorization": "Bearer ot"},
json={"instrument": "BTC"},
)
assert r.status_code == 200
def test_get_positions_no_auth_401(http):
r = http.post("/tools/get_positions", json={})
assert r.status_code == 401
# --- Write tools: core only ---
def test_place_order_core_ok(http):
# CER-016: amount * price = 150 < cap 200
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ct"},
json={"instrument": "BTC", "side": "buy", "amount": 0.003, "price": 50000},
)
assert r.status_code == 200
def test_place_order_observer_forbidden(http):
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ot"},
json={"instrument": "BTC", "side": "buy", "amount": 0.001, "price": 50000},
)
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):
r = http.post(
"/tools/place_order",
headers={"Authorization": "Bearer ct"},
json={
"instrument": "BTC",
"side": "buy",
"amount": 0.001,
"price": 50000,
"leverage": 10,
},
)
assert r.status_code == 403
def test_cancel_order_core_ok(http):
r = http.post(
"/tools/cancel_order",
headers={"Authorization": "Bearer ct"},
json={"order_id": "123", "instrument": "BTC"},
)
assert r.status_code == 200
def test_cancel_order_observer_forbidden(http):
r = http.post(
"/tools/cancel_order",
headers={"Authorization": "Bearer ot"},
json={"order_id": "123", "instrument": "BTC"},
)
assert r.status_code == 403
def test_set_stop_loss_core_ok(http):
r = http.post(
"/tools/set_stop_loss",
headers={"Authorization": "Bearer ct"},
json={"instrument": "BTC", "stop_price": 45000.0, "size": 0.1},
)
assert r.status_code == 200
def test_set_stop_loss_observer_forbidden(http):
r = http.post(
"/tools/set_stop_loss",
headers={"Authorization": "Bearer ot"},
json={"instrument": "BTC", "stop_price": 45000.0, "size": 0.1},
)
assert r.status_code == 403
def test_set_take_profit_observer_forbidden(http):
r = http.post(
"/tools/set_take_profit",
headers={"Authorization": "Bearer ot"},
json={"instrument": "BTC", "tp_price": 55000.0, "size": 0.1},
)
assert r.status_code == 403
def test_close_position_core_ok(http):
r = http.post(
"/tools/close_position",
headers={"Authorization": "Bearer ct"},
json={"instrument": "BTC"},
)
assert r.status_code == 200
def test_close_position_observer_forbidden(http):
r = http.post(
"/tools/close_position",
headers={"Authorization": "Bearer ot"},
json={"instrument": "BTC"},
)
assert r.status_code == 403