diff --git a/litellm/proxy/db/mcp_alert_rules.py b/litellm/proxy/db/mcp_alert_rules.py new file mode 100644 index 00000000000..101da0ca26b --- /dev/null +++ b/litellm/proxy/db/mcp_alert_rules.py @@ -0,0 +1,83 @@ +""" +MCP alert rules: check if a tool call matches any configured alert rule +and fire webhooks when it does. +""" + +import fnmatch +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +import httpx + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.utils import PrismaClient + + +async def check_and_fire_mcp_alerts( + prisma_client: PrismaClient, + mcp_server_name: str, + tool_name: Optional[str], + request_id: str, + user_id: Optional[str], + api_key_hash: Optional[str], + team_id: Optional[str], +) -> None: + """ + Check if a tool call matches any alert rules and fire webhooks. + Called after inserting into SpendLogMCPServerIndex. + """ + if not tool_name: + return + + try: + where: Dict[str, Any] = {"enabled": True} + rules = await prisma_client.db.litellm_mcpalertrule.find_many( + where=where + ) + + for rule in rules: + if rule.mcp_server_name and rule.mcp_server_name != mcp_server_name: + continue + if not fnmatch.fnmatch(tool_name.lower(), rule.tool_name_pattern.lower()): + continue + + payload = { + "alert_name": rule.alert_name, + "alert_rule_id": rule.id, + "mcp_server_name": mcp_server_name, + "tool_name": tool_name, + "request_id": request_id, + "user_id": user_id, + "api_key_hash": api_key_hash, + "team_id": team_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "description": rule.description, + "message": ( + f"MCP Alert: Tool '{tool_name}' was called on server " + f"'{mcp_server_name}' (rule: {rule.alert_name})" + ), + } + + try: + async with httpx.AsyncClient(timeout=10) as client: + await client.post( + rule.webhook_url, + json=payload, + headers={"Content-Type": "application/json"}, + ) + verbose_proxy_logger.info( + "MCP alert fired: rule=%s, tool=%s, server=%s", + rule.alert_name, + tool_name, + mcp_server_name, + ) + except Exception as webhook_err: + verbose_proxy_logger.warning( + "MCP alert webhook failed for rule %s: %s", + rule.alert_name, + webhook_err, + ) + except Exception as e: + verbose_proxy_logger.warning( + "MCP alert rule check failed (non-fatal): %s", e + ) diff --git a/litellm/proxy/db/spend_log_mcp_server_index.py b/litellm/proxy/db/spend_log_mcp_server_index.py new file mode 100644 index 00000000000..f8301a72ab1 --- /dev/null +++ b/litellm/proxy/db/spend_log_mcp_server_index.py @@ -0,0 +1,140 @@ +""" +Track MCP server usage for operational visibility: insert into +SpendLogMCPServerIndex when spend logs are written, so "last N requests +for MCP server X" and "which users/keys called which tools" queries are fast. +""" + +import json +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from litellm._logging import verbose_proxy_logger +from litellm.proxy.utils import PrismaClient + + +def _parse_mcp_info_from_payload( + payload: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """ + Extract MCP server info from spend log payload metadata. + Returns dict with mcp_server_name, tool_name if present. + """ + meta = payload.get("metadata") + if not meta: + return None + if isinstance(meta, str): + try: + meta = json.loads(meta) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(meta, dict): + return None + mcp_tool_call = meta.get("mcp_tool_call_metadata") + if not mcp_tool_call or not isinstance(mcp_tool_call, dict): + return None + + mcp_server_name = mcp_tool_call.get("mcp_server_name") + if not mcp_server_name: + namespaced = payload.get("mcp_namespaced_tool_name") or mcp_tool_call.get( + "namespaced_tool_name" + ) + if namespaced and isinstance(namespaced, str) and "/" in namespaced: + mcp_server_name = namespaced.split("/", 1)[0] + if not mcp_server_name: + return None + + tool_name = mcp_tool_call.get("name") + return { + "mcp_server_name": mcp_server_name, + "tool_name": tool_name, + } + + +async def process_spend_logs_mcp_server_usage( + prisma_client: PrismaClient, + logs_to_process: List[Dict[str, Any]], +) -> None: + """ + After spend logs are written: insert SpendLogMCPServerIndex rows + from mcp_tool_call_metadata in each payload. + """ + if not logs_to_process: + return + + index_rows: List[Dict[str, Any]] = [] + + for payload in logs_to_process: + request_id = payload.get("request_id") + start_time = payload.get("startTime") + if not request_id or not start_time: + continue + if isinstance(start_time, str): + try: + start_time = datetime.fromisoformat( + start_time.replace("Z", "+00:00") + ) + except (ValueError, TypeError): + continue + if start_time.tzinfo is None: + start_time = start_time.replace(tzinfo=timezone.utc) + + mcp_info = _parse_mcp_info_from_payload(payload) + if not mcp_info: + continue + + meta = payload.get("metadata") + if isinstance(meta, str): + try: + meta = json.loads(meta) + except (json.JSONDecodeError, TypeError): + meta = {} + if not isinstance(meta, dict): + meta = {} + + api_key_hash = payload.get("api_key") or meta.get("user_api_key_hash") + user_id = payload.get("user") or meta.get("user_api_key_user_id") + team_id = payload.get("team_id") or meta.get("user_api_key_team_id") + + index_rows.append( + { + "request_id": request_id, + "mcp_server_name": mcp_info["mcp_server_name"], + "tool_name": mcp_info.get("tool_name"), + "api_key_hash": str(api_key_hash) if api_key_hash else None, + "user_id": str(user_id) if user_id else None, + "team_id": str(team_id) if team_id else None, + "start_time": start_time, + } + ) + + if not index_rows: + return + + try: + await prisma_client.db.litellm_spendlogmcpserverindex.create_many( + data=index_rows, + skip_duplicates=True, + ) + except Exception as e: + verbose_proxy_logger.warning( + "MCP server usage tracking (SpendLogMCPServerIndex) failed (non-fatal): %s", + e, + ) + + for row in index_rows: + try: + from litellm.proxy.db.mcp_alert_rules import check_and_fire_mcp_alerts + + await check_and_fire_mcp_alerts( + prisma_client=prisma_client, + mcp_server_name=row["mcp_server_name"], + tool_name=row.get("tool_name"), + request_id=row["request_id"], + user_id=row.get("user_id"), + api_key_hash=row.get("api_key_hash"), + team_id=row.get("team_id"), + ) + except Exception as alert_err: + verbose_proxy_logger.debug( + "MCP alert check failed (non-fatal): %s", alert_err + ) diff --git a/litellm/proxy/management_endpoints/mcp_usage_endpoints.py b/litellm/proxy/management_endpoints/mcp_usage_endpoints.py new file mode 100644 index 00000000000..b48503b14ff --- /dev/null +++ b/litellm/proxy/management_endpoints/mcp_usage_endpoints.py @@ -0,0 +1,608 @@ +""" +MCP server operational visibility endpoints. + +- GET /v1/mcp/usage/logs – paginated request logs per MCP server +- GET /v1/mcp/usage/overview – per-server request counts and top tools +- GET /v1/mcp/usage/tools – which users/keys called which tools on a server +""" + +import json +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel + +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + +router = APIRouter(prefix="/v1/mcp", tags=["MCP Usage"]) + + +# --- Response models --- + + +class MCPUsageLogEntry(BaseModel): + id: str + timestamp: str + mcp_server_name: str + tool_name: Optional[str] = None + api_key_hash: Optional[str] = None + api_key_alias: Optional[str] = None + user_id: Optional[str] = None + team_id: Optional[str] = None + model: Optional[str] = None + status: Optional[str] = None + spend: Optional[float] = None + input_snippet: Optional[str] = None + output_snippet: Optional[str] = None + + +class MCPUsageLogsResponse(BaseModel): + logs: List[MCPUsageLogEntry] + total: int + page: int + page_size: int + + +class MCPServerOverviewRow(BaseModel): + mcp_server_name: str + server_id: Optional[str] = None + description: Optional[str] = None + total_requests: int + top_tools: List[Dict[str, Any]] + unique_users: int + unique_keys: int + + +class MCPUsageOverviewResponse(BaseModel): + servers: List[MCPServerOverviewRow] + total_requests: int + + +class MCPToolUserEntry(BaseModel): + tool_name: str + api_key_hash: Optional[str] = None + api_key_alias: Optional[str] = None + user_id: Optional[str] = None + team_id: Optional[str] = None + call_count: int + last_called: str + + +class MCPToolUsersResponse(BaseModel): + entries: List[MCPToolUserEntry] + total: int + + +# --- Helpers --- + + +def _snippet(text: Any, max_len: int = 200) -> Optional[str]: + if text is None: + return None + if isinstance(text, str): + s = text + elif isinstance(text, list): + parts = [] + for item in text: + if isinstance(item, dict) and "content" in item: + c = item["content"] + parts.append(c if isinstance(c, str) else str(c)) + else: + parts.append(str(item)) + s = " ".join(parts) + else: + s = str(text) + result = (s[:max_len] + "...") if len(s) > max_len else s + if result == "{}": + return None + return result + + +def _input_snippet_for_log(sl: Any) -> Optional[str]: + out = _snippet(sl.messages) + if out: + return out + psr = getattr(sl, "proxy_server_request", None) + if not psr: + return None + if isinstance(psr, str): + try: + psr = json.loads(psr) + except Exception: + return _snippet(psr) + if isinstance(psr, dict): + msgs = psr.get("messages") + if msgs is None and isinstance(psr.get("body"), dict): + msgs = psr["body"].get("messages") + out = _snippet(msgs) + if out: + return out + return _snippet(psr) + return _snippet(psr) + + +def _build_index_where( + mcp_server_name: Optional[str], + tool_name: Optional[str], + start_date: Optional[str], + end_date: Optional[str], +) -> Dict[str, Any]: + where: Dict[str, Any] = {} + if mcp_server_name: + where["mcp_server_name"] = mcp_server_name + if tool_name: + where["tool_name"] = tool_name + if start_date or end_date: + st_filter: Dict[str, Any] = {} + if start_date: + sd = start_date.replace("Z", "+00:00").strip() + if "T" not in sd: + sd += "T00:00:00+00:00" + st_filter["gte"] = datetime.fromisoformat(sd) + if end_date: + ed = end_date.replace("Z", "+00:00").strip() + if "T" not in ed: + ed += "T23:59:59+00:00" + st_filter["lte"] = datetime.fromisoformat(ed) + where["start_time"] = st_filter + return where + + +def _extract_api_key_alias(sl: Any) -> Optional[str]: + meta = getattr(sl, "metadata", None) + if isinstance(meta, str): + try: + meta = json.loads(meta) + except Exception: + return None + if isinstance(meta, dict): + return meta.get("user_api_key_alias") + return None + + +# --- Endpoints --- + + +@router.get( + "/usage/logs", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUsageLogsResponse, +) +async def mcp_usage_logs( + mcp_server_name: Optional[str] = Query(None), + tool_name: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + start_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return paginated MCP server request logs from SpendLogs via index.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return MCPUsageLogsResponse( + logs=[], total=0, page=page, page_size=page_size + ) + + try: + where = _build_index_where(mcp_server_name, tool_name, start_date, end_date) + + index_rows = await prisma_client.db.litellm_spendlogmcpserverindex.find_many( + where=where, + order={"start_time": "desc"}, + skip=(page - 1) * page_size, + take=page_size, + ) + total = await prisma_client.db.litellm_spendlogmcpserverindex.count( + where=where + ) + request_ids = [r.request_id for r in index_rows] + if not request_ids: + return MCPUsageLogsResponse( + logs=[], total=total, page=page, page_size=page_size + ) + + spend_logs = await prisma_client.db.litellm_spendlogs.find_many( + where={"request_id": {"in": request_ids}} + ) + log_by_id = {s.request_id: s for s in spend_logs} + + logs_out: List[MCPUsageLogEntry] = [] + for r in index_rows: + sl = log_by_id.get(r.request_id) + if not sl: + continue + ts = ( + sl.startTime.isoformat() + if hasattr(sl.startTime, "isoformat") + else str(sl.startTime) + ) + logs_out.append( + MCPUsageLogEntry( + id=r.request_id, + timestamp=ts, + mcp_server_name=r.mcp_server_name, + tool_name=r.tool_name, + api_key_hash=r.api_key_hash, + api_key_alias=_extract_api_key_alias(sl), + user_id=r.user_id, + team_id=r.team_id, + model=sl.model, + status=sl.status, + spend=float(sl.spend) if sl.spend else None, + input_snippet=_input_snippet_for_log(sl), + output_snippet=_snippet(sl.response), + ) + ) + return MCPUsageLogsResponse( + logs=logs_out, total=total, page=page, page_size=page_size + ) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +@router.get( + "/usage/overview", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUsageOverviewResponse, +) +async def mcp_usage_overview( + start_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return per-MCP-server request counts and top tools for the dashboard.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return MCPUsageOverviewResponse(servers=[], total_requests=0) + + try: + where = _build_index_where(None, None, start_date, end_date) + + index_rows = await prisma_client.db.litellm_spendlogmcpserverindex.find_many( + where=where if where else {}, + ) + + server_info_by_name: Dict[str, Dict[str, Optional[str]]] = {} + try: + mcp_servers_db = ( + await prisma_client.db.litellm_mcpservertable.find_many() + ) + for srv in mcp_servers_db: + name = getattr(srv, "server_name", None) or getattr( + srv, "alias", None + ) + if name: + server_info_by_name[name] = { + "server_id": srv.server_id, + "description": getattr(srv, "description", None), + } + except Exception: + pass + + server_data: Dict[str, Dict[str, Any]] = {} + for r in index_rows: + name = r.mcp_server_name + if name not in server_data: + server_data[name] = { + "total_requests": 0, + "tools": {}, + "users": set(), + "keys": set(), + } + sd = server_data[name] + sd["total_requests"] += 1 + tool = r.tool_name or "unknown" + sd["tools"][tool] = sd["tools"].get(tool, 0) + 1 + if r.user_id: + sd["users"].add(r.user_id) + if r.api_key_hash: + sd["keys"].add(r.api_key_hash) + + servers: List[MCPServerOverviewRow] = [] + for name, sd in sorted( + server_data.items(), key=lambda x: x[1]["total_requests"], reverse=True + ): + top_tools = sorted( + sd["tools"].items(), key=lambda x: x[1], reverse=True + )[:10] + info = server_info_by_name.get(name, {}) + servers.append( + MCPServerOverviewRow( + mcp_server_name=name, + server_id=info.get("server_id"), + description=info.get("description"), + total_requests=sd["total_requests"], + top_tools=[ + {"name": t, "count": c} for t, c in top_tools + ], + unique_users=len(sd["users"]), + unique_keys=len(sd["keys"]), + ) + ) + + return MCPUsageOverviewResponse( + servers=servers, + total_requests=sum(sd["total_requests"] for sd in server_data.values()), + ) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +@router.get( + "/usage/tools", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPToolUsersResponse, +) +async def mcp_usage_tools( + mcp_server_name: str = Query(..., description="MCP server name"), + start_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Return which users/keys called which tools on a specific MCP server.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return MCPToolUsersResponse(entries=[], total=0) + + try: + where = _build_index_where(mcp_server_name, None, start_date, end_date) + + index_rows = await prisma_client.db.litellm_spendlogmcpserverindex.find_many( + where=where, + order={"start_time": "desc"}, + ) + + aggregation: Dict[tuple, Dict[str, Any]] = {} + for r in index_rows: + key = ( + r.tool_name or "unknown", + r.api_key_hash or "", + r.user_id or "", + r.team_id or "", + ) + if key not in aggregation: + ts = ( + r.start_time.isoformat() + if hasattr(r.start_time, "isoformat") + else str(r.start_time) + ) + aggregation[key] = {"count": 0, "last_called": ts} + aggregation[key]["count"] += 1 + + entries: List[MCPToolUserEntry] = [] + for (tool, api_key, user, team), data in sorted( + aggregation.items(), key=lambda x: x[1]["count"], reverse=True + ): + entries.append( + MCPToolUserEntry( + tool_name=tool, + api_key_hash=api_key or None, + user_id=user or None, + team_id=team or None, + call_count=data["count"], + last_called=data["last_called"], + ) + ) + + return MCPToolUsersResponse(entries=entries, total=len(entries)) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +# --- Alert Rule Models --- + + +class MCPAlertRuleCreate(BaseModel): + mcp_server_name: Optional[str] = None + tool_name_pattern: str + webhook_url: str + alert_name: str + description: Optional[str] = None + enabled: bool = True + + +class MCPAlertRuleResponse(BaseModel): + id: str + mcp_server_name: Optional[str] = None + tool_name_pattern: str + webhook_url: str + alert_name: str + description: Optional[str] = None + enabled: bool + created_at: str + updated_at: str + + +class MCPAlertRulesListResponse(BaseModel): + rules: List[MCPAlertRuleResponse] + total: int + + +# --- Alert Rule Endpoints --- + + +@router.get( + "/alert-rules", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPAlertRulesListResponse, +) +async def list_mcp_alert_rules( + mcp_server_name: Optional[str] = Query(None), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """List all MCP alert rules, optionally filtered by server name.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return MCPAlertRulesListResponse(rules=[], total=0) + + try: + where: Dict[str, Any] = {} + if mcp_server_name: + where["mcp_server_name"] = mcp_server_name + rules = await prisma_client.db.litellm_mcpalertrule.find_many( + where=where if where else {}, + order={"created_at": "desc"}, + ) + result = [ + MCPAlertRuleResponse( + id=r.id, + mcp_server_name=r.mcp_server_name, + tool_name_pattern=r.tool_name_pattern, + webhook_url=r.webhook_url, + alert_name=r.alert_name, + description=r.description, + enabled=r.enabled, + created_at=r.created_at.isoformat() + if hasattr(r.created_at, "isoformat") + else str(r.created_at), + updated_at=r.updated_at.isoformat() + if hasattr(r.updated_at, "isoformat") + else str(r.updated_at), + ) + for r in rules + ] + return MCPAlertRulesListResponse(rules=result, total=len(result)) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +@router.post( + "/alert-rules", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPAlertRuleResponse, +) +async def create_mcp_alert_rule( + data: MCPAlertRuleCreate, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Create a new MCP alert rule.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + from fastapi import HTTPException + + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + rule = await prisma_client.db.litellm_mcpalertrule.create( + data={ + "mcp_server_name": data.mcp_server_name, + "tool_name_pattern": data.tool_name_pattern, + "webhook_url": data.webhook_url, + "alert_name": data.alert_name, + "description": data.description, + "enabled": data.enabled, + } + ) + return MCPAlertRuleResponse( + id=rule.id, + mcp_server_name=rule.mcp_server_name, + tool_name_pattern=rule.tool_name_pattern, + webhook_url=rule.webhook_url, + alert_name=rule.alert_name, + description=rule.description, + enabled=rule.enabled, + created_at=rule.created_at.isoformat() + if hasattr(rule.created_at, "isoformat") + else str(rule.created_at), + updated_at=rule.updated_at.isoformat() + if hasattr(rule.updated_at, "isoformat") + else str(rule.updated_at), + ) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +@router.delete( + "/alert-rules/{rule_id}", + dependencies=[Depends(user_api_key_auth)], +) +async def delete_mcp_alert_rule( + rule_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Delete an MCP alert rule.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + from fastapi import HTTPException + + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + await prisma_client.db.litellm_mcpalertrule.delete( + where={"id": rule_id} + ) + return {"status": "ok", "deleted": rule_id} + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) + + +@router.put( + "/alert-rules/{rule_id}", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPAlertRuleResponse, +) +async def update_mcp_alert_rule( + rule_id: str, + data: MCPAlertRuleCreate, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """Update an existing MCP alert rule.""" + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + from fastapi import HTTPException + + raise HTTPException(status_code=500, detail="Prisma client not initialized") + + try: + rule = await prisma_client.db.litellm_mcpalertrule.update( + where={"id": rule_id}, + data={ + "mcp_server_name": data.mcp_server_name, + "tool_name_pattern": data.tool_name_pattern, + "webhook_url": data.webhook_url, + "alert_name": data.alert_name, + "description": data.description, + "enabled": data.enabled, + }, + ) + return MCPAlertRuleResponse( + id=rule.id, + mcp_server_name=rule.mcp_server_name, + tool_name_pattern=rule.tool_name_pattern, + webhook_url=rule.webhook_url, + alert_name=rule.alert_name, + description=rule.description, + enabled=rule.enabled, + created_at=rule.created_at.isoformat() + if hasattr(rule.created_at, "isoformat") + else str(rule.created_at), + updated_at=rule.updated_at.isoformat() + if hasattr(rule.updated_at, "isoformat") + else str(rule.updated_at), + ) + except Exception as e: + from litellm.proxy.utils import handle_exception_on_proxy + + raise handle_exception_on_proxy(e) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9683b37dbb4..232224c50ba 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -386,6 +386,9 @@ from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( from litellm.proxy.management_endpoints.mcp_management_endpoints import ( router as mcp_management_router, ) +from litellm.proxy.management_endpoints.mcp_usage_endpoints import ( + router as mcp_usage_router, +) from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( router as model_access_group_management_router, ) @@ -13016,6 +13019,7 @@ app.include_router(vector_store_files_router) app.include_router(credential_router) app.include_router(llm_passthrough_router) app.include_router(mcp_management_router) +app.include_router(mcp_usage_router) app.include_router(mcp_byok_oauth_router) app.include_router(anthropic_router) app.include_router(anthropic_skills_router) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 80600585362..aecea4de5c9 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -976,6 +976,38 @@ model LiteLLM_SpendLogToolIndex { @@index([tool_name, start_time]) } +// Index for fast "last N logs for MCP server" from SpendLogs – operational visibility per MCP server +model LiteLLM_SpendLogMCPServerIndex { + request_id String + mcp_server_name String // server name extracted from mcp_tool_call_metadata + tool_name String? // specific tool that was called (e.g. "get_page_content") + api_key_hash String? // hashed api key for "which keys called which tools" + user_id String? // user who made the request + team_id String? // team that made the request + start_time DateTime + + @@id([request_id, mcp_server_name]) + @@index([mcp_server_name, start_time]) + @@index([api_key_hash, start_time]) + @@index([user_id, start_time]) +} + +// Alert rules for MCP tool operations (e.g., notify when a delete operation is invoked) +model LiteLLM_MCPAlertRule { + id String @id @default(uuid()) + mcp_server_name String? // null = match all servers + tool_name_pattern String // glob-style pattern (e.g. "delete*", "*remove*") + webhook_url String // where to send the alert + alert_name String // human-readable name for the rule + description String? + enabled Boolean @default(true) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([mcp_server_name]) + @@index([enabled]) +} + // Prompt table for storing prompt configurations model LiteLLM_PromptTable { id String @id @default(uuid()) diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index e6da95bb78f..9e2152453d6 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -4702,6 +4702,21 @@ async def update_spend_logs_job( tool_tracking_err, ) + # MCP server usage tracking (same batch): SpendLogMCPServerIndex + try: + from litellm.proxy.db.spend_log_mcp_server_index import ( + process_spend_logs_mcp_server_usage, + ) + await process_spend_logs_mcp_server_usage( + prisma_client=prisma_client, + logs_to_process=logs_to_process, + ) + except Exception as mcp_tracking_err: + verbose_proxy_logger.warning( + "Spend tracking - MCP server usage tracking failed (non-fatal): %s", + mcp_tracking_err, + ) + async def _monitor_spend_logs_queue( prisma_client: PrismaClient, diff --git a/tests/test_litellm/proxy/db/test_mcp_alert_rules.py b/tests/test_litellm/proxy/db/test_mcp_alert_rules.py new file mode 100644 index 00000000000..b4d48dbee85 --- /dev/null +++ b/tests/test_litellm/proxy/db/test_mcp_alert_rules.py @@ -0,0 +1,158 @@ +""" +Tests for MCP alert rules (check_and_fire_mcp_alerts). +""" + +import os +import sys +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy.db.mcp_alert_rules import check_and_fire_mcp_alerts + + +def _make_rule( + alert_name="test-alert", + tool_name_pattern="*delete*", + mcp_server_name=None, + webhook_url="https://hooks.example.com/test", + enabled=True, + description=None, +): + rule = MagicMock() + rule.id = "rule-1" + rule.alert_name = alert_name + rule.tool_name_pattern = tool_name_pattern + rule.mcp_server_name = mcp_server_name + rule.webhook_url = webhook_url + rule.enabled = enabled + rule.description = description + return rule + + +class TestCheckAndFireMCPAlerts: + """Test check_and_fire_mcp_alerts.""" + + @pytest.mark.asyncio + async def test_should_skip_when_no_tool_name(self): + mock_prisma = MagicMock() + await check_and_fire_mcp_alerts( + prisma_client=mock_prisma, + mcp_server_name="test-server", + tool_name=None, + request_id="req-1", + user_id=None, + api_key_hash=None, + team_id=None, + ) + + @pytest.mark.asyncio + async def test_should_fire_webhook_when_pattern_matches(self): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_mcpalertrule = MagicMock() + rule = _make_rule(tool_name_pattern="*delete*") + mock_prisma.db.litellm_mcpalertrule.find_many = AsyncMock( + return_value=[rule] + ) + + with patch("litellm.proxy.db.mcp_alert_rules.httpx.AsyncClient") as mock_httpx: + mock_client = AsyncMock() + mock_httpx.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_httpx.return_value.__aexit__ = AsyncMock(return_value=None) + + await check_and_fire_mcp_alerts( + prisma_client=mock_prisma, + mcp_server_name="test-server", + tool_name="delete_item", + request_id="req-1", + user_id="user-1", + api_key_hash="sk-hash", + team_id="team-1", + ) + + mock_client.post.assert_called_once() + call_kwargs = mock_client.post.call_args + assert call_kwargs[0][0] == "https://hooks.example.com/test" + payload = call_kwargs[1]["json"] + assert payload["tool_name"] == "delete_item" + assert payload["mcp_server_name"] == "test-server" + + @pytest.mark.asyncio + async def test_should_not_fire_when_pattern_does_not_match(self): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_mcpalertrule = MagicMock() + rule = _make_rule(tool_name_pattern="*delete*") + mock_prisma.db.litellm_mcpalertrule.find_many = AsyncMock( + return_value=[rule] + ) + + with patch("litellm.proxy.db.mcp_alert_rules.httpx.AsyncClient") as mock_httpx: + mock_client = AsyncMock() + mock_httpx.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_httpx.return_value.__aexit__ = AsyncMock(return_value=None) + + await check_and_fire_mcp_alerts( + prisma_client=mock_prisma, + mcp_server_name="test-server", + tool_name="get_page_content", + request_id="req-1", + user_id="user-1", + api_key_hash="sk-hash", + team_id="team-1", + ) + + mock_client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_should_filter_by_server_name(self): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_mcpalertrule = MagicMock() + rule = _make_rule( + tool_name_pattern="*delete*", mcp_server_name="specific-server" + ) + mock_prisma.db.litellm_mcpalertrule.find_many = AsyncMock( + return_value=[rule] + ) + + with patch("litellm.proxy.db.mcp_alert_rules.httpx.AsyncClient") as mock_httpx: + mock_client = AsyncMock() + mock_httpx.return_value.__aenter__ = AsyncMock(return_value=mock_client) + mock_httpx.return_value.__aexit__ = AsyncMock(return_value=None) + + await check_and_fire_mcp_alerts( + prisma_client=mock_prisma, + mcp_server_name="other-server", + tool_name="delete_item", + request_id="req-1", + user_id=None, + api_key_hash=None, + team_id=None, + ) + + mock_client.post.assert_not_called() + + @pytest.mark.asyncio + async def test_should_handle_db_failure_gracefully(self): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_mcpalertrule = MagicMock() + mock_prisma.db.litellm_mcpalertrule.find_many = AsyncMock( + side_effect=Exception("DB error") + ) + + # Should not raise + await check_and_fire_mcp_alerts( + prisma_client=mock_prisma, + mcp_server_name="test-server", + tool_name="delete_item", + request_id="req-1", + user_id=None, + api_key_hash=None, + team_id=None, + ) diff --git a/tests/test_litellm/proxy/db/test_spend_log_mcp_server_index.py b/tests/test_litellm/proxy/db/test_spend_log_mcp_server_index.py new file mode 100644 index 00000000000..49d8e6a9c48 --- /dev/null +++ b/tests/test_litellm/proxy/db/test_spend_log_mcp_server_index.py @@ -0,0 +1,195 @@ +""" +Tests for MCP server usage tracking (SpendLogMCPServerIndex). +""" + +import os +import sys +from datetime import datetime, timezone +from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy.db.spend_log_mcp_server_index import ( + _parse_mcp_info_from_payload, + process_spend_logs_mcp_server_usage, +) + + +class TestParseMCPInfoFromPayload: + """Test _parse_mcp_info_from_payload helper.""" + + def test_should_return_none_for_empty_payload(self): + result = _parse_mcp_info_from_payload({}) + assert result is None + + def test_should_return_none_for_no_mcp_metadata(self): + payload = { + "metadata": {"some_key": "some_value"} + } + result = _parse_mcp_info_from_payload(payload) + assert result is None + + def test_should_extract_mcp_server_name_from_metadata(self): + payload = { + "metadata": { + "mcp_tool_call_metadata": { + "name": "get_page_content", + "mcp_server_name": "deepwiki-mcp", + "namespaced_tool_name": "deepwiki-mcp/get_page_content", + } + } + } + result = _parse_mcp_info_from_payload(payload) + assert result is not None + assert result["mcp_server_name"] == "deepwiki-mcp" + assert result["tool_name"] == "get_page_content" + + def test_should_extract_server_name_from_namespaced_tool_name(self): + payload = { + "mcp_namespaced_tool_name": "github-mcp/create_issue", + "metadata": { + "mcp_tool_call_metadata": { + "name": "create_issue", + "namespaced_tool_name": "github-mcp/create_issue", + } + }, + } + result = _parse_mcp_info_from_payload(payload) + assert result is not None + assert result["mcp_server_name"] == "github-mcp" + assert result["tool_name"] == "create_issue" + + def test_should_handle_string_metadata(self): + import json + + payload = { + "metadata": json.dumps( + { + "mcp_tool_call_metadata": { + "name": "search", + "mcp_server_name": "search-server", + } + } + ) + } + result = _parse_mcp_info_from_payload(payload) + assert result is not None + assert result["mcp_server_name"] == "search-server" + + def test_should_return_none_for_invalid_json_metadata(self): + payload = {"metadata": "not valid json"} + result = _parse_mcp_info_from_payload(payload) + assert result is None + + def test_should_return_none_when_no_server_name_derivable(self): + payload = { + "metadata": { + "mcp_tool_call_metadata": { + "name": "some_tool", + } + } + } + result = _parse_mcp_info_from_payload(payload) + assert result is None + + +class TestProcessSpendLogsMCPServerUsage: + """Test process_spend_logs_mcp_server_usage.""" + + @pytest.mark.asyncio + async def test_should_skip_empty_logs(self): + mock_prisma = MagicMock() + await process_spend_logs_mcp_server_usage(mock_prisma, []) + + @pytest.mark.asyncio + async def test_should_insert_index_rows(self): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_spendlogmcpserverindex = MagicMock() + mock_prisma.db.litellm_spendlogmcpserverindex.create_many = AsyncMock( + return_value=None + ) + + logs = [ + { + "request_id": "req-123", + "startTime": datetime(2024, 1, 1, tzinfo=timezone.utc), + "metadata": { + "mcp_tool_call_metadata": { + "name": "get_page_content", + "mcp_server_name": "deepwiki-mcp", + }, + "user_api_key_hash": "sk-hash-123", + "user_api_key_user_id": "user-1", + "user_api_key_team_id": "team-1", + }, + "api_key": "sk-hash-123", + "user": "user-1", + "team_id": "team-1", + } + ] + + with patch( + "litellm.proxy.db.mcp_alert_rules.check_and_fire_mcp_alerts", + new_callable=AsyncMock, + ): + await process_spend_logs_mcp_server_usage(mock_prisma, logs) + + mock_prisma.db.litellm_spendlogmcpserverindex.create_many.assert_called_once() + call_args = ( + mock_prisma.db.litellm_spendlogmcpserverindex.create_many.call_args + ) + data = call_args.kwargs.get("data") or call_args[1].get("data") + assert len(data) == 1 + assert data[0]["request_id"] == "req-123" + assert data[0]["mcp_server_name"] == "deepwiki-mcp" + assert data[0]["tool_name"] == "get_page_content" + assert data[0]["api_key_hash"] == "sk-hash-123" + assert data[0]["user_id"] == "user-1" + assert data[0]["team_id"] == "team-1" + + @pytest.mark.asyncio + async def test_should_skip_non_mcp_logs(self): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_spendlogmcpserverindex = MagicMock() + mock_prisma.db.litellm_spendlogmcpserverindex.create_many = AsyncMock() + + logs = [ + { + "request_id": "req-456", + "startTime": datetime(2024, 1, 1, tzinfo=timezone.utc), + "metadata": {"some_key": "some_value"}, + } + ] + + await process_spend_logs_mcp_server_usage(mock_prisma, logs) + mock_prisma.db.litellm_spendlogmcpserverindex.create_many.assert_not_called() + + @pytest.mark.asyncio + async def test_should_handle_create_many_failure_gracefully(self): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_spendlogmcpserverindex = MagicMock() + mock_prisma.db.litellm_spendlogmcpserverindex.create_many = AsyncMock( + side_effect=Exception("DB error") + ) + + logs = [ + { + "request_id": "req-789", + "startTime": datetime(2024, 1, 1, tzinfo=timezone.utc), + "metadata": { + "mcp_tool_call_metadata": { + "name": "some_tool", + "mcp_server_name": "test-server", + } + }, + } + ] + + # Should not raise + await process_spend_logs_mcp_server_usage(mock_prisma, logs) diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_usage_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_usage_endpoints.py new file mode 100644 index 00000000000..cfd23300aa5 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_usage_endpoints.py @@ -0,0 +1,159 @@ +""" +Tests for MCP usage/operational visibility endpoints. +""" + +import os +import sys +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy.management_endpoints.mcp_usage_endpoints import ( + MCPUsageLogsResponse, + MCPUsageOverviewResponse, + MCPToolUsersResponse, + MCPAlertRulesListResponse, + _build_index_where, + _snippet, +) + +MOCK_ADMIN_USER = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + + +class TestBuildIndexWhere: + """Test _build_index_where helper.""" + + def test_should_return_empty_dict_for_no_filters(self): + result = _build_index_where(None, None, None, None) + assert result == {} + + def test_should_filter_by_server_name(self): + result = _build_index_where("test-server", None, None, None) + assert result["mcp_server_name"] == "test-server" + + def test_should_filter_by_tool_name(self): + result = _build_index_where(None, "delete_item", None, None) + assert result["tool_name"] == "delete_item" + + def test_should_filter_by_date_range(self): + result = _build_index_where(None, None, "2024-01-01", "2024-01-31") + assert "start_time" in result + assert "gte" in result["start_time"] + assert "lte" in result["start_time"] + + +class TestSnippet: + """Test _snippet helper.""" + + def test_should_return_none_for_none_input(self): + assert _snippet(None) is None + + def test_should_truncate_long_strings(self): + long_text = "x" * 300 + result = _snippet(long_text) + assert len(result) == 203 # 200 + "..." + assert result.endswith("...") + + def test_should_return_none_for_empty_dict_string(self): + assert _snippet("{}") is None + + def test_should_handle_list_of_messages(self): + msgs = [{"content": "hello"}, {"content": "world"}] + result = _snippet(msgs) + assert result == "hello world" + + +class TestMCPUsageLogs: + """Test mcp_usage_logs endpoint.""" + + @pytest.mark.asyncio + async def test_should_return_empty_when_no_prisma(self): + from litellm.proxy.management_endpoints.mcp_usage_endpoints import ( + mcp_usage_logs, + ) + + with patch( + "litellm.proxy.proxy_server.prisma_client", + None, + ): + result = await mcp_usage_logs( + mcp_server_name="test", + tool_name=None, + page=1, + page_size=50, + start_date=None, + end_date=None, + user_api_key_dict=MOCK_ADMIN_USER, + ) + assert result.total == 0 + assert result.logs == [] + + +class TestMCPUsageOverview: + """Test mcp_usage_overview endpoint.""" + + @pytest.mark.asyncio + async def test_should_return_empty_when_no_prisma(self): + from litellm.proxy.management_endpoints.mcp_usage_endpoints import ( + mcp_usage_overview, + ) + + with patch( + "litellm.proxy.proxy_server.prisma_client", + None, + ): + result = await mcp_usage_overview( + start_date=None, + end_date=None, + user_api_key_dict=MOCK_ADMIN_USER, + ) + assert result.total_requests == 0 + assert result.servers == [] + + +class TestMCPUsageTools: + """Test mcp_usage_tools endpoint.""" + + @pytest.mark.asyncio + async def test_should_return_empty_when_no_prisma(self): + from litellm.proxy.management_endpoints.mcp_usage_endpoints import ( + mcp_usage_tools, + ) + + with patch( + "litellm.proxy.proxy_server.prisma_client", + None, + ): + result = await mcp_usage_tools( + mcp_server_name="test", + start_date=None, + end_date=None, + user_api_key_dict=MOCK_ADMIN_USER, + ) + assert result.total == 0 + assert result.entries == [] + + +class TestMCPAlertRules: + """Test alert rule CRUD endpoints.""" + + @pytest.mark.asyncio + async def test_should_return_empty_rules_when_no_prisma(self): + from litellm.proxy.management_endpoints.mcp_usage_endpoints import ( + list_mcp_alert_rules, + ) + + with patch( + "litellm.proxy.proxy_server.prisma_client", + None, + ): + result = await list_mcp_alert_rules( + mcp_server_name=None, + user_api_key_dict=MOCK_ADMIN_USER, + ) + assert result.total == 0 + assert result.rules == [] diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index b927f312df8..418d6ecf1bf 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -14,6 +14,7 @@ import LoadingScreen from "@/components/common_components/LoadingScreen"; import { CostTrackingSettings } from "@/components/CostTrackingSettings"; import GeneralSettings from "@/components/general_settings"; import GuardrailsMonitorView from "@/components/GuardrailsMonitor/GuardrailsMonitorView"; +import MCPMonitorView from "@/components/MCPMonitor/MCPMonitorView"; import GuardrailsPanel from "@/components/guardrails"; import PoliciesPanel from "@/components/policies"; import { Team } from "@/components/key_team_helpers/key_list"; @@ -552,6 +553,8 @@ function CreateKeyPageContent() { ) : page == "guardrails-monitor" ? ( + ) : page == "mcp-monitor" ? ( + ) : page == "new_usage" ? ( getMCPAlertRules(accessToken!, mcpServerName), + enabled: !!accessToken, + }); + + const createMutation = useMutation({ + mutationFn: (newRule: any) => createMCPAlertRule(accessToken!, newRule), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["mcp-alert-rules"] }); + setCreateModalOpen(false); + setFormData({ + alert_name: "", + tool_name_pattern: "", + webhook_url: "", + description: "", + mcp_server_name: mcpServerName || "", + }); + message.success("Alert rule created"); + }, + onError: (err: any) => { + message.error(`Failed to create alert rule: ${err.message}`); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (ruleId: string) => deleteMCPAlertRule(accessToken!, ruleId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["mcp-alert-rules"] }); + message.success("Alert rule deleted"); + }, + onError: (err: any) => { + message.error(`Failed to delete: ${err.message}`); + }, + }); + + const rules = data?.rules ?? []; + + const handleCreate = () => { + if (!formData.alert_name || !formData.tool_name_pattern || !formData.webhook_url) { + message.warning("Please fill in all required fields"); + return; + } + createMutation.mutate({ + alert_name: formData.alert_name, + tool_name_pattern: formData.tool_name_pattern, + webhook_url: formData.webhook_url, + description: formData.description || undefined, + mcp_server_name: formData.mcp_server_name || undefined, + enabled: true, + }); + }; + + return ( +
+
+
+
+

+ + Alert Rules +

+

+ Get notified when specific MCP tools are invoked (e.g., delete operations) +

+
+ +
+
+ + {isLoading ? ( +
+ +
+ ) : rules.length === 0 ? ( +
+ No alert rules configured. Create one to get notified about specific tool operations. +
+ ) : ( +
+ {rules.map((rule: any) => ( +
+
+
+ + {rule.alert_name} + + + {rule.enabled ? "Active" : "Disabled"} + +
+
+ + Pattern:{" "} + + {rule.tool_name_pattern} + + + {rule.mcp_server_name && ( + Server: {rule.mcp_server_name} + )} + {rule.description && ( + {rule.description} + )} +
+
+
+ ))} +
+ )} + + setCreateModalOpen(false)} + onOk={handleCreate} + confirmLoading={createMutation.isPending} + okText="Create" + > +
+
+ + + setFormData({ ...formData, alert_name: e.target.value }) + } + /> +
+
+ + + setFormData({ ...formData, tool_name_pattern: e.target.value }) + } + /> +

+ Glob-style pattern. Use * as wildcard. Examples: *delete*, *remove*, drop_* +

+
+
+ + + setFormData({ ...formData, webhook_url: e.target.value }) + } + /> +
+
+ + + setFormData({ ...formData, mcp_server_name: e.target.value }) + } + /> +
+
+ + + setFormData({ ...formData, description: e.target.value }) + } + rows={2} + /> +
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/MCPMonitor/MCPLogViewer.tsx b/ui/litellm-dashboard/src/components/MCPMonitor/MCPLogViewer.tsx new file mode 100644 index 00000000000..50bb721cc1d --- /dev/null +++ b/ui/litellm-dashboard/src/components/MCPMonitor/MCPLogViewer.tsx @@ -0,0 +1,215 @@ +import { + ToolOutlined, + DownOutlined, + UserOutlined, + KeyOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import moment from "moment"; +import { Button, Spin } from "antd"; +import React, { useState } from "react"; +import { uiSpendLogsCall } from "@/components/networking"; +import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer"; +import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns"; + +interface MCPLogEntry { + id: string; + timestamp: string; + mcp_server_name: string; + tool_name?: string; + api_key_hash?: string; + api_key_alias?: string; + user_id?: string; + team_id?: string; + model?: string; + status?: string; + spend?: number; + input_snippet?: string; + output_snippet?: string; +} + +interface MCPLogViewerProps { + serverName?: string; + logs: MCPLogEntry[]; + logsLoading?: boolean; + totalLogs?: number; + accessToken?: string | null; + startDate?: string; + endDate?: string; +} + +export function MCPLogViewer({ + serverName, + logs = [], + logsLoading = false, + totalLogs, + accessToken = null, + startDate = "", + endDate = "", +}: MCPLogViewerProps) { + const [sampleSize, setSampleSize] = useState(10); + const [selectedRequestId, setSelectedRequestId] = useState( + null + ); + const [drawerOpen, setDrawerOpen] = useState(false); + + const displayLogs = logs.slice(0, sampleSize); + const total = totalLogs ?? logs.length; + const sampleSizes = [10, 50, 100]; + + const startTime = startDate + ? moment(startDate).utc().format("YYYY-MM-DD HH:mm:ss") + : moment().subtract(24, "hours").utc().format("YYYY-MM-DD HH:mm:ss"); + const endTime = endDate + ? moment(endDate).utc().endOf("day").format("YYYY-MM-DD HH:mm:ss") + : moment().utc().format("YYYY-MM-DD HH:mm:ss"); + + const { data: fullLogResponse } = useQuery({ + queryKey: ["spend-log-by-request", selectedRequestId, startTime, endTime], + queryFn: async () => { + if (!accessToken || !selectedRequestId) return null; + const res = await uiSpendLogsCall({ + accessToken, + start_date: startTime, + end_date: endTime, + page: 1, + page_size: 10, + params: { request_id: selectedRequestId }, + }); + return res as { data: ViewLogsLogEntry[]; total: number }; + }, + enabled: Boolean(accessToken && selectedRequestId && drawerOpen), + }); + + const selectedLog: ViewLogsLogEntry | null = + fullLogResponse?.data?.[0] ?? null; + + const handleLogClick = (log: MCPLogEntry) => { + setSelectedRequestId(log.id); + setDrawerOpen(true); + }; + + const handleCloseDrawer = () => { + setDrawerOpen(false); + setSelectedRequestId(null); + }; + + return ( +
+
+
+
+

+ {serverName ? `Logs — ${serverName}` : "MCP Request Logs"} +

+

+ {logsLoading + ? "Loading…" + : logs.length > 0 + ? `Showing ${displayLogs.length} of ${total} entries` + : "No logs for this period."} +

+
+ {logs.length > 0 && ( +
+ Sample: + {sampleSizes.map((size) => ( + + ))} +
+ )} +
+
+ + {logsLoading && ( +
+ +
+ )} + {!logsLoading && displayLogs.length === 0 && ( +
+ No logs to display. Adjust date range. +
+ )} + {!logsLoading && displayLogs.length > 0 && ( +
+ {displayLogs.map((log) => ( + + ))} +
+ )} + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/MCPMonitor/MCPMonitorView.tsx b/ui/litellm-dashboard/src/components/MCPMonitor/MCPMonitorView.tsx new file mode 100644 index 00000000000..d6f374c31e6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/MCPMonitor/MCPMonitorView.tsx @@ -0,0 +1,95 @@ +import type { DateRangePickerValue } from "@tremor/react"; +import { Tabs } from "antd"; +import React, { useCallback, useMemo, useState } from "react"; +import { formatDate } from "@/components/networking"; +import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; +import { MCPOverview } from "./MCPOverview"; +import { MCPServerDetail } from "./MCPServerDetail"; +import { MCPAlertRules } from "./MCPAlertRules"; + +type View = + | { type: "overview" } + | { type: "detail"; serverName: string }; + +interface MCPMonitorViewProps { + accessToken?: string | null; +} + +const defaultEnd = new Date(); +const defaultStart = new Date(); +defaultStart.setDate(defaultStart.getDate() - 7); + +export default function MCPMonitorView({ accessToken = null }: MCPMonitorViewProps) { + const [view, setView] = useState({ type: "overview" }); + const [activeTab, setActiveTab] = useState("servers"); + + const initialFrom = useMemo(() => new Date(defaultStart), []); + const initialTo = useMemo(() => new Date(defaultEnd), []); + + const [dateValue, setDateValue] = useState({ + from: initialFrom, + to: initialTo, + }); + + const startDate = dateValue.from ? formatDate(dateValue.from) : ""; + const endDate = dateValue.to ? formatDate(dateValue.to) : ""; + + const handleDateChange = useCallback((newValue: DateRangePickerValue) => { + setDateValue(newValue); + }, []); + + const handleSelectServer = (serverName: string) => { + setView({ type: "detail", serverName }); + }; + + const handleBack = () => { + setView({ type: "overview" }); + }; + + return ( +
+
+

MCP Server Monitor

+ +
+ + {view.type === "detail" ? ( + + ) : ( + <> + + {activeTab === "servers" && ( + + )} + {activeTab === "alerts" && ( + + )} + + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/MCPMonitor/MCPOverview.tsx b/ui/litellm-dashboard/src/components/MCPMonitor/MCPOverview.tsx new file mode 100644 index 00000000000..7adf696b171 --- /dev/null +++ b/ui/litellm-dashboard/src/components/MCPMonitor/MCPOverview.tsx @@ -0,0 +1,177 @@ +import { ToolOutlined, TeamOutlined, KeyOutlined, ApiOutlined } from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Spin } from "antd"; +import React from "react"; +import { getMCPUsageOverview } from "@/components/networking"; + +interface MCPOverviewProps { + accessToken?: string | null; + startDate: string; + endDate: string; + onSelectServer: (serverName: string) => void; +} + +export function MCPOverview({ + accessToken, + startDate, + endDate, + onSelectServer, +}: MCPOverviewProps) { + const { data, isLoading, error } = useQuery({ + queryKey: ["mcp-usage-overview", startDate, endDate], + queryFn: () => getMCPUsageOverview(accessToken!, startDate, endDate), + enabled: !!accessToken, + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ Failed to load MCP usage overview. +
+ ); + } + + const servers = data?.servers ?? []; + const totalRequests = data?.total_requests ?? 0; + + return ( +
+
+
+
+ + Total MCP Requests +
+
+ {totalRequests.toLocaleString()} +
+
+
+
+ + Active Servers +
+
+ {servers.length} +
+
+
+
+ + Unique Users +
+
+ {servers.reduce( + (sum: number, s: any) => sum + (s.unique_users || 0), + 0 + )} +
+
+
+ +
+
+

+ MCP Servers +

+

+ Click a server to view detailed logs and tool usage +

+
+ + {servers.length === 0 ? ( +
+ No MCP server activity found for this date range. +
+ ) : ( +
+ {servers.map((server: any) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/MCPMonitor/MCPServerDetail.tsx b/ui/litellm-dashboard/src/components/MCPMonitor/MCPServerDetail.tsx new file mode 100644 index 00000000000..9ce22e51832 --- /dev/null +++ b/ui/litellm-dashboard/src/components/MCPMonitor/MCPServerDetail.tsx @@ -0,0 +1,217 @@ +import { + ArrowLeftOutlined, + ToolOutlined, + TeamOutlined, + KeyOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Spin, Tabs } from "antd"; +import React, { useMemo, useState } from "react"; +import { + getMCPUsageLogs, + getMCPUsageTools, +} from "@/components/networking"; +import { MCPLogViewer } from "./MCPLogViewer"; +import { MCPToolUsersTable } from "./MCPToolUsersTable"; + +interface MCPServerDetailProps { + serverName: string; + onBack: () => void; + accessToken?: string | null; + startDate: string; + endDate: string; +} + +export function MCPServerDetail({ + serverName, + onBack, + accessToken = null, + startDate, + endDate, +}: MCPServerDetailProps) { + const [activeTab, setActiveTab] = useState("logs"); + const [logsPage, setLogsPage] = useState(1); + const logsPageSize = 50; + + const { data: logsData, isLoading: logsLoading } = useQuery({ + queryKey: [ + "mcp-usage-logs", + serverName, + logsPage, + logsPageSize, + startDate, + endDate, + ], + queryFn: () => + getMCPUsageLogs(accessToken!, { + mcpServerName: serverName, + page: logsPage, + pageSize: logsPageSize, + startDate, + endDate, + }), + enabled: !!accessToken && !!serverName, + }); + + const { data: toolsData, isLoading: toolsLoading } = useQuery({ + queryKey: ["mcp-usage-tools", serverName, startDate, endDate], + queryFn: () => + getMCPUsageTools(accessToken!, serverName, startDate, endDate), + enabled: !!accessToken && !!serverName, + }); + + const logs = useMemo(() => logsData?.logs ?? [], [logsData]); + const toolEntries = useMemo( + () => toolsData?.entries ?? [], + [toolsData] + ); + + const totalLogs = logsData?.total ?? 0; + const totalToolEntries = toolsData?.total ?? 0; + + const toolsSummary = useMemo(() => { + const toolMap = new Map(); + for (const entry of toolEntries) { + const current = toolMap.get(entry.tool_name) ?? 0; + toolMap.set(entry.tool_name, current + entry.call_count); + } + return Array.from(toolMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ name, count })); + }, [toolEntries]); + + const uniqueUsers = useMemo(() => { + const users = new Set(); + for (const entry of toolEntries) { + if (entry.user_id) users.add(entry.user_id); + } + return users.size; + }, [toolEntries]); + + const uniqueKeys = useMemo(() => { + const keys = new Set(); + for (const entry of toolEntries) { + if (entry.api_key_hash) keys.add(entry.api_key_hash); + } + return keys.size; + }, [toolEntries]); + + return ( +
+
+ + +
+
+
+ +

+ {serverName} +

+
+
+
+ +
+
+
Total Requests
+
+ {totalLogs.toLocaleString()} +
+
+
+
+ + Tools Used +
+
+ {toolsSummary.length} +
+
+
+
+ + Unique Users +
+
+ {uniqueUsers} +
+
+
+
+ + Unique Keys +
+
+ {uniqueKeys} +
+
+
+ + {toolsSummary.length > 0 && ( +
+ {toolsSummary.slice(0, 8).map((tool) => ( + + {tool.name} + + ({tool.count}) + + + ))} +
+ )} +
+ + + + {activeTab === "logs" && ( +
+ +
+ )} + + {activeTab === "users" && ( +
+ {toolsLoading ? ( +
+ +
+ ) : ( + + )} +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/MCPMonitor/MCPToolUsersTable.tsx b/ui/litellm-dashboard/src/components/MCPMonitor/MCPToolUsersTable.tsx new file mode 100644 index 00000000000..ae99e6d5fdd --- /dev/null +++ b/ui/litellm-dashboard/src/components/MCPMonitor/MCPToolUsersTable.tsx @@ -0,0 +1,107 @@ +import { UserOutlined, KeyOutlined, TeamOutlined } from "@ant-design/icons"; +import React from "react"; + +interface MCPToolUserEntry { + tool_name: string; + api_key_hash?: string | null; + api_key_alias?: string | null; + user_id?: string | null; + team_id?: string | null; + call_count: number; + last_called: string; +} + +interface MCPToolUsersTableProps { + entries: MCPToolUserEntry[]; + total: number; +} + +export function MCPToolUsersTable({ entries, total }: MCPToolUsersTableProps) { + if (entries.length === 0) { + return ( +
+ No tool usage data for this period. +
+ ); + } + + return ( +
+
+

+ Users & Keys per Tool +

+

+ Which users and API keys called which tools — {total} entries +

+
+
+ + + + + + + + + + + + + {entries.map((entry, idx) => ( + + + + + + + + + ))} + +
ToolUserAPI KeyTeamCallsLast Called
+ + {entry.tool_name} + + + {entry.user_id ? ( + + + {entry.user_id} + + ) : ( + — + )} + + {entry.api_key_alias || entry.api_key_hash ? ( + + + {entry.api_key_alias || + (entry.api_key_hash + ? `${entry.api_key_hash.slice(0, 12)}...` + : "")} + + ) : ( + — + )} + + {entry.team_id ? ( + + + {entry.team_id} + + ) : ( + — + )} + + {entry.call_count.toLocaleString()} + + {entry.last_called} +
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 12ac7e58e40..d19d022fba6 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -174,6 +174,13 @@ const menuGroups: MenuGroup[] = [ icon: , roles: [...all_admin_roles, ...internalUserRoles], }, + { + key: "mcp-monitor", + page: "mcp-monitor", + label: "MCP Monitor", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, ], }, { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 30c1d3c5b81..b67d9a8cc81 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5057,6 +5057,192 @@ export const getGuardrailsUsageLogs = async ( } }; +// ───────────────────────────────────────────────────────────────────────────── +// MCP Usage / Operational Visibility API Calls +// ───────────────────────────────────────────────────────────────────────────── + +export const getMCPUsageLogs = async ( + accessToken: string, + options: { + mcpServerName?: string; + toolName?: string; + page?: number; + pageSize?: number; + startDate?: string; + endDate?: string; + } +) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/usage/logs` : `/v1/mcp/usage/logs`; + const params = new URLSearchParams(); + if (options.mcpServerName) params.append("mcp_server_name", options.mcpServerName); + if (options.toolName) params.append("tool_name", options.toolName); + if (options.page != null) params.append("page", String(options.page)); + if (options.pageSize != null) params.append("page_size", String(options.pageSize)); + if (options.startDate) params.append("start_date", options.startDate); + if (options.endDate) params.append("end_date", options.endDate); + if (params.toString()) url += `?${params.toString()}`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(deriveErrorMessage(errorData)); + } + return response.json(); + } catch (error) { + console.error("Failed to get MCP usage logs:", error); + throw error; + } +}; + +export const getMCPUsageOverview = async ( + accessToken: string, + startDate?: string, + endDate?: string +) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/usage/overview` : `/v1/mcp/usage/overview`; + const params = new URLSearchParams(); + if (startDate) params.append("start_date", startDate); + if (endDate) params.append("end_date", endDate); + if (params.toString()) url += `?${params.toString()}`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(deriveErrorMessage(errorData)); + } + return response.json(); + } catch (error) { + console.error("Failed to get MCP usage overview:", error); + throw error; + } +}; + +export const getMCPUsageTools = async ( + accessToken: string, + mcpServerName: string, + startDate?: string, + endDate?: string +) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/usage/tools` : `/v1/mcp/usage/tools`; + const params = new URLSearchParams(); + params.append("mcp_server_name", mcpServerName); + if (startDate) params.append("start_date", startDate); + if (endDate) params.append("end_date", endDate); + if (params.toString()) url += `?${params.toString()}`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(deriveErrorMessage(errorData)); + } + return response.json(); + } catch (error) { + console.error("Failed to get MCP usage tools:", error); + throw error; + } +}; + +export const getMCPAlertRules = async ( + accessToken: string, + mcpServerName?: string +) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/alert-rules` : `/v1/mcp/alert-rules`; + const params = new URLSearchParams(); + if (mcpServerName) params.append("mcp_server_name", mcpServerName); + if (params.toString()) url += `?${params.toString()}`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(deriveErrorMessage(errorData)); + } + return response.json(); + } catch (error) { + console.error("Failed to get MCP alert rules:", error); + throw error; + } +}; + +export const createMCPAlertRule = async ( + accessToken: string, + data: { + mcp_server_name?: string; + tool_name_pattern: string; + webhook_url: string; + alert_name: string; + description?: string; + enabled?: boolean; + } +) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/alert-rules` : `/v1/mcp/alert-rules`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(deriveErrorMessage(errorData)); + } + return response.json(); + } catch (error) { + console.error("Failed to create MCP alert rule:", error); + throw error; + } +}; + +export const deleteMCPAlertRule = async ( + accessToken: string, + ruleId: string +) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/alert-rules/${ruleId}` : `/v1/mcp/alert-rules/${ruleId}`; + const response = await fetch(url, { + method: "DELETE", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(deriveErrorMessage(errorData)); + } + return response.json(); + } catch (error) { + console.error("Failed to delete MCP alert rule:", error); + throw error; + } +}; + // ───────────────────────────────────────────────────────────────────────────── // Policy CRUD API Calls // ───────────────────────────────────────────────────────────────────────────── diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index a910373d66d..76e56918da3 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -18,6 +18,7 @@ export const pageDescriptions: Record = { new_usage: "View usage analytics and metrics", logs: "Access request and response logs", "guardrails-monitor": "Monitor guardrail performance and view logs", + "mcp-monitor": "Monitor MCP server usage, view logs per server, and see which users/keys called which tools", users: "Manage internal user accounts and permissions", teams: "Create and manage teams for access control", organizations: "Manage organizations and their members",