From de19d428507381c3a9b100b400a61190af074e11 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 27 Apr 2026 17:57:17 +0200 Subject: [PATCH] feat(mcp-alpaca): leverage_cap + paper resolver + environment_info --- .../mcp-alpaca/src/mcp_alpaca/__main__.py | 21 +++++-- .../mcp-alpaca/src/mcp_alpaca/leverage_cap.py | 56 +++++++++++++++++++ services/mcp-alpaca/src/mcp_alpaca/server.py | 33 ++++++++++- .../mcp-alpaca/tests/test_environment_info.py | 51 +++++++++++++++++ .../mcp-alpaca/tests/test_leverage_cap.py | 47 ++++++++++++++++ services/mcp-alpaca/tests/test_server_acl.py | 2 +- 6 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 services/mcp-alpaca/src/mcp_alpaca/leverage_cap.py create mode 100644 services/mcp-alpaca/tests/test_environment_info.py create mode 100644 services/mcp-alpaca/tests/test_leverage_cap.py diff --git a/services/mcp-alpaca/src/mcp_alpaca/__main__.py b/services/mcp-alpaca/src/mcp_alpaca/__main__.py index 867663b..e04eb28 100644 --- a/services/mcp-alpaca/src/mcp_alpaca/__main__.py +++ b/services/mcp-alpaca/src/mcp_alpaca/__main__.py @@ -5,6 +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_alpaca.client import AlpacaClient @@ -19,20 +20,32 @@ def main(): with open(creds_file) as f: creds = json.load(f) - paper_env = os.environ.get("ALPACA_PAPER", "true").lower() - paper = paper_env not in ("0", "false", "no") + creds.setdefault("base_url_live", "https://api.alpaca.markets") + creds.setdefault("base_url_testnet", "https://paper-api.alpaca.markets") + + env_info = resolve_environment( + creds, + env_var="ALPACA_PAPER", + flag_key="paper", + exchange="alpaca", + ) client = AlpacaClient( api_key=creds["api_key_id"], secret_key=creds["secret_key"], - paper=paper, + paper=(env_info.environment == "testnet"), ) 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) + app = create_app( + client=client, + token_store=token_store, + creds=creds, + env_info=env_info, + ) uvicorn.run( app, log_config=None, diff --git a/services/mcp-alpaca/src/mcp_alpaca/leverage_cap.py b/services/mcp-alpaca/src/mcp_alpaca/leverage_cap.py new file mode 100644 index 0000000..d04dd51 --- /dev/null +++ b/services/mcp-alpaca/src/mcp_alpaca/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-alpaca/src/mcp_alpaca/server.py b/services/mcp-alpaca/src/mcp_alpaca/server.py index b87f140..dfe16c6 100644 --- a/services/mcp-alpaca/src/mcp_alpaca/server.py +++ b/services/mcp-alpaca/src/mcp_alpaca/server.py @@ -4,11 +4,13 @@ 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_alpaca.client import AlpacaClient +from mcp_alpaca.leverage_cap import get_max_leverage # --- Body models: reads --- @@ -118,11 +120,39 @@ def _check(principal: Principal, *, core: bool = False, observer: bool = False) raise HTTPException(status_code=403, detail="forbidden") -def create_app(*, client: AlpacaClient, token_store: TokenStore): +def create_app( + *, + client: AlpacaClient, + token_store: TokenStore, + creds: dict | None = None, + env_info: EnvironmentInfo | None = None, +): + creds = creds or {} app = build_app(name="mcp-alpaca", 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": "alpaca", + "environment": "testnet" if getattr(client, "paper", 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_account", tags=["reads"]) async def t_get_account(body: AccountReq, principal: Principal = Depends(require_principal)): _check(principal, core=True, observer=True) @@ -227,6 +257,7 @@ def create_app(*, client: AlpacaClient, token_store: TokenStore): token_store=token_store, internal_base_url=f"http://localhost:{port}", tools=[ + {"name": "environment_info", "description": "Ambiente operativo (paper/live), source, base_url, max_leverage cap."}, {"name": "get_account", "description": "Alpaca account summary (equity, cash, buying_power)."}, {"name": "get_positions", "description": "Posizioni aperte (stocks/crypto/options)."}, {"name": "get_activities", "description": "Activity log (fills, dividends, transfers)."}, diff --git a/services/mcp-alpaca/tests/test_environment_info.py b/services/mcp-alpaca/tests/test_environment_info.py new file mode 100644 index 0000000..ec01544 --- /dev/null +++ b/services/mcp-alpaca/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_alpaca.server import create_app + + +def _make_app(env_info, creds): + c = MagicMock() + c.paper = 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_paper_is_testnet(): + """Alpaca: 'paper' nel secret mappa a environment='testnet'.""" + env = EnvironmentInfo( + exchange="alpaca", + environment="testnet", + source="env", + env_value="true", + base_url="https://paper-api.alpaca.markets", + ) + app = _make_app(env, creds={"max_leverage": 1}) + c = TestClient(app) + r = c.post("/tools/environment_info", headers={"Authorization": "Bearer ot"}) + assert r.status_code == 200 + body = r.json() + assert body["exchange"] == "alpaca" + assert body["environment"] == "testnet" + assert body["source"] == "env" + assert body["base_url"] == "https://paper-api.alpaca.markets" + assert body["max_leverage"] == 1 + + +def test_environment_info_requires_auth(): + env = EnvironmentInfo( + exchange="alpaca", environment="testnet", source="default", + env_value=None, base_url="https://paper-api.alpaca.markets", + ) + app = _make_app(env, creds={"max_leverage": 1}) + c = TestClient(app) + r = c.post("/tools/environment_info") + assert r.status_code == 401 diff --git a/services/mcp-alpaca/tests/test_leverage_cap.py b/services/mcp-alpaca/tests/test_leverage_cap.py new file mode 100644 index 0000000..6be55ce --- /dev/null +++ b/services/mcp-alpaca/tests/test_leverage_cap.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from mcp_alpaca.leverage_cap import enforce_leverage, get_max_leverage + + +def test_get_max_leverage_returns_creds_value(): + creds = {"max_leverage": 4} + assert get_max_leverage(creds) == 4 + + +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_at_cap_one(): + """Alpaca cash account: cap 1, leverage 1 OK.""" + creds = {"max_leverage": 1} + enforce_leverage(1, creds=creds, exchange="alpaca") # no raise + + +def test_enforce_leverage_reject_over_cap_one(): + creds = {"max_leverage": 1} + with pytest.raises(HTTPException) as exc: + enforce_leverage(2, creds=creds, exchange="alpaca") + assert exc.value.status_code == 403 + assert exc.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" + assert exc.value.detail["exchange"] == "alpaca" + assert exc.value.detail["requested"] == 2 + assert exc.value.detail["max"] == 1 + + +def test_enforce_leverage_reject_when_below_one(): + creds = {"max_leverage": 1} + with pytest.raises(HTTPException) as exc: + enforce_leverage(0, creds=creds, exchange="alpaca") + assert exc.value.status_code == 403 + + +def test_enforce_leverage_default_when_none(): + """Se requested è None, applica il cap come default.""" + creds = {"max_leverage": 1} + result = enforce_leverage(None, creds=creds, exchange="alpaca") + assert result == 1 diff --git a/services/mcp-alpaca/tests/test_server_acl.py b/services/mcp-alpaca/tests/test_server_acl.py index b6b9129..0f318f3 100644 --- a/services/mcp-alpaca/tests/test_server_acl.py +++ b/services/mcp-alpaca/tests/test_server_acl.py @@ -44,7 +44,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": 1}) return TestClient(app)