diff --git a/docker-compose.yml b/docker-compose.yml index 9d9bdcc..e9b6834 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,7 +113,7 @@ services: BYBIT_CREDENTIALS_FILE: /run/secrets/bybit_credentials CORE_TOKEN_FILE: /run/secrets/core_token OBSERVER_TOKEN_FILE: /run/secrets/observer_token - BYBIT_TESTNET: "false" + BYBIT_TESTNET: "true" # override secrets/bybit.json testnet flag ROOT_PATH: /mcp-bybit PORT: "9019" diff --git a/services/mcp-bybit/src/mcp_bybit/leverage_cap.py b/services/mcp-bybit/src/mcp_bybit/leverage_cap.py new file mode 100644 index 0000000..d04dd51 --- /dev/null +++ b/services/mcp-bybit/src/mcp_bybit/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-bybit/src/mcp_bybit/server.py b/services/mcp-bybit/src/mcp_bybit/server.py index 4b2c768..0efdbc5 100644 --- a/services/mcp-bybit/src/mcp_bybit/server.py +++ b/services/mcp-bybit/src/mcp_bybit/server.py @@ -4,11 +4,14 @@ import os from fastapi import Depends, HTTPException from mcp_common.auth import Principal, TokenStore, require_principal +from mcp_common.environment import EnvironmentInfo from mcp_common.mcp_bridge import mount_mcp_endpoint from mcp_common.server import build_app from pydantic import BaseModel from mcp_bybit.client import BybitClient +from mcp_bybit.leverage_cap import enforce_leverage as _enforce_leverage +from mcp_bybit.leverage_cap import get_max_leverage # --- Body models: reads --- @@ -180,11 +183,39 @@ def _check(principal: Principal, *, core: bool = False, observer: bool = False) raise HTTPException(status_code=403, detail="forbidden") -def create_app(*, client: BybitClient, token_store: TokenStore): +def create_app( + *, + client: BybitClient, + token_store: TokenStore, + creds: dict | None = None, + env_info: "EnvironmentInfo | None" = None, +): + creds = creds or {} app = build_app(name="mcp-bybit", version="0.1.0", token_store=token_store) # ── Reads ────────────────────────────────────────────── + @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": "bybit", + "environment": "testnet" if client.testnet 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_ticker", tags=["reads"]) async def t_get_ticker(body: TickerReq, principal: Principal = Depends(require_principal)): _check(principal, core=True, observer=True) @@ -309,6 +340,7 @@ def create_app(*, client: BybitClient, token_store: TokenStore): @app.post("/tools/set_leverage", tags=["writes"]) async def t_set_leverage(body: SetLeverageReq, principal: Principal = Depends(require_principal)): + _enforce_leverage(body.leverage, creds=creds, exchange="bybit") _check(principal, core=True) return await client.set_leverage(body.category, body.symbol, body.leverage) @@ -332,6 +364,7 @@ def create_app(*, client: BybitClient, token_store: TokenStore): 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_ticker", "description": "Ticker Bybit (spot/linear/inverse/option)."}, {"name": "get_ticker_batch", "description": "Ticker per più simboli."}, {"name": "get_orderbook", "description": "Orderbook profondità N."}, diff --git a/services/mcp-bybit/tests/test_environment_info.py b/services/mcp-bybit/tests/test_environment_info.py new file mode 100644 index 0000000..50bccc5 --- /dev/null +++ b/services/mcp-bybit/tests/test_environment_info.py @@ -0,0 +1,55 @@ +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_bybit.server import create_app + + +def _make_app(env_info, creds): + c = MagicMock() + c.testnet = True + c.set_leverage = AsyncMock(return_value={"state": "ok"}) + 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="bybit", + environment="testnet", + source="env", + env_value="true", + base_url="https://api-testnet.bybit.com", + ) + 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"] == "bybit" + assert body["environment"] == "testnet" + assert body["source"] == "env" + assert body["env_value"] == "true" + assert body["base_url"] == "https://api-testnet.bybit.com" + assert body["max_leverage"] == 3 + + +def test_environment_info_requires_auth(): + env = EnvironmentInfo( + exchange="bybit", environment="testnet", source="default", + env_value=None, base_url="https://api-testnet.bybit.com", + ) + 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-bybit/tests/test_leverage_cap.py b/services/mcp-bybit/tests/test_leverage_cap.py new file mode 100644 index 0000000..130a466 --- /dev/null +++ b/services/mcp-bybit/tests/test_leverage_cap.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from mcp_bybit.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="bybit") # no raise + + +def test_enforce_leverage_pass_at_cap(): + creds = {"max_leverage": 3} + enforce_leverage(3, creds=creds, exchange="bybit") # 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="bybit") + assert exc.value.status_code == 403 + assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" + assert exc.value.detail["exchange"] == "bybit" + 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="bybit") + 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="bybit") + assert result == 3 diff --git a/services/mcp-bybit/tests/test_server_acl.py b/services/mcp-bybit/tests/test_server_acl.py index f0d7688..c54fa90 100644 --- a/services/mcp-bybit/tests/test_server_acl.py +++ b/services/mcp-bybit/tests/test_server_acl.py @@ -52,7 +52,7 @@ def mock_client(): @pytest.fixture def http(mock_client, token_store): - app = create_app(client=mock_client, token_store=token_store) + app = create_app(client=mock_client, token_store=token_store, creds={"max_leverage": 5}) return TestClient(app)