From 8dfb932c8c56f7c5dad2377781e2e914cc8ddfd0 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Wed, 29 Apr 2026 00:09:20 +0200 Subject: [PATCH] feat(mcp-macro): expose COT report tools via MCP endpoint --- services/mcp-macro/src/mcp_macro/server.py | 43 +++++++++++- services/mcp-macro/tests/test_server_acl.py | 76 +++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/services/mcp-macro/src/mcp_macro/server.py b/services/mcp-macro/src/mcp_macro/server.py index 97b110f..18d84c6 100644 --- a/services/mcp-macro/src/mcp_macro/server.py +++ b/services/mcp-macro/src/mcp_macro/server.py @@ -6,11 +6,14 @@ from fastapi import Depends, FastAPI, HTTPException from mcp_common.auth import Principal, TokenStore, require_principal from mcp_common.mcp_bridge import mount_mcp_endpoint from mcp_common.server import build_app -from pydantic import BaseModel +from pydantic import BaseModel, Field from mcp_macro.fetchers import ( fetch_asset_price, fetch_breakeven_inflation, + fetch_cot_disaggregated, + fetch_cot_extreme_positioning, + fetch_cot_tff, fetch_economic_indicators, fetch_equity_futures, fetch_macro_calendar, @@ -57,6 +60,20 @@ class GetBreakevenInflationReq(BaseModel): pass +class GetCotTffReq(BaseModel): + symbol: str + lookback_weeks: int = Field(default=52, ge=4, le=520) + + +class GetCotDisaggregatedReq(BaseModel): + symbol: str + lookback_weeks: int = Field(default=52, ge=4, le=520) + + +class GetCotExtremeReq(BaseModel): + lookback_weeks: int = Field(default=156, ge=4, le=520) + + # --- ACL helper --- def _check(principal: Principal, *, core: bool = False, observer: bool = False) -> None: @@ -139,6 +156,27 @@ def create_app(*, fred_api_key: str = "", finnhub_api_key: str = "", token_store _check(principal, core=True, observer=True) return await fetch_breakeven_inflation(fred_api_key=fred_api_key) + @app.post("/tools/get_cot_tff", tags=["reads"]) + async def t_get_cot_tff( + body: GetCotTffReq, principal: Principal = Depends(require_principal) + ): + _check(principal, core=True, observer=True) + return await fetch_cot_tff(body.symbol, body.lookback_weeks) + + @app.post("/tools/get_cot_disaggregated", tags=["reads"]) + async def t_get_cot_disaggregated( + body: GetCotDisaggregatedReq, principal: Principal = Depends(require_principal) + ): + _check(principal, core=True, observer=True) + return await fetch_cot_disaggregated(body.symbol, body.lookback_weeks) + + @app.post("/tools/get_cot_extreme_positioning", tags=["reads"]) + async def t_get_cot_extreme( + body: GetCotExtremeReq, principal: Principal = Depends(require_principal) + ): + _check(principal, core=True, observer=True) + return await fetch_cot_extreme_positioning(body.lookback_weeks) + # ───── MCP endpoint (/mcp) — bridge verso /tools/* ───── port = int(os.environ.get("PORT", "9013")) mount_mcp_endpoint( @@ -156,6 +194,9 @@ def create_app(*, fred_api_key: str = "", finnhub_api_key: str = "", token_store {"name": "get_equity_futures", "description": "Futures ES/NQ/YM/RTY con session status."}, {"name": "get_yield_curve_slope", "description": "Slope 2y10y/5y30y + butterfly + regime (steep/normal/flat/inverted)."}, {"name": "get_breakeven_inflation", "description": "Breakeven inflation 5Y/10Y + 5y5y forward (FRED T5YIE/T10YIE/T5YIFR)."}, + {"name": "get_cot_tff", "description": "COT TFF report (CFTC) per equity/financial: ES/NQ/RTY/ZN/ZB/6E/6J/DX. Roles: dealer, asset manager, leveraged funds, other."}, + {"name": "get_cot_disaggregated", "description": "COT Disaggregated report (CFTC) per commodities: CL/GC/SI/HG/ZW/ZC/ZS. Roles: producer/merchant, swap dealer, managed money, other."}, + {"name": "get_cot_extreme_positioning", "description": "Scanner posizionamento estremo (percentile ≤5 o ≥95) sui simboli watchlist."}, ], ) diff --git a/services/mcp-macro/tests/test_server_acl.py b/services/mcp-macro/tests/test_server_acl.py index a1a312f..fa24cbc 100644 --- a/services/mcp-macro/tests/test_server_acl.py +++ b/services/mcp-macro/tests/test_server_acl.py @@ -125,3 +125,79 @@ def test_get_market_overview_observer_ok(http): def test_get_market_overview_no_auth_401(http): r = http.post("/tools/get_market_overview", json={}) assert r.status_code == 401 + + +from unittest.mock import AsyncMock, patch + + +def test_get_cot_tff_core_ok(http): + with patch( + "mcp_macro.server.fetch_cot_tff", + new=AsyncMock(return_value={"symbol": "ES", "rows": []}), + ): + r = http.post( + "/tools/get_cot_tff", + headers={"Authorization": "Bearer ct"}, + json={"symbol": "ES"}, + ) + assert r.status_code == 200 + assert r.json()["symbol"] == "ES" + + +def test_get_cot_tff_observer_ok(http): + with patch( + "mcp_macro.server.fetch_cot_tff", + new=AsyncMock(return_value={"symbol": "ES", "rows": []}), + ): + r = http.post( + "/tools/get_cot_tff", + headers={"Authorization": "Bearer ot"}, + json={"symbol": "ES"}, + ) + assert r.status_code == 200 + + +def test_get_cot_tff_no_auth_401(http): + r = http.post("/tools/get_cot_tff", json={"symbol": "ES"}) + assert r.status_code == 401 + + +def test_get_cot_disagg_observer_ok(http): + with patch( + "mcp_macro.server.fetch_cot_disaggregated", + new=AsyncMock(return_value={"symbol": "CL", "rows": []}), + ): + r = http.post( + "/tools/get_cot_disaggregated", + headers={"Authorization": "Bearer ot"}, + json={"symbol": "CL"}, + ) + assert r.status_code == 200 + + +def test_get_cot_disagg_no_auth_401(http): + r = http.post("/tools/get_cot_disaggregated", json={"symbol": "CL"}) + assert r.status_code == 401 + + +def test_get_cot_extreme_positioning_ok(http): + with patch( + "mcp_macro.server.fetch_cot_extreme_positioning", + new=AsyncMock(return_value={"extremes": []}), + ): + r = http.post( + "/tools/get_cot_extreme_positioning", + headers={"Authorization": "Bearer ot"}, + json={}, + ) + assert r.status_code == 200 + + +def test_get_cot_extreme_positioning_lookback_too_short(http): + """Pydantic validation: lookback_weeks < 4 → 422.""" + r = http.post( + "/tools/get_cot_extreme_positioning", + headers={"Authorization": "Bearer ct"}, + json={"lookback_weeks": 2}, + ) + assert r.status_code == 422