diff --git a/analytics_mcp.py b/analytics_mcp.py index 20f559e..e75d426 100644 --- a/analytics_mcp.py +++ b/analytics_mcp.py @@ -26,6 +26,7 @@ Env: """ from __future__ import annotations +import contextvars import hmac import logging import os @@ -65,6 +66,12 @@ def _get_logger(name: str) -> logging.Logger: log = _get_logger("server") +# Per-request caller name, set by BearerAuth from the matched token so the tools can +# attribute each query to an analyst in the logs. A ContextVar (not a tool arg) because +# FastMCP tools never receive the HTTP request; anyio propagates the context into the +# worker thread that runs each sync tool. Defaults to "?" if auth ever didn't run. +_caller_var: contextvars.ContextVar[str] = contextvars.ContextVar("caller", default="?") + DATABASE_URL = os.environ["DATABASE_URL"] # analytics_ro DSN (set by deploy) MAX_ROWS_CEIL = int(os.getenv("MCP_MAX_ROWS", "10000")) # Schemas the introspection helpers (list_tables/describe_table/sample_table) expose. @@ -284,7 +291,10 @@ def query(sql: str, max_rows: int = 1000) -> dict: truncated = len(rows) > cap rows = rows[:cap] dur_ms = int((time.monotonic() - t0) * 1000) - log.info("query rows=%d trunc=%s %dms :: %s", len(rows), truncated, dur_ms, sql[:200]) + log.info( + "query caller=%s rows=%d trunc=%s %dms :: %s", + _caller_var.get(), len(rows), truncated, dur_ms, sql[:200], + ) return {"row_count": len(rows), "truncated": truncated, "rows": rows} @@ -393,6 +403,7 @@ class BearerAuth(BaseHTTPMiddleware): if caller is None: return JSONResponse({"error": "unauthorized"}, status_code=401) request.state.caller = caller + _caller_var.set(caller) # so the tools can attribute the query in the logs return await call_next(request)