feat: import 6 MCP services + common workspace
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user