diff --git a/README.md b/README.md index 80adfe6..b71f376 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,20 @@ Le tool puramente read-only (`/mcp-macro/*` e `/mcp-sentiment/*`) richiedono comunque un bearer valido, ma il valore (testnet o mainnet) è indifferente perché non hanno endpoint testnet. +### Header X-Bot-Tag (identificazione bot) + +Tutte le chiamate a `/mcp-*` richiedono inoltre l'header `X-Bot-Tag` con +una stringa identificativa del bot chiamante (massimo 64 caratteri). Il +valore viene loggato negli audit record per tracciare quale bot ha +eseguito ogni operazione write. Esempio di richiesta: + + Authorization: Bearer $MAINNET_TOKEN + X-Bot-Tag: scanner-alpha-prod + +Se l'header è assente o vuoto la risposta è `400 BAD_REQUEST`. L'header +non è richiesto sugli endpoint pubblici (`/health`, `/apidocs`, +`/openapi.json`) né sull'endpoint admin `/admin/audit`. + ## Endpoint principali | Path | Descrizione | @@ -75,6 +89,33 @@ indifferente perché non hanno endpoint testnet. | `POST /mcp-alpaca/tools/{tool}` | Tool exchange Alpaca | | `POST /mcp-macro/tools/{tool}` | Tool macro/market data | | `POST /mcp-sentiment/tools/{tool}` | Tool sentiment/news | +| `GET /admin/audit` | Query dell'audit log JSONL (bearer richiesto, no X-Bot-Tag) | + +## Audit query + +`GET /admin/audit` legge il file JSONL puntato da `AUDIT_LOG_FILE` e +restituisce i record filtrati. Richiede un bearer valido (testnet o +mainnet); non richiede l'header `X-Bot-Tag`. + +Parametri di query (tutti opzionali): + +- `from`, `to`: ISO 8601 datetime (es. `2026-05-01` o `2026-05-01T12:34:56Z`) +- `actor`: `testnet` | `mainnet` +- `exchange`: nome dell'exchange (`deribit`, `bybit`, `hyperliquid`, `alpaca`) +- `action`: nome del tool (es. `place_order`) +- `bot_tag`: identificatore del bot +- `limit`: massimo record restituiti, default `1000`, massimo `10000` + +Esempio di chiamata: + + curl -H "Authorization: Bearer $MAINNET_TOKEN" \ + "http://localhost:9000/admin/audit?from=2026-05-01&actor=mainnet&action=place_order&limit=100" + +Se `AUDIT_LOG_FILE` non è configurata l'endpoint risponde `count: 0` con +un campo `warning`. Per abilitare il sink persistente impostare nel `.env`: + + AUDIT_LOG_FILE=/var/log/cerbero-mcp/audit.jsonl + AUDIT_LOG_BACKUP_DAYS=30 ## Tool disponibili diff --git a/src/cerbero_mcp/__main__.py b/src/cerbero_mcp/__main__.py index eb18a2a..709b081 100644 --- a/src/cerbero_mcp/__main__.py +++ b/src/cerbero_mcp/__main__.py @@ -15,6 +15,7 @@ from typing import Literal, cast import uvicorn from fastapi import FastAPI +from cerbero_mcp import admin from cerbero_mcp.client_registry import ClientRegistry from cerbero_mcp.common.logging import configure_root_logging from cerbero_mcp.exchanges import build_client @@ -62,6 +63,7 @@ def _make_app(settings: Settings) -> FastAPI: app.include_router(alpaca.make_router()) app.include_router(macro.make_router()) app.include_router(sentiment.make_router()) + app.include_router(admin.make_admin_router()) return app diff --git a/src/cerbero_mcp/admin.py b/src/cerbero_mcp/admin.py new file mode 100644 index 0000000..b3f445f --- /dev/null +++ b/src/cerbero_mcp/admin.py @@ -0,0 +1,158 @@ +"""Endpoint admin: query audit log con filtri.""" +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any, Literal + +from fastapi import APIRouter, HTTPException, Query, Request + +MAX_RECORDS = 10000 +DEFAULT_LIMIT = 1000 + + +def _parse_iso(value: str | None) -> datetime | None: + if not value: + return None + try: + # supporta sia "2026-05-01" sia "2026-05-01T12:34:56Z" + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as e: + raise HTTPException(400, f"invalid datetime: {value}") from e + + +def _record_timestamp(rec: dict[str, Any]) -> datetime | None: + """Estrae il timestamp da un record audit. JsonFormatter mette 'asctime' + in formato '2026-05-01 12:34:56,789'. Lo parsiamo come UTC. + """ + ts = rec.get("asctime") or rec.get("timestamp") + if not ts: + return None + try: + # asctime format default: 'YYYY-MM-DD HH:MM:SS,mmm' + ts_clean = ts.replace(",", ".") + return datetime.fromisoformat(ts_clean) + except ValueError: + return None + + +def _matches_filters( + rec: dict[str, Any], + *, + from_dt: datetime | None, + to_dt: datetime | None, + actor: str | None, + exchange: str | None, + action: str | None, + bot_tag: str | None, +) -> bool: + if rec.get("audit_event") != "write_op": + return False + if actor is not None and rec.get("actor") != actor: + return False + if exchange is not None and rec.get("exchange") != exchange: + return False + if action is not None and rec.get("action") != action: + return False + if bot_tag is not None and rec.get("bot_tag") != bot_tag: + return False + if from_dt is not None or to_dt is not None: + rec_ts = _record_timestamp(rec) + if rec_ts is None: + return False + if from_dt is not None and rec_ts < from_dt: + return False + if to_dt is not None and rec_ts > to_dt: + return False + return True + + +def _read_audit_records(file_path: Path) -> list[dict[str, Any]]: + if not file_path.exists(): + return [] + out: list[dict[str, Any]] = [] + with file_path.open("r", encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped: + continue + try: + out.append(json.loads(stripped)) + except json.JSONDecodeError: + continue + return out + + +def make_admin_router() -> APIRouter: + r = APIRouter(prefix="/admin", tags=["admin"]) + + @r.get("/audit") + async def query_audit( + request: Request, + from_: str | None = Query(None, alias="from"), + to: str | None = Query(None), + actor: Literal["testnet", "mainnet"] | None = Query(None), + exchange: str | None = Query(None), + action: str | None = Query(None), + bot_tag: str | None = Query(None), + limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_RECORDS), + ) -> dict[str, Any]: + """Restituisce i record audit_write_op filtrati. + + Param query (tutti opzionali): + - from / to: ISO 8601 datetime (es. 2026-05-01 oppure 2026-05-01T12:34:56) + - actor: testnet | mainnet + - exchange: deribit | bybit | hyperliquid | alpaca + - action: nome del tool (es. place_order) + - bot_tag: identificatore bot + - limit: max record da ritornare (default 1000, max 10000) + + Source: AUDIT_LOG_FILE (env var). Se non settata, ritorna lista vuota + con warning. + """ + from_dt = _parse_iso(from_) + to_dt = _parse_iso(to) + + file_str = os.environ.get("AUDIT_LOG_FILE", "").strip() + if not file_str: + return { + "records": [], + "count": 0, + "warning": "AUDIT_LOG_FILE not configured; no persistent audit log to query", + "from": from_, + "to": to, + } + + file_path = Path(file_str) + all_records = _read_audit_records(file_path) + filtered = [ + rec for rec in all_records + if _matches_filters( + rec, + from_dt=from_dt, to_dt=to_dt, + actor=actor, exchange=exchange, action=action, + bot_tag=bot_tag, + ) + ] + # sort desc per timestamp (ultimi prima) + limit + filtered.sort( + key=lambda rec: _record_timestamp(rec) or datetime.min, + reverse=True, + ) + if len(filtered) > limit: + filtered = filtered[:limit] + + return { + "records": filtered, + "count": len(filtered), + "from": from_, + "to": to, + "filters": { + "actor": actor, "exchange": exchange, + "action": action, "bot_tag": bot_tag, + }, + } + + return r diff --git a/src/cerbero_mcp/auth.py b/src/cerbero_mcp/auth.py index f0feb69..cb6e443 100644 --- a/src/cerbero_mcp/auth.py +++ b/src/cerbero_mcp/auth.py @@ -1,4 +1,8 @@ -"""Bearer auth middleware: bearer token → request.state.environment.""" +"""Bearer auth middleware: bearer token → request.state.environment. + +Inoltre richiede header `X-Bot-Tag` su tutte le chiamate non whitelisted, +così che l'audit log identifichi il bot chiamante. +""" from __future__ import annotations import secrets @@ -9,7 +13,17 @@ from fastapi.responses import JSONResponse Environment = Literal["testnet", "mainnet"] -WHITELIST_PATHS = frozenset({"/health", "/apidocs", "/openapi.json", "/docs", "/redoc"}) +# Path che bypassano sia bearer auth sia bot_tag check. +PATH_WHITELIST_FULL = frozenset( + {"/health", "/apidocs", "/openapi.json", "/docs", "/redoc"} +) +# Path che richiedono bearer ma NON il bot_tag (admin endpoint). +PATH_WHITELIST_BOT_TAG_ONLY = frozenset({"/admin/audit"}) + +# Backward-compat alias (vecchi import). +WHITELIST_PATHS = PATH_WHITELIST_FULL + +MAX_BOT_TAG_LEN = 64 def _extract_bearer(auth_header: str) -> str | None: @@ -35,13 +49,17 @@ def install_auth_middleware( testnet_token: str, mainnet_token: str, ) -> None: - """Registra middleware di auth bearer sull'app FastAPI.""" + """Registra middleware di auth bearer + bot_tag sull'app FastAPI.""" @app.middleware("http") async def auth_middleware(request: Request, call_next): - if request.url.path in WHITELIST_PATHS: + path = request.url.path + + # 1. Whitelist totale: nessun check. + if path in PATH_WHITELIST_FULL: return await call_next(request) + # 2. Bearer auth (sempre richiesto). token = _extract_bearer(request.headers.get("Authorization", "")) if token is None: return JSONResponse( @@ -57,4 +75,25 @@ def install_auth_middleware( "message": "invalid token"}}, ) request.state.environment = env + + # 3. Whitelist parziale (admin): bearer ok, no bot_tag check. + if path in PATH_WHITELIST_BOT_TAG_ONLY: + return await call_next(request) + + # 4. X-Bot-Tag obbligatorio. + raw_tag = request.headers.get("X-Bot-Tag", "") + tag = raw_tag.strip() if raw_tag else "" + if not tag: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"error": {"code": "BAD_REQUEST", + "message": "missing X-Bot-Tag header"}}, + ) + if len(tag) > MAX_BOT_TAG_LEN: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"error": {"code": "BAD_REQUEST", + "message": "X-Bot-Tag too long"}}, + ) + request.state.bot_tag = tag return await call_next(request) diff --git a/src/cerbero_mcp/common/audit.py b/src/cerbero_mcp/common/audit.py index 427f050..ed05eba 100644 --- a/src/cerbero_mcp/common/audit.py +++ b/src/cerbero_mcp/common/audit.py @@ -67,6 +67,7 @@ def _configure_audit_sink() -> None: def audit_write_op( *, actor: str | None = None, + bot_tag: str | None = None, action: str, exchange: str, target: str | None = None, @@ -78,6 +79,7 @@ def audit_write_op( actor: identificatore di chi ha invocato (es. "testnet", "mainnet", oppure None per logging anonimo). + bot_tag: identificatore del bot chiamante (header X-Bot-Tag). 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. @@ -91,6 +93,7 @@ def audit_write_op( "action": action, "exchange": exchange, "actor": actor, + "bot_tag": bot_tag, "target": target, "payload": payload or {}, } diff --git a/src/cerbero_mcp/common/audit_helpers.py b/src/cerbero_mcp/common/audit_helpers.py index ab98ff6..dcb0308 100644 --- a/src/cerbero_mcp/common/audit_helpers.py +++ b/src/cerbero_mcp/common/audit_helpers.py @@ -57,6 +57,7 @@ async def audit_call( ) -> Any: """Esegue tool_fn e logga audit (success o error). Riraisola eccezioni.""" actor = getattr(request.state, "environment", None) + bot_tag = getattr(request.state, "bot_tag", None) target = _extract_target(params, target_field) payload = _safe_dump(params) @@ -65,6 +66,7 @@ async def audit_call( except Exception as e: audit_write_op( actor=actor, + bot_tag=bot_tag, action=action, exchange=exchange, target=target, @@ -85,6 +87,7 @@ async def audit_call( audit_write_op( actor=actor, + bot_tag=bot_tag, action=action, exchange=exchange, target=target, diff --git a/tests/integration/test_env_routing.py b/tests/integration/test_env_routing.py index 046ea14..4036c12 100644 --- a/tests/integration/test_env_routing.py +++ b/tests/integration/test_env_routing.py @@ -29,11 +29,11 @@ def app(monkeypatch): def _bearer_test(): - return {"Authorization": "Bearer t_test_123"} + return {"Authorization": "Bearer t_test_123", "X-Bot-Tag": "test-bot"} def _bearer_live(): - return {"Authorization": "Bearer t_live_456"} + return {"Authorization": "Bearer t_live_456", "X-Bot-Tag": "test-bot"} # ── Spy helpers ────────────────────────────────────────────────────────────── diff --git a/tests/unit/common/test_audit_helpers.py b/tests/unit/common/test_audit_helpers.py index ec60858..d2f6fdb 100644 --- a/tests/unit/common/test_audit_helpers.py +++ b/tests/unit/common/test_audit_helpers.py @@ -101,3 +101,67 @@ async def test_audit_call_no_params_no_target(): tool_fn=tool_fn, ) assert result == {"ok": True} + + +@pytest.mark.asyncio +async def test_audit_call_propagates_bot_tag(monkeypatch): + """bot_tag letto da request.state e propagato a audit_write_op.""" + from cerbero_mcp.common.audit_helpers import audit_call + + logged = [] + + def fake_audit(**kw): + logged.append(kw) + + monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit) + + class FakeRequest: + class _State: + environment = "testnet" + bot_tag = "scanner-alpha" + state = _State() + + async def tool_fn(): + return {"order_id": "abc"} + + await audit_call( + request=FakeRequest(), # type: ignore[arg-type] + exchange="deribit", + action="place_order", + target_field="instrument_name", + params=FakeReq(instrument_name="BTC-PERPETUAL", qty=0.1), + tool_fn=tool_fn, + ) + assert len(logged) == 1 + assert logged[0]["bot_tag"] == "scanner-alpha" + assert logged[0]["actor"] == "testnet" + + +@pytest.mark.asyncio +async def test_audit_call_bot_tag_none_when_missing(monkeypatch): + """Se request.state.bot_tag non esiste, audit riceve None senza errore.""" + from cerbero_mcp.common.audit_helpers import audit_call + + logged = [] + + def fake_audit(**kw): + logged.append(kw) + + monkeypatch.setattr("cerbero_mcp.common.audit_helpers.audit_write_op", fake_audit) + + class FakeRequest: + class _State: + environment = "testnet" + state = _State() + + async def tool_fn(): + return {"ok": True} + + await audit_call( + request=FakeRequest(), # type: ignore[arg-type] + exchange="bybit", + action="cancel_all_orders", + tool_fn=tool_fn, + ) + assert len(logged) == 1 + assert logged[0]["bot_tag"] is None diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py new file mode 100644 index 0000000..b83b1eb --- /dev/null +++ b/tests/unit/test_admin.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def tmp_audit_file(tmp_path, monkeypatch): + file_path = tmp_path / "audit.jsonl" + monkeypatch.setenv("AUDIT_LOG_FILE", str(file_path)) + return file_path + + +@pytest.fixture +def app(monkeypatch, tmp_audit_file): + from tests.unit.test_settings import _minimal_env + for k, v in _minimal_env().items(): + monkeypatch.setenv(k, v) + from cerbero_mcp.__main__ import _make_app + from cerbero_mcp.settings import Settings + return _make_app(Settings()) + + +def _write_records(file_path: Path, records: list[dict]) -> None: + with file_path.open("w") as f: + for r in records: + f.write(json.dumps(r) + "\n") + + +def _bearer_test(): + return {"Authorization": "Bearer t_test_123"} + + +def test_admin_audit_no_file(app): + """Senza AUDIT_LOG_FILE settato, ritorna empty + warning.""" + import os + os.environ.pop("AUDIT_LOG_FILE", None) + c = TestClient(app) + r = c.get("/admin/audit", headers=_bearer_test()) + assert r.status_code == 200 + body = r.json() + assert body["count"] == 0 + assert "warning" in body + + +def test_admin_audit_no_bearer_returns_401(app): + c = TestClient(app) + r = c.get("/admin/audit") + assert r.status_code == 401 + + +def test_admin_audit_no_bot_tag_required(app, tmp_audit_file): + """Endpoint admin NON richiede X-Bot-Tag (solo bearer).""" + _write_records(tmp_audit_file, []) + c = TestClient(app) + r = c.get("/admin/audit", headers=_bearer_test()) + assert r.status_code == 200 + + +def test_admin_audit_returns_records(app, tmp_audit_file): + records = [ + { + "audit_event": "write_op", + "asctime": "2026-05-01 10:00:00,000", + "actor": "testnet", "bot_tag": "alpha", + "exchange": "deribit", "action": "place_order", + "target": "BTC-PERPETUAL", + "payload": {"qty": 0.1}, + "result": {"order_id": "abc"}, + }, + { + "audit_event": "write_op", + "asctime": "2026-05-01 11:00:00,000", + "actor": "mainnet", "bot_tag": "beta", + "exchange": "bybit", "action": "cancel_order", + "target": "ord-1", + "payload": {}, + }, + ] + _write_records(tmp_audit_file, records) + c = TestClient(app) + r = c.get("/admin/audit", headers=_bearer_test()) + assert r.status_code == 200 + body = r.json() + assert body["count"] == 2 + + +def test_admin_audit_filter_by_actor(app, tmp_audit_file): + records = [ + {"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000", + "actor": "testnet", "bot_tag": "a", "exchange": "deribit", "action": "place_order"}, + {"audit_event": "write_op", "asctime": "2026-05-01 11:00:00,000", + "actor": "mainnet", "bot_tag": "b", "exchange": "bybit", "action": "place_order"}, + ] + _write_records(tmp_audit_file, records) + c = TestClient(app) + r = c.get("/admin/audit?actor=mainnet", headers=_bearer_test()) + assert r.status_code == 200 + body = r.json() + assert body["count"] == 1 + assert body["records"][0]["actor"] == "mainnet" + + +def test_admin_audit_filter_by_date_range(app, tmp_audit_file): + records = [ + {"audit_event": "write_op", "asctime": "2026-04-30 10:00:00,000", + "actor": "testnet", "exchange": "deribit", "action": "place_order"}, + {"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000", + "actor": "testnet", "exchange": "deribit", "action": "place_order"}, + {"audit_event": "write_op", "asctime": "2026-05-02 10:00:00,000", + "actor": "testnet", "exchange": "deribit", "action": "place_order"}, + ] + _write_records(tmp_audit_file, records) + c = TestClient(app) + r = c.get("/admin/audit?from=2026-05-01&to=2026-05-01T23:59:59", headers=_bearer_test()) + assert r.status_code == 200 + assert r.json()["count"] == 1 + + +def test_admin_audit_filter_by_bot_tag(app, tmp_audit_file): + records = [ + {"audit_event": "write_op", "asctime": "2026-05-01 10:00:00,000", + "actor": "testnet", "bot_tag": "alpha", "exchange": "deribit", "action": "place_order"}, + {"audit_event": "write_op", "asctime": "2026-05-01 11:00:00,000", + "actor": "testnet", "bot_tag": "beta", "exchange": "deribit", "action": "place_order"}, + ] + _write_records(tmp_audit_file, records) + c = TestClient(app) + r = c.get("/admin/audit?bot_tag=alpha", headers=_bearer_test()) + assert r.status_code == 200 + assert r.json()["count"] == 1 + assert r.json()["records"][0]["bot_tag"] == "alpha" + + +def test_admin_audit_invalid_date(app, tmp_audit_file): + _write_records(tmp_audit_file, []) + c = TestClient(app) + r = c.get("/admin/audit?from=not-a-date", headers=_bearer_test()) + assert r.status_code == 400 + + +def test_admin_audit_limit(app, tmp_audit_file): + records = [ + {"audit_event": "write_op", "asctime": f"2026-05-01 10:{i:02d}:00,000", + "actor": "testnet", "exchange": "deribit", "action": "place_order"} + for i in range(50) + ] + _write_records(tmp_audit_file, records) + c = TestClient(app) + r = c.get("/admin/audit?limit=10", headers=_bearer_test()) + assert r.status_code == 200 + assert r.json()["count"] == 10 diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index f26ed69..8b2eb1a 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -67,7 +67,10 @@ def test_testnet_token_sets_env_testnet(): return {"env": request.state.environment} c = TestClient(fa) - r = c.get("/mcp-deribit/peek", headers={"Authorization": "Bearer tk_test"}) + r = c.get( + "/mcp-deribit/peek", + headers={"Authorization": "Bearer tk_test", "X-Bot-Tag": "test-bot"}, + ) assert r.status_code == 200 assert r.json() == {"env": "testnet"} @@ -83,7 +86,10 @@ def test_mainnet_token_sets_env_mainnet(): return {"env": request.state.environment} c = TestClient(fa) - r = c.get("/mcp-deribit/peek", headers={"Authorization": "Bearer tk_live"}) + r = c.get( + "/mcp-deribit/peek", + headers={"Authorization": "Bearer tk_live", "X-Bot-Tag": "test-bot"}, + ) assert r.status_code == 200 assert r.json() == {"env": "mainnet"} @@ -96,3 +102,73 @@ def test_uses_compare_digest(): src = inspect.getsource(auth) assert "compare_digest" in src, "auth.py deve usare secrets.compare_digest" + + +# ── X-Bot-Tag header ───────────────────────────────────────────────────────── + +def test_missing_bot_tag_returns_400(): + from cerbero_mcp.auth import install_auth_middleware + fa = FastAPI() + install_auth_middleware(fa, testnet_token="t", mainnet_token="m") + + @fa.get("/mcp-deribit/health") + def h(): + return {"ok": True} + + c = TestClient(fa) + r = c.get("/mcp-deribit/health", headers={"Authorization": "Bearer t"}) + assert r.status_code == 400 + assert "X-Bot-Tag" in r.json()["error"]["message"] + + +def test_bot_tag_accepted_and_set_on_state(): + from cerbero_mcp.auth import install_auth_middleware + fa = FastAPI() + install_auth_middleware(fa, testnet_token="t", mainnet_token="m") + + @fa.get("/mcp-deribit/peek") + def peek(request: Request): + return { + "env": request.state.environment, + "bot_tag": request.state.bot_tag, + } + + c = TestClient(fa) + r = c.get( + "/mcp-deribit/peek", + headers={"Authorization": "Bearer t", "X-Bot-Tag": "scanner-alpha"}, + ) + assert r.status_code == 200 + assert r.json() == {"env": "testnet", "bot_tag": "scanner-alpha"} + + +def test_bot_tag_too_long_returns_400(): + from cerbero_mcp.auth import install_auth_middleware + fa = FastAPI() + install_auth_middleware(fa, testnet_token="t", mainnet_token="m") + + @fa.get("/mcp-deribit/health") + def h(): + return {"ok": True} + + c = TestClient(fa) + r = c.get( + "/mcp-deribit/health", + headers={"Authorization": "Bearer t", "X-Bot-Tag": "x" * 65}, + ) + assert r.status_code == 400 + + +def test_bot_tag_not_required_on_health(): + """Health endpoint deve restare senza auth e senza bot tag.""" + from cerbero_mcp.auth import install_auth_middleware + fa = FastAPI() + install_auth_middleware(fa, testnet_token="t", mainnet_token="m") + + @fa.get("/health") + def h(): + return {"ok": True} + + c = TestClient(fa) + r = c.get("/health") + assert r.status_code == 200