diff --git a/pyproject.toml b/pyproject.toml index 5d9a24f..6244539 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ members = [ [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py313" [tool.ruff.lint] select = ["E", "F", "I", "W", "UP", "B", "SIM"] @@ -37,6 +37,7 @@ extend-immutable-calls = [ asyncio_mode = "auto" testpaths = ["services"] addopts = "--import-mode=importlib" +consider_namespace_packages = true [dependency-groups] dev = [ diff --git a/services/common/src/mcp_common/app_factory.py b/services/common/src/mcp_common/app_factory.py new file mode 100644 index 0000000..b9581c7 --- /dev/null +++ b/services/common/src/mcp_common/app_factory.py @@ -0,0 +1,86 @@ +"""App factory comune per i servizi mcp-{exchange}. + +Centralizza il boilerplate dei `__main__.py`: +- configure_root_logging (JSON) +- fail_fast_if_missing su env mandatory +- summarize env +- load creds JSON +- resolve_environment con default URLs +- load token store +- delega creazione client + app a callback per-servizio +- uvicorn.run + +Ogni servizio invoca `run_exchange_main(spec)` con uno spec dichiarativo. +""" +from __future__ import annotations + +import json +import os +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import uvicorn + +from mcp_common.auth import load_token_store_from_files +from mcp_common.env_validation import fail_fast_if_missing, require_env, summarize +from mcp_common.environment import EnvironmentInfo, resolve_environment +from mcp_common.logging import configure_root_logging + + +@dataclass(frozen=True) +class ExchangeAppSpec: + exchange: str + creds_env_var: str + env_var: str # es. "BYBIT_TESTNET", "ALPACA_PAPER" + flag_key: str # campo nel secret JSON ("testnet" o "paper") + default_base_url_live: str + default_base_url_testnet: str + default_port: int + build_client: Callable[[dict, EnvironmentInfo], Any] + build_app: Callable[..., Any] + extra_summarize_envs: tuple[str, ...] = () + + +def run_exchange_main(spec: ExchangeAppSpec) -> None: + configure_root_logging() + + fail_fast_if_missing([spec.creds_env_var]) + summarize([ + spec.creds_env_var, + "CORE_TOKEN_FILE", + "OBSERVER_TOKEN_FILE", + "PORT", + "HOST", + spec.env_var, + *spec.extra_summarize_envs, + ]) + + creds_file = require_env(spec.creds_env_var, f"{spec.exchange} credentials JSON path") + with open(creds_file) as f: + creds = json.load(f) + + env_info = resolve_environment( + creds, + env_var=spec.env_var, + flag_key=spec.flag_key, + exchange=spec.exchange, + default_base_url_live=spec.default_base_url_live, + default_base_url_testnet=spec.default_base_url_testnet, + ) + + client = spec.build_client(creds, env_info) + + 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 = spec.build_app(client=client, token_store=token_store, creds=creds, env_info=env_info) + + uvicorn.run( + app, + log_config=None, + host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", str(spec.default_port))), + ) diff --git a/services/common/src/mcp_common/audit.py b/services/common/src/mcp_common/audit.py new file mode 100644 index 0000000..4637906 --- /dev/null +++ b/services/common/src/mcp_common/audit.py @@ -0,0 +1,74 @@ +"""Audit log strutturato per write endpoint MCP (place_order, cancel, +set_*, close_*, transfer_*). Usa un logger dedicato `mcp.audit` su stream +JSON: in deployment può essere redirezionato a file/syslog/SIEM separato. + +Logica: +- `audit_write_op(principal, action, exchange, target, payload, result)` + emette UN record JSON per ogni operazione con esito (ok/error). +- Payload sensibile (api_key, secret) già filtrato dal SecretsFilter + globale; qui non si include creds. +""" +from __future__ import annotations + +import logging +from typing import Any + +from mcp_common.auth import Principal +from mcp_common.logging import get_json_logger + +_logger = get_json_logger("mcp.audit", level=logging.INFO) + + +def audit_write_op( + *, + principal: Principal | None, + action: str, + exchange: str, + target: str | None = None, + payload: dict[str, Any] | None = None, + result: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + """Emit a structured audit log record per write operation. + + principal: chi ha invocato (None se anonimo, ma normalmente _check + impedisce di arrivare qui senza principal). + action: nome del tool (es. "place_order", "cancel_order"). + exchange: identificatore servizio (deribit, bybit, alpaca, hyperliquid). + target: instrument/symbol/order_id su cui si agisce. + payload: input non-sensibile (qty, side, leverage, ecc.). + result: output del client (order_id, status, ecc.). + error: stringa errore se l'operazione ha fallito. + """ + record: dict[str, Any] = { + "audit_event": "write_op", + "action": action, + "exchange": exchange, + "principal": principal.name if principal else None, + "target": target, + "payload": payload or {}, + } + if result is not None: + record["result"] = _summarize_result(result) + if error is not None: + record["error"] = error + _logger.error("audit", extra=record) + else: + _logger.info("audit", extra=record) + + +def _summarize_result(result: dict[str, Any]) -> dict[str, Any]: + """Estrae i campi rilevanti dal result (order_id, state, error code) + per evitare di loggare payload enormi. + """ + keys = ( + "order_id", "order_link_id", "combo_instrument", "state", "status", + "code", "error", "stop_price", "tp_price", "transfer_id", + ) + out: dict[str, Any] = {} + for k in keys: + if k in result: + out[k] = result[k] + if "orders" in result: + out["orders_count"] = len(result["orders"]) + return out diff --git a/services/common/src/mcp_common/env_validation.py b/services/common/src/mcp_common/env_validation.py new file mode 100644 index 0000000..f402315 --- /dev/null +++ b/services/common/src/mcp_common/env_validation.py @@ -0,0 +1,69 @@ +"""Env validation policy: fail-fast per mandatory, soft per optional. + +Usage al boot di ogni mcp `__main__.py`: + + from mcp_common.env_validation import require_env, optional_env, summarize + + creds_file = require_env("CREDENTIALS_FILE", "deribit credentials JSON path") + host = optional_env("HOST", default="0.0.0.0") + summarize(["CREDENTIALS_FILE", "HOST", "PORT"]) +""" + +from __future__ import annotations + +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +class MissingEnvError(RuntimeError): + """Mandatory env var absent or empty.""" + + +def require_env(name: str, description: str = "") -> str: + val = (os.environ.get(name) or "").strip() + if not val: + msg = f"missing mandatory env var: {name}" + if description: + msg += f" ({description})" + logger.error(msg) + raise MissingEnvError(msg) + return val + + +def optional_env(name: str, *, default: str = "") -> str: + val = (os.environ.get(name) or "").strip() + if not val: + if default: + logger.info("env %s not set, using default=%r", name, default) + return default + return val + + +def summarize(names: list[str]) -> None: + sensitive_tokens = ("SECRET", "KEY", "TOKEN", "PASSWORD", "CREDENTIAL", "WALLET") + for n in names: + val = os.environ.get(n) + if val is None: + logger.info("env[%s]: ", n) + continue + if any(t in n.upper() for t in sensitive_tokens): + logger.info("env[%s]: ", n, len(val)) + else: + logger.info("env[%s]: %s", n, val) + + +def fail_fast_if_missing(names: list[str]) -> None: + missing: list[str] = [] + for n in names: + if not (os.environ.get(n) or "").strip(): + missing.append(n) + if missing: + logger.error("boot aborted: missing mandatory env vars: %s", missing) + print( + f"FATAL: missing mandatory env vars: {missing}", + file=sys.stderr, + ) + sys.exit(2) diff --git a/services/common/src/mcp_common/mcp_bridge.py b/services/common/src/mcp_common/mcp_bridge.py index e48564d..55c66d7 100644 --- a/services/common/src/mcp_common/mcp_bridge.py +++ b/services/common/src/mcp_common/mcp_bridge.py @@ -40,6 +40,7 @@ def _derive_input_schemas(app: FastAPI, tool_names: list[str]) -> dict[str, dict risolvibili vengono saltate: il chiamante userà un fallback. """ import typing + from pydantic import BaseModel names_set = set(tool_names) diff --git a/services/common/src/mcp_common/server.py b/services/common/src/mcp_common/server.py index d3c8b3e..1122792 100644 --- a/services/common/src/mcp_common/server.py +++ b/services/common/src/mcp_common/server.py @@ -4,10 +4,9 @@ import json import os import time import uuid -from datetime import UTC, datetime - from collections.abc import Callable from contextlib import AbstractAsyncContextManager +from datetime import UTC, datetime from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError diff --git a/services/common/tests/test_app_factory.py b/services/common/tests/test_app_factory.py new file mode 100644 index 0000000..8277b62 --- /dev/null +++ b/services/common/tests/test_app_factory.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from mcp_common.app_factory import ExchangeAppSpec, run_exchange_main +from mcp_common.environment import EnvironmentInfo + + +def _make_spec(build_client=None, build_app=None) -> ExchangeAppSpec: + return ExchangeAppSpec( + exchange="testex", + creds_env_var="TESTEX_CREDENTIALS_FILE", + env_var="TESTEX_TESTNET", + flag_key="testnet", + default_base_url_live="https://api.testex.com", + default_base_url_testnet="https://test.testex.com", + default_port=9999, + build_client=build_client or (lambda creds, env_info: MagicMock(name="client")), + build_app=build_app or (lambda **kwargs: MagicMock(name="app")), + ) + + +def test_run_exchange_main_loads_creds_and_resolves_env(tmp_path, monkeypatch): + creds_file = tmp_path / "creds.json" + creds_file.write_text(json.dumps({"api_key": "k", "api_secret": "s"})) + monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) + monkeypatch.setenv("PORT", "10000") + monkeypatch.delenv("TESTEX_TESTNET", raising=False) + + captured: dict = {} + + def build_client(creds, env_info): + captured["creds"] = creds + captured["env_info"] = env_info + return MagicMock() + + def build_app(**kwargs): + captured["app_kwargs"] = kwargs + return MagicMock() + + spec = _make_spec(build_client=build_client, build_app=build_app) + + with patch("mcp_common.app_factory.uvicorn.run") as mock_run: + run_exchange_main(spec) + + assert captured["creds"]["api_key"] == "k" + assert captured["creds"]["base_url_live"] == "https://api.testex.com" + assert captured["creds"]["base_url_testnet"] == "https://test.testex.com" + assert isinstance(captured["env_info"], EnvironmentInfo) + assert captured["env_info"].environment == "testnet" + assert captured["env_info"].exchange == "testex" + + assert "client" in captured["app_kwargs"] + assert "token_store" in captured["app_kwargs"] + assert "creds" in captured["app_kwargs"] + assert "env_info" in captured["app_kwargs"] + + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["port"] == 10000 # PORT override + + +def test_run_exchange_main_uses_default_port(tmp_path, monkeypatch): + creds_file = tmp_path / "creds.json" + creds_file.write_text(json.dumps({})) + monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) + monkeypatch.delenv("PORT", raising=False) + + spec = _make_spec() + with patch("mcp_common.app_factory.uvicorn.run") as mock_run: + run_exchange_main(spec) + + assert mock_run.call_args.kwargs["port"] == 9999 + + +def test_run_exchange_main_env_var_overrides_creds(tmp_path, monkeypatch): + creds_file = tmp_path / "creds.json" + creds_file.write_text(json.dumps({"testnet": True})) + monkeypatch.setenv("TESTEX_CREDENTIALS_FILE", str(creds_file)) + monkeypatch.setenv("TESTEX_TESTNET", "false") + + captured: dict = {} + + def build_client(creds, env_info): + captured["env_info"] = env_info + return MagicMock() + + spec = _make_spec(build_client=build_client) + + with patch("mcp_common.app_factory.uvicorn.run"): + run_exchange_main(spec) + + # env var "false" overrides creds.testnet=True → mainnet + assert captured["env_info"].environment == "mainnet" + assert captured["env_info"].source == "env" + + +def test_run_exchange_main_missing_creds_file_exits(monkeypatch): + monkeypatch.delenv("TESTEX_CREDENTIALS_FILE", raising=False) + + spec = _make_spec() + import pytest + with pytest.raises(SystemExit) as exc_info: + run_exchange_main(spec) + assert exc_info.value.code == 2 diff --git a/services/common/tests/test_audit.py b/services/common/tests/test_audit.py new file mode 100644 index 0000000..7d0ff7c --- /dev/null +++ b/services/common/tests/test_audit.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import logging + +import pytest +from mcp_common import audit as audit_mod +from mcp_common.audit import audit_write_op +from mcp_common.auth import Principal + + +@pytest.fixture +def captured_records(monkeypatch): + """Cattura i record emessi dal logger mcp.audit (propagate=False blocca caplog). + + Sostituisce il logger del modulo con uno che ha caplog attaccato. + """ + records: list[logging.LogRecord] = [] + + class ListHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + test_logger = logging.getLogger("mcp.audit.test") + test_logger.handlers.clear() + test_logger.addHandler(ListHandler()) + test_logger.setLevel(logging.DEBUG) + test_logger.propagate = False + monkeypatch.setattr(audit_mod, "_logger", test_logger) + return records + + +def test_audit_write_op_emits_structured_record(captured_records): + p = Principal("core", {"core"}) + audit_write_op( + principal=p, + action="place_order", + exchange="deribit", + target="BTC-PERPETUAL", + payload={"side": "buy", "amount": 10, "leverage": 3}, + result={"order_id": "abc", "state": "open"}, + ) + assert len(captured_records) == 1 + rec = captured_records[0] + assert rec.action == "place_order" + assert rec.exchange == "deribit" + assert rec.target == "BTC-PERPETUAL" + assert rec.principal == "core" + assert rec.payload == {"side": "buy", "amount": 10, "leverage": 3} + assert rec.result == {"order_id": "abc", "state": "open"} + + +def test_audit_write_op_error_uses_error_level(captured_records): + p = Principal("core", {"core"}) + audit_write_op( + principal=p, + action="cancel_order", + exchange="bybit", + target="ord-123", + payload={}, + error="not_found", + ) + assert len(captured_records) == 1 + rec = captured_records[0] + assert rec.levelname == "ERROR" + assert rec.error == "not_found" + + +def test_audit_write_op_summarizes_result_fields(captured_records): + p = Principal("core", {"core"}) + big_result = { + "order_id": "ord-1", + "state": "submitted", + "extra_huge_field": "x" * 10000, + "orders": [{"id": 1}, {"id": 2}, {"id": 3}], + } + audit_write_op( + principal=p, + action="place_combo_order", + exchange="bybit", + payload={}, + result=big_result, + ) + rec = captured_records[0] + assert "extra_huge_field" not in rec.result + assert rec.result["order_id"] == "ord-1" + assert rec.result["orders_count"] == 3 + + +def test_audit_write_op_no_principal(captured_records): + audit_write_op( + principal=None, + action="place_order", + exchange="alpaca", + payload={}, + ) + rec = captured_records[0] + assert rec.principal is None diff --git a/services/common/tests/test_environment.py b/services/common/tests/test_environment.py index a142592..022eeca 100644 --- a/services/common/tests/test_environment.py +++ b/services/common/tests/test_environment.py @@ -1,10 +1,7 @@ from __future__ import annotations -import json - import pytest - -from mcp_common.environment import EnvironmentInfo, resolve_environment +from mcp_common.environment import resolve_environment def test_env_var_overrides_secret(monkeypatch): diff --git a/services/common/tests/test_options.py b/services/common/tests/test_options.py index 23134be..97d15c7 100644 --- a/services/common/tests/test_options.py +++ b/services/common/tests/test_options.py @@ -4,7 +4,6 @@ dall'exchange). from __future__ import annotations import pytest - from mcp_common.options import ( atm_vs_wings_vol, dealer_gamma_profile, @@ -13,7 +12,6 @@ from mcp_common.options import ( vanna_charm_aggregate, ) - # ---------- oi_weighted_skew ---------- def test_oi_weighted_skew_balanced(): diff --git a/services/common/tests/test_stats.py b/services/common/tests/test_stats.py index 213b91f..e14ab32 100644 --- a/services/common/tests/test_stats.py +++ b/services/common/tests/test_stats.py @@ -1,6 +1,5 @@ from __future__ import annotations -import math import random from mcp_common.stats import cointegration_test diff --git a/services/mcp-alpaca/src/mcp_alpaca/__main__.py b/services/mcp-alpaca/src/mcp_alpaca/__main__.py index 5ad5794..eca018b 100644 --- a/services/mcp-alpaca/src/mcp_alpaca/__main__.py +++ b/services/mcp-alpaca/src/mcp_alpaca/__main__.py @@ -1,56 +1,29 @@ from __future__ import annotations -import json -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_common.app_factory import ExchangeAppSpec, run_exchange_main from mcp_alpaca.client import AlpacaClient from mcp_alpaca.server import create_app - -configure_root_logging() - - -def main(): - creds_file = os.environ["ALPACA_CREDENTIALS_FILE"] - with open(creds_file) as f: - creds = json.load(f) - - env_info = resolve_environment( - creds, - env_var="ALPACA_PAPER", - flag_key="paper", - exchange="alpaca", - default_base_url_live="https://api.alpaca.markets", - default_base_url_testnet="https://paper-api.alpaca.markets", - ) - - client = AlpacaClient( +SPEC = ExchangeAppSpec( + exchange="alpaca", + creds_env_var="ALPACA_CREDENTIALS_FILE", + env_var="ALPACA_PAPER", + flag_key="paper", + default_base_url_live="https://api.alpaca.markets", + default_base_url_testnet="https://paper-api.alpaca.markets", + default_port=9020, + build_client=lambda creds, env_info: AlpacaClient( api_key=creds["api_key_id"], secret_key=creds["secret_key"], paper=(env_info.environment == "testnet"), - ) + ), + build_app=create_app, +) - 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, - creds=creds, - env_info=env_info, - ) - uvicorn.run( - app, - log_config=None, - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", "9020")), - ) + +def main(): + run_exchange_main(SPEC) if __name__ == "__main__": diff --git a/services/mcp-alpaca/src/mcp_alpaca/client.py b/services/mcp-alpaca/src/mcp_alpaca/client.py index 886e851..61d0514 100644 --- a/services/mcp-alpaca/src/mcp_alpaca/client.py +++ b/services/mcp-alpaca/src/mcp_alpaca/client.py @@ -26,8 +26,6 @@ from alpaca.trading.client import TradingClient from alpaca.trading.enums import ( AssetClass, OrderSide, - OrderStatus, - OrderType, QueryOrderStatus, TimeInForce, ) @@ -41,7 +39,6 @@ from alpaca.trading.requests import ( StopOrderRequest, ) - _TF_MAP = { "1min": TimeFrame(1, TimeFrameUnit.Minute), "5min": TimeFrame(5, TimeFrameUnit.Minute), diff --git a/services/mcp-alpaca/src/mcp_alpaca/server.py b/services/mcp-alpaca/src/mcp_alpaca/server.py index dfe16c6..e5219a7 100644 --- a/services/mcp-alpaca/src/mcp_alpaca/server.py +++ b/services/mcp-alpaca/src/mcp_alpaca/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import os from fastapi import Depends, HTTPException +from mcp_common.audit import audit_write_op from mcp_common.auth import Principal, TokenStore, require_principal from mcp_common.environment import EnvironmentInfo from mcp_common.mcp_bridge import mount_mcp_endpoint @@ -12,7 +13,6 @@ from pydantic import BaseModel from mcp_alpaca.client import AlpacaClient from mcp_alpaca.leverage_cap import get_max_leverage - # --- Body models: reads --- class AccountReq(BaseModel): @@ -215,37 +215,77 @@ def create_app( @app.post("/tools/place_order", tags=["writes"]) async def t_place_order(body: PlaceOrderReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.place_order( + result = await client.place_order( body.symbol, body.side, body.qty, body.notional, body.order_type, body.limit_price, body.stop_price, body.tif, body.asset_class, ) + audit_write_op( + principal=principal, action="place_order", exchange="alpaca", + target=body.symbol, + payload={"side": body.side, "qty": body.qty, "notional": body.notional, + "order_type": body.order_type, "limit_price": body.limit_price, + "stop_price": body.stop_price, "tif": body.tif, + "asset_class": body.asset_class}, + result=result, + ) + return result @app.post("/tools/amend_order", tags=["writes"]) async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.amend_order( + result = await client.amend_order( body.order_id, body.qty, body.limit_price, body.stop_price, body.tif, ) + audit_write_op( + principal=principal, action="amend_order", exchange="alpaca", + target=body.order_id, + payload={"qty": body.qty, "limit_price": body.limit_price, + "stop_price": body.stop_price, "tif": body.tif}, + result=result, + ) + return result @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) + result = await client.cancel_order(body.order_id) + audit_write_op( + principal=principal, action="cancel_order", exchange="alpaca", + target=body.order_id, payload={}, result=result, + ) + return result @app.post("/tools/cancel_all_orders", tags=["writes"]) async def t_cancel_all(body: CancelAllReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return {"canceled": await client.cancel_all_orders()} + result = {"canceled": await client.cancel_all_orders()} + audit_write_op( + principal=principal, action="cancel_all_orders", exchange="alpaca", + payload={}, result=result, + ) + return result @app.post("/tools/close_position", tags=["writes"]) async def t_close(body: ClosePositionReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.close_position(body.symbol, body.qty, body.percentage) + result = await client.close_position(body.symbol, body.qty, body.percentage) + audit_write_op( + principal=principal, action="close_position", exchange="alpaca", + target=body.symbol, + payload={"qty": body.qty, "percentage": body.percentage}, + result=result, + ) + return result @app.post("/tools/close_all_positions", tags=["writes"]) async def t_close_all(body: CloseAllPositionsReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return {"closed": await client.close_all_positions(body.cancel_orders)} + result = {"closed": await client.close_all_positions(body.cancel_orders)} + audit_write_op( + principal=principal, action="close_all_positions", exchange="alpaca", + payload={"cancel_orders": body.cancel_orders}, result=result, + ) + return result # ── MCP mount ────────────────────────────────────────── diff --git a/services/mcp-alpaca/tests/__init__.py b/services/mcp-alpaca/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-alpaca/tests/conftest.py b/services/mcp-alpaca/tests/conftest.py index ad79753..de96baa 100644 --- a/services/mcp-alpaca/tests/conftest.py +++ b/services/mcp-alpaca/tests/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations from unittest.mock import MagicMock import pytest - from mcp_alpaca.client import AlpacaClient diff --git a/services/mcp-alpaca/tests/test_environment_info.py b/services/mcp-alpaca/tests/test_environment_info.py index ec01544..a6bfbff 100644 --- a/services/mcp-alpaca/tests/test_environment_info.py +++ b/services/mcp-alpaca/tests/test_environment_info.py @@ -1,12 +1,11 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock from fastapi.testclient import TestClient - +from mcp_alpaca.server import create_app 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): diff --git a/services/mcp-alpaca/tests/test_leverage_cap.py b/services/mcp-alpaca/tests/test_leverage_cap.py index 6be55ce..c42d1b3 100644 --- a/services/mcp-alpaca/tests/test_leverage_cap.py +++ b/services/mcp-alpaca/tests/test_leverage_cap.py @@ -2,7 +2,6 @@ from __future__ import annotations import pytest from fastapi import HTTPException - from mcp_alpaca.leverage_cap import enforce_leverage, get_max_leverage diff --git a/services/mcp-alpaca/tests/test_server_acl.py b/services/mcp-alpaca/tests/test_server_acl.py index 0f318f3..57911eb 100644 --- a/services/mcp-alpaca/tests/test_server_acl.py +++ b/services/mcp-alpaca/tests/test_server_acl.py @@ -4,9 +4,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore - from mcp_alpaca.server import create_app +from mcp_common.auth import Principal, TokenStore @pytest.fixture diff --git a/services/mcp-bybit/src/mcp_bybit/__main__.py b/services/mcp-bybit/src/mcp_bybit/__main__.py index e28c8b7..bcc531a 100644 --- a/services/mcp-bybit/src/mcp_bybit/__main__.py +++ b/services/mcp-bybit/src/mcp_bybit/__main__.py @@ -1,56 +1,29 @@ from __future__ import annotations -import json -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_common.app_factory import ExchangeAppSpec, run_exchange_main from mcp_bybit.client import BybitClient from mcp_bybit.server import create_app - -configure_root_logging() - - -def main(): - creds_file = os.environ["BYBIT_CREDENTIALS_FILE"] - with open(creds_file) as f: - creds = json.load(f) - - env_info = resolve_environment( - creds, - env_var="BYBIT_TESTNET", - flag_key="testnet", - exchange="bybit", - default_base_url_live="https://api.bybit.com", - default_base_url_testnet="https://api-testnet.bybit.com", - ) - - client = BybitClient( +SPEC = ExchangeAppSpec( + exchange="bybit", + creds_env_var="BYBIT_CREDENTIALS_FILE", + env_var="BYBIT_TESTNET", + flag_key="testnet", + default_base_url_live="https://api.bybit.com", + default_base_url_testnet="https://api-testnet.bybit.com", + default_port=9019, + build_client=lambda creds, env_info: BybitClient( api_key=creds["api_key"], api_secret=creds["api_secret"], testnet=(env_info.environment == "testnet"), - ) + ), + build_app=create_app, +) - 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, - creds=creds, - env_info=env_info, - ) - uvicorn.run( - app, - log_config=None, - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", "9019")), - ) + +def main(): + run_exchange_main(SPEC) if __name__ == "__main__": diff --git a/services/mcp-bybit/src/mcp_bybit/server.py b/services/mcp-bybit/src/mcp_bybit/server.py index fbf28db..26328ad 100644 --- a/services/mcp-bybit/src/mcp_bybit/server.py +++ b/services/mcp-bybit/src/mcp_bybit/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import os from fastapi import Depends, HTTPException +from mcp_common.audit import audit_write_op from mcp_common.auth import Principal, TokenStore, require_principal from mcp_common.environment import EnvironmentInfo from mcp_common.mcp_bridge import mount_mcp_endpoint @@ -13,7 +14,6 @@ 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 --- class TickerReq(BaseModel): @@ -213,7 +213,7 @@ def create_app( client: BybitClient, token_store: TokenStore, creds: dict | None = None, - env_info: "EnvironmentInfo | None" = None, + env_info: EnvironmentInfo | None = None, ): creds = creds or {} app = build_app(name="mcp-bybit", version="0.1.0", token_store=token_store) @@ -336,66 +336,146 @@ def create_app( @app.post("/tools/place_order", tags=["writes"]) async def t_place_order(body: PlaceOrderReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.place_order( + result = await client.place_order( body.category, body.symbol, body.side, body.qty, body.order_type, body.price, body.tif, body.reduce_only, body.position_idx, ) + audit_write_op( + principal=principal, action="place_order", exchange="bybit", + target=body.symbol, + payload={"category": body.category, "side": body.side, "qty": body.qty, + "order_type": body.order_type, "price": body.price, "tif": body.tif, + "reduce_only": body.reduce_only}, + result=result, + ) + return result @app.post("/tools/place_combo_order", tags=["writes"]) async def t_place_combo_order(body: PlaceComboOrderReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.place_combo_order( + result = await client.place_combo_order( category=body.category, legs=[leg.model_dump() for leg in body.legs], ) + audit_write_op( + principal=principal, action="place_combo_order", exchange="bybit", + payload={"category": body.category, + "legs": [leg.model_dump() for leg in body.legs]}, + result=result if isinstance(result, dict) else None, + ) + return result @app.post("/tools/amend_order", tags=["writes"]) async def t_amend_order(body: AmendOrderReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.amend_order( + result = await client.amend_order( body.category, body.symbol, body.order_id, body.new_qty, body.new_price, ) + audit_write_op( + principal=principal, action="amend_order", exchange="bybit", + target=body.order_id, + payload={"category": body.category, "symbol": body.symbol, + "new_qty": body.new_qty, "new_price": body.new_price}, + result=result, + ) + return result @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.category, body.symbol, body.order_id) + result = await client.cancel_order(body.category, body.symbol, body.order_id) + audit_write_op( + principal=principal, action="cancel_order", exchange="bybit", + target=body.order_id, + payload={"category": body.category, "symbol": body.symbol}, + result=result, + ) + return result @app.post("/tools/cancel_all_orders", tags=["writes"]) async def t_cancel_all(body: CancelAllReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.cancel_all_orders(body.category, body.symbol) + result = await client.cancel_all_orders(body.category, body.symbol) + audit_write_op( + principal=principal, action="cancel_all_orders", exchange="bybit", + target=body.symbol, + payload={"category": body.category}, + result=result, + ) + return result @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.category, body.symbol, body.stop_loss, body.position_idx) + result = await client.set_stop_loss(body.category, body.symbol, body.stop_loss, body.position_idx) + audit_write_op( + principal=principal, action="set_stop_loss", exchange="bybit", + target=body.symbol, + payload={"stop_loss": body.stop_loss, "position_idx": body.position_idx}, + result=result, + ) + return result @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.category, body.symbol, body.take_profit, body.position_idx) + result = await client.set_take_profit(body.category, body.symbol, body.take_profit, body.position_idx) + audit_write_op( + principal=principal, action="set_take_profit", exchange="bybit", + target=body.symbol, + payload={"take_profit": body.take_profit, "position_idx": body.position_idx}, + result=result, + ) + return result @app.post("/tools/close_position", tags=["writes"]) async def t_close(body: ClosePositionReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.close_position(body.category, body.symbol) + result = await client.close_position(body.category, body.symbol) + audit_write_op( + principal=principal, action="close_position", exchange="bybit", + target=body.symbol, + payload={"category": body.category}, + result=result, + ) + return result @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) + result = await client.set_leverage(body.category, body.symbol, body.leverage) + audit_write_op( + principal=principal, action="set_leverage", exchange="bybit", + target=body.symbol, + payload={"category": body.category, "leverage": body.leverage}, + result=result, + ) + return result @app.post("/tools/switch_position_mode", tags=["writes"]) async def t_switch_mode(body: SwitchModeReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.switch_position_mode(body.category, body.symbol, body.mode) + result = await client.switch_position_mode(body.category, body.symbol, body.mode) + audit_write_op( + principal=principal, action="switch_position_mode", exchange="bybit", + target=body.symbol, + payload={"category": body.category, "mode": body.mode}, + result=result, + ) + return result @app.post("/tools/transfer_asset", tags=["writes"]) async def t_transfer(body: TransferReq, principal: Principal = Depends(require_principal)): _check(principal, core=True) - return await client.transfer_asset(body.coin, body.amount, body.from_type, body.to_type) + result = await client.transfer_asset(body.coin, body.amount, body.from_type, body.to_type) + audit_write_op( + principal=principal, action="transfer_asset", exchange="bybit", + payload={"coin": body.coin, "amount": body.amount, + "from_type": body.from_type, "to_type": body.to_type}, + result=result, + ) + return result # ── MCP mount ────────────────────────────────────────── diff --git a/services/mcp-bybit/tests/__init__.py b/services/mcp-bybit/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-bybit/tests/conftest.py b/services/mcp-bybit/tests/conftest.py index ac8e9b5..7812045 100644 --- a/services/mcp-bybit/tests/conftest.py +++ b/services/mcp-bybit/tests/conftest.py @@ -3,7 +3,6 @@ from __future__ import annotations from unittest.mock import MagicMock import pytest - from mcp_bybit.client import BybitClient diff --git a/services/mcp-bybit/tests/test_client.py b/services/mcp-bybit/tests/test_client.py index d88461b..c1d3434 100644 --- a/services/mcp-bybit/tests/test_client.py +++ b/services/mcp-bybit/tests/test_client.py @@ -1,7 +1,6 @@ from __future__ import annotations import pytest - from mcp_bybit.client import BybitClient diff --git a/services/mcp-bybit/tests/test_environment_info.py b/services/mcp-bybit/tests/test_environment_info.py index 50bccc5..a758998 100644 --- a/services/mcp-bybit/tests/test_environment_info.py +++ b/services/mcp-bybit/tests/test_environment_info.py @@ -3,10 +3,9 @@ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock from fastapi.testclient import TestClient - +from mcp_bybit.server import create_app 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): diff --git a/services/mcp-bybit/tests/test_leverage_cap.py b/services/mcp-bybit/tests/test_leverage_cap.py index 130a466..18c7715 100644 --- a/services/mcp-bybit/tests/test_leverage_cap.py +++ b/services/mcp-bybit/tests/test_leverage_cap.py @@ -2,7 +2,6 @@ from __future__ import annotations import pytest from fastapi import HTTPException - from mcp_bybit.leverage_cap import enforce_leverage, get_max_leverage diff --git a/services/mcp-bybit/tests/test_server_acl.py b/services/mcp-bybit/tests/test_server_acl.py index 64498f4..1edf42b 100644 --- a/services/mcp-bybit/tests/test_server_acl.py +++ b/services/mcp-bybit/tests/test_server_acl.py @@ -4,9 +4,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi.testclient import TestClient -from mcp_common.auth import Principal, TokenStore - from mcp_bybit.server import create_app +from mcp_common.auth import Principal, TokenStore @pytest.fixture diff --git a/services/mcp-deribit/src/mcp_deribit/__main__.py b/services/mcp-deribit/src/mcp_deribit/__main__.py index 3471c02..2f78db8 100644 --- a/services/mcp-deribit/src/mcp_deribit/__main__.py +++ b/services/mcp-deribit/src/mcp_deribit/__main__.py @@ -1,63 +1,29 @@ from __future__ import annotations -import json -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_common.app_factory import ExchangeAppSpec, run_exchange_main from mcp_deribit.client import DeribitClient -from mcp_deribit.env_validation import ( - fail_fast_if_missing, - require_env, - summarize, -) from mcp_deribit.server import create_app -configure_root_logging() # CER-P5-009: JSON default, env LOG_FORMAT=text per dev - - -def main(): - # CER-P5-010: fail-fast boot su env mandatory - fail_fast_if_missing(["CREDENTIALS_FILE"]) - summarize(["CREDENTIALS_FILE", "CORE_TOKEN_FILE", "OBSERVER_TOKEN_FILE", "PORT", "HOST"]) - creds_file = require_env("CREDENTIALS_FILE", "deribit credentials JSON path") - with open(creds_file) as f: - creds = json.load(f) - - env_info = resolve_environment( - creds, - env_var="DERIBIT_TESTNET", - flag_key="testnet", - exchange="deribit", - default_base_url_live="https://www.deribit.com/api/v2", - default_base_url_testnet="https://test.deribit.com/api/v2", - ) - - client = DeribitClient( +SPEC = ExchangeAppSpec( + exchange="deribit", + creds_env_var="CREDENTIALS_FILE", + env_var="DERIBIT_TESTNET", + flag_key="testnet", + default_base_url_live="https://www.deribit.com/api/v2", + default_base_url_testnet="https://test.deribit.com/api/v2", + default_port=9011, + build_client=lambda creds, env_info: DeribitClient( client_id=creds["client_id"], client_secret=creds["client_secret"], testnet=(env_info.environment == "testnet"), - ) + ), + build_app=create_app, +) - 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, - creds=creds, - env_info=env_info, - ) - 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", "9011")), - ) + +def main(): + run_exchange_main(SPEC) if __name__ == "__main__": diff --git a/services/mcp-deribit/src/mcp_deribit/env_validation.py b/services/mcp-deribit/src/mcp_deribit/env_validation.py index 723249d..6a9cfe0 100644 --- a/services/mcp-deribit/src/mcp_deribit/env_validation.py +++ b/services/mcp-deribit/src/mcp_deribit/env_validation.py @@ -1,80 +1,18 @@ -"""CER-P5-010: env validation policy — fail-fast per mandatory, soft per optional. - -Usage al boot di ogni mcp `__main__.py`: - - from option_mcp_common.env_validation import require_env, optional_env, summarize - - creds_file = require_env("CREDENTIALS_FILE", "deribit credentials JSON path") - host = optional_env("HOST", default="0.0.0.0") - summarize(["CREDENTIALS_FILE", "HOST", "PORT"]) +"""Re-export shim per backward-compat: la logica vive ora in +mcp_common.env_validation. Non aggiungere nuovo codice qui. """ +from mcp_common.env_validation import ( + MissingEnvError, + fail_fast_if_missing, + optional_env, + require_env, + summarize, +) -from __future__ import annotations - -import logging -import os -import sys - -logger = logging.getLogger(__name__) - - -class MissingEnvError(RuntimeError): - """Mandatory env var absent or empty.""" - - -def require_env(name: str, description: str = "") -> str: - """Fail-fast: raise MissingEnvError se name non presente o vuoto. - - Uscita dal processo con codice 2 se chiamato dal main(). Comporta - logging chiaro del missing var prima dell'exit. - """ - val = (os.environ.get(name) or "").strip() - if not val: - msg = f"missing mandatory env var: {name}" - if description: - msg += f" ({description})" - logger.error(msg) - raise MissingEnvError(msg) - return val - - -def optional_env(name: str, *, default: str = "") -> str: - """Soft: ritorna env o default. Log INFO se default usato.""" - val = (os.environ.get(name) or "").strip() - if not val: - if default: - logger.info("env %s not set, using default=%r", name, default) - return default - return val - - -def summarize(names: list[str]) -> None: - """Log INFO di tutti gli env rilevanti con presenza (mask se SECRET/KEY/TOKEN).""" - sensitive_tokens = ("SECRET", "KEY", "TOKEN", "PASSWORD", "CREDENTIAL", "WALLET") - for n in names: - val = os.environ.get(n) - if val is None: - logger.info("env[%s]: ", n) - continue - if any(t in n.upper() for t in sensitive_tokens): - logger.info("env[%s]: ", n, len(val)) - else: - logger.info("env[%s]: %s", n, val) - - -def fail_fast_if_missing(names: list[str]) -> None: - """Verifica lista di nomi mandatory al boot. Exit 2 se uno solo manca. - - Uso preferito: early call in main() per bloccare boot se config incompleta. - """ - missing: list[str] = [] - for n in names: - if not (os.environ.get(n) or "").strip(): - missing.append(n) - if missing: - logger.error("boot aborted: missing mandatory env vars: %s", missing) - print( - f"FATAL: missing mandatory env vars: {missing}", - file=sys.stderr, - ) - sys.exit(2) +__all__ = [ + "MissingEnvError", + "fail_fast_if_missing", + "optional_env", + "require_env", + "summarize", +] diff --git a/services/mcp-deribit/src/mcp_deribit/server.py b/services/mcp-deribit/src/mcp_deribit/server.py index 383952a..7318f48 100644 --- a/services/mcp-deribit/src/mcp_deribit/server.py +++ b/services/mcp-deribit/src/mcp_deribit/server.py @@ -3,15 +3,16 @@ from __future__ import annotations import os from fastapi import Depends, FastAPI, HTTPException +from mcp_common.audit import audit_write_op 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_deribit.leverage_cap import enforce_leverage as _enforce_leverage -from mcp_deribit.leverage_cap import get_max_leverage from mcp_common.server import build_app from pydantic import BaseModel, field_validator, model_validator from mcp_deribit.client import DeribitClient +from mcp_deribit.leverage_cap import enforce_leverage as _enforce_leverage +from mcp_deribit.leverage_cap import get_max_leverage # --- Body models --- @@ -554,7 +555,7 @@ def create_app( await client.set_leverage(body.instrument_name, lev) except Exception: pass - return await client.place_order( + result = await client.place_order( instrument_name=body.instrument_name, side=body.side, amount=body.amount, @@ -564,6 +565,14 @@ def create_app( post_only=body.post_only, label=body.label, ) + audit_write_op( + principal=principal, action="place_order", exchange="deribit", + target=body.instrument_name, + payload={"side": body.side, "amount": body.amount, "type": body.type, + "price": body.price, "leverage": lev, "label": body.label}, + result=result, + ) + return result @app.post("/tools/place_combo_order", tags=["writes"]) async def t_place_combo_order( @@ -577,7 +586,7 @@ def create_app( await client.set_leverage(leg.instrument_name, lev) except Exception: pass - return await client.place_combo_order( + result = await client.place_combo_order( legs=[leg.model_dump() for leg in body.legs], side=body.side, amount=body.amount, @@ -585,34 +594,62 @@ def create_app( price=body.price, label=body.label, ) + audit_write_op( + principal=principal, action="place_combo_order", exchange="deribit", + target=result.get("combo_instrument") if isinstance(result, dict) else None, + payload={"legs": [leg.model_dump() for leg in body.legs], + "side": body.side, "amount": body.amount, "leverage": lev}, + result=result if isinstance(result, dict) else None, + ) + return result @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) + result = await client.cancel_order(body.order_id) + audit_write_op( + principal=principal, action="cancel_order", exchange="deribit", + target=body.order_id, payload={}, result=result, + ) + return result @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.order_id, body.stop_price) + result = await client.set_stop_loss(body.order_id, body.stop_price) + audit_write_op( + principal=principal, action="set_stop_loss", exchange="deribit", + target=body.order_id, payload={"stop_price": body.stop_price}, result=result, + ) + return result @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.order_id, body.tp_price) + result = await client.set_take_profit(body.order_id, body.tp_price) + audit_write_op( + principal=principal, action="set_take_profit", exchange="deribit", + target=body.order_id, payload={"tp_price": body.tp_price}, result=result, + ) + return result @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_name) + result = await client.close_position(body.instrument_name) + audit_write_op( + principal=principal, action="close_position", exchange="deribit", + target=body.instrument_name, payload={}, result=result, + ) + return result # ───── MCP endpoint (/mcp) — bridge verso /tools/* ───── port = int(os.environ.get("PORT", "9011")) diff --git a/services/mcp-deribit/tests/test_environment_info.py b/services/mcp-deribit/tests/test_environment_info.py index 5344ade..080c958 100644 --- a/services/mcp-deribit/tests/test_environment_info.py +++ b/services/mcp-deribit/tests/test_environment_info.py @@ -3,7 +3,6 @@ from __future__ import annotations from unittest.mock import AsyncMock from fastapi.testclient import TestClient - from mcp_common.auth import Principal, TokenStore from mcp_common.environment import EnvironmentInfo from mcp_deribit.server import create_app diff --git a/services/mcp-deribit/tests/test_leverage_cap.py b/services/mcp-deribit/tests/test_leverage_cap.py index 0e5f73c..f70bd86 100644 --- a/services/mcp-deribit/tests/test_leverage_cap.py +++ b/services/mcp-deribit/tests/test_leverage_cap.py @@ -2,7 +2,6 @@ from __future__ import annotations import pytest from fastapi import HTTPException - from mcp_deribit.leverage_cap import enforce_leverage, get_max_leverage diff --git a/services/mcp-deribit/tests/test_server_acl.py b/services/mcp-deribit/tests/test_server_acl.py index 28378c5..3777546 100644 --- a/services/mcp-deribit/tests/test_server_acl.py +++ b/services/mcp-deribit/tests/test_server_acl.py @@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi.testclient import TestClient -from mcp_deribit.server import create_app from mcp_common.auth import Principal, TokenStore +from mcp_deribit.server import create_app @pytest.fixture diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py index 512dc58..397d3e5 100644 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py +++ b/services/mcp-hyperliquid/src/mcp_hyperliquid/__main__.py @@ -1,56 +1,30 @@ from __future__ import annotations -import json -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_common.app_factory import ExchangeAppSpec, run_exchange_main 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) - - env_info = resolve_environment( - creds, - env_var="HYPERLIQUID_TESTNET", - flag_key="testnet", - exchange="hyperliquid", - default_base_url_live="https://api.hyperliquid.xyz", - default_base_url_testnet="https://api.hyperliquid-testnet.xyz", - ) - - client = HyperliquidClient( +SPEC = ExchangeAppSpec( + exchange="hyperliquid", + creds_env_var="HYPERLIQUID_WALLET_FILE", + env_var="HYPERLIQUID_TESTNET", + flag_key="testnet", + default_base_url_live="https://api.hyperliquid.xyz", + default_base_url_testnet="https://api.hyperliquid-testnet.xyz", + default_port=9012, + build_client=lambda creds, env_info: HyperliquidClient( wallet_address=creds["wallet_address"], private_key=creds["private_key"], testnet=(env_info.environment == "testnet"), api_wallet_address=creds.get("api_wallet_address"), - ) + ), + build_app=create_app, +) - 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, - creds=creds, - env_info=env_info, - ) - 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")), - ) + +def main(): + run_exchange_main(SPEC) if __name__ == "__main__": diff --git a/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py b/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py index 1c9e7d4..ffb434f 100644 --- a/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py +++ b/services/mcp-hyperliquid/src/mcp_hyperliquid/server.py @@ -3,15 +3,16 @@ from __future__ import annotations import os from fastapi import Depends, FastAPI, HTTPException +from mcp_common.audit import audit_write_op from mcp_common.auth import Principal, TokenStore, require_principal -from mcp_common.mcp_bridge import mount_mcp_endpoint 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.mcp_bridge import mount_mcp_endpoint from mcp_common.server import build_app from pydantic import BaseModel, field_validator, model_validator from mcp_hyperliquid.client import HyperliquidClient +from mcp_hyperliquid.leverage_cap import enforce_leverage as _enforce_leverage +from mcp_hyperliquid.leverage_cap import get_max_leverage # --- Body models --- @@ -305,7 +306,7 @@ def create_app( ): _check(principal, core=True) _enforce_leverage(body.leverage, creds=creds, exchange="hyperliquid") - return await client.place_order( + result = await client.place_order( instrument=body.instrument, side=body.side, amount=body.amount, @@ -313,34 +314,67 @@ def create_app( price=body.price, reduce_only=body.reduce_only, ) + audit_write_op( + principal=principal, action="place_order", exchange="hyperliquid", + target=body.instrument, + payload={"side": body.side, "amount": body.amount, "type": body.type, + "price": body.price, "reduce_only": body.reduce_only, + "leverage": body.leverage}, + result=result, + ) + return result @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) + result = await client.cancel_order(body.order_id, body.instrument) + audit_write_op( + principal=principal, action="cancel_order", exchange="hyperliquid", + target=body.order_id, payload={"instrument": body.instrument}, result=result, + ) + return result @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) + result = await client.set_stop_loss(body.instrument, body.stop_price, body.size) + audit_write_op( + principal=principal, action="set_stop_loss", exchange="hyperliquid", + target=body.instrument, + payload={"stop_price": body.stop_price, "size": body.size}, + result=result, + ) + return result @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) + result = await client.set_take_profit(body.instrument, body.tp_price, body.size) + audit_write_op( + principal=principal, action="set_take_profit", exchange="hyperliquid", + target=body.instrument, + payload={"tp_price": body.tp_price, "size": body.size}, + result=result, + ) + return result @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) + result = await client.close_position(body.instrument) + audit_write_op( + principal=principal, action="close_position", exchange="hyperliquid", + target=body.instrument, payload={}, result=result, + ) + return result # ───── MCP endpoint (/mcp) — bridge verso /tools/* ───── port = int(os.environ.get("PORT", "9012")) diff --git a/services/mcp-hyperliquid/tests/__init__.py b/services/mcp-hyperliquid/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/services/mcp-hyperliquid/tests/test_environment_info.py b/services/mcp-hyperliquid/tests/test_environment_info.py index 53295de..d9f7bf7 100644 --- a/services/mcp-hyperliquid/tests/test_environment_info.py +++ b/services/mcp-hyperliquid/tests/test_environment_info.py @@ -1,9 +1,8 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import 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 diff --git a/services/mcp-hyperliquid/tests/test_leverage_cap.py b/services/mcp-hyperliquid/tests/test_leverage_cap.py index 8344543..51140b8 100644 --- a/services/mcp-hyperliquid/tests/test_leverage_cap.py +++ b/services/mcp-hyperliquid/tests/test_leverage_cap.py @@ -2,7 +2,6 @@ from __future__ import annotations import pytest from fastapi import HTTPException - from mcp_hyperliquid.leverage_cap import enforce_leverage, get_max_leverage diff --git a/services/mcp-hyperliquid/tests/test_server_acl.py b/services/mcp-hyperliquid/tests/test_server_acl.py index c8e9e43..e1bf8f1 100644 --- a/services/mcp-hyperliquid/tests/test_server_acl.py +++ b/services/mcp-hyperliquid/tests/test_server_acl.py @@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest from fastapi.testclient import TestClient -from mcp_hyperliquid.server import create_app from mcp_common.auth import Principal, TokenStore +from mcp_hyperliquid.server import create_app @pytest.fixture diff --git a/services/mcp-macro/src/mcp_macro/__main__.py b/services/mcp-macro/src/mcp_macro/__main__.py index 42b3fdf..f985d79 100644 --- a/services/mcp-macro/src/mcp_macro/__main__.py +++ b/services/mcp-macro/src/mcp_macro/__main__.py @@ -5,12 +5,10 @@ import os import uvicorn from mcp_common.auth import load_token_store_from_files - from mcp_common.logging import configure_root_logging from mcp_macro.server import create_app - configure_root_logging() # CER-P5-009 def main(): diff --git a/services/mcp-macro/tests/test_server_acl.py b/services/mcp-macro/tests/test_server_acl.py index 4e17f9c..a1a312f 100644 --- a/services/mcp-macro/tests/test_server_acl.py +++ b/services/mcp-macro/tests/test_server_acl.py @@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, patch import pytest from fastapi.testclient import TestClient -from mcp_macro.server import create_app from mcp_common.auth import Principal, TokenStore +from mcp_macro.server import create_app @pytest.fixture diff --git a/services/mcp-sentiment/src/mcp_sentiment/__main__.py b/services/mcp-sentiment/src/mcp_sentiment/__main__.py index 8d8ec47..52aa248 100644 --- a/services/mcp-sentiment/src/mcp_sentiment/__main__.py +++ b/services/mcp-sentiment/src/mcp_sentiment/__main__.py @@ -5,7 +5,6 @@ import os import uvicorn from mcp_common.auth import load_token_store_from_files - from mcp_common.logging import configure_root_logging from mcp_sentiment.server import create_app diff --git a/services/mcp-sentiment/src/mcp_sentiment/fetchers.py b/services/mcp-sentiment/src/mcp_sentiment/fetchers.py index b1d29c3..70a0529 100644 --- a/services/mcp-sentiment/src/mcp_sentiment/fetchers.py +++ b/services/mcp-sentiment/src/mcp_sentiment/fetchers.py @@ -336,7 +336,8 @@ async def fetch_funding_rates(asset: str = "BTC") -> dict[str, Any]: async def fetch_cross_exchange_funding(assets: list[str] | None = None) -> dict[str, Any]: """Snapshot multi-asset funding rates con spread e arbitrage detection.""" - from datetime import UTC, datetime as _dt + from datetime import UTC + from datetime import datetime as _dt assets = [a.upper() for a in (assets or ["BTC", "ETH", "SOL"])] snapshot: dict[str, dict[str, Any]] = {} diff --git a/services/mcp-sentiment/src/mcp_sentiment/server.py b/services/mcp-sentiment/src/mcp_sentiment/server.py index 57cb909..68bac9b 100644 --- a/services/mcp-sentiment/src/mcp_sentiment/server.py +++ b/services/mcp-sentiment/src/mcp_sentiment/server.py @@ -12,9 +12,9 @@ from pydantic import BaseModel logger = logging.getLogger(__name__) from mcp_sentiment.fetchers import ( - fetch_crypto_news, fetch_cointegration_pairs, fetch_cross_exchange_funding, + fetch_crypto_news, fetch_funding_arb_spread, fetch_funding_rates, fetch_liquidation_heatmap, diff --git a/services/mcp-sentiment/tests/test_server_acl.py b/services/mcp-sentiment/tests/test_server_acl.py index a422356..b5fd28a 100644 --- a/services/mcp-sentiment/tests/test_server_acl.py +++ b/services/mcp-sentiment/tests/test_server_acl.py @@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, patch import pytest from fastapi.testclient import TestClient -from mcp_sentiment.server import create_app from mcp_common.auth import Principal, TokenStore +from mcp_sentiment.server import create_app @pytest.fixture