mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat: add MCP operational visibility - logs per server, user/key tracking, alerts
- Add LiteLLM_SpendLogMCPServerIndex pointer table for fast per-server log queries - Add LiteLLM_MCPAlertRule table for configurable alert rules on tool operations - Add spend log processing to populate MCP server index (mcp_server_name, tool_name, user, key) - Add backend endpoints: GET /v1/mcp/usage/overview, /usage/logs, /usage/tools - Add alert rule CRUD endpoints: GET/POST/PUT/DELETE /v1/mcp/alert-rules - Add webhook firing on tool call pattern matches (e.g. *delete* operations) - Add MCP Monitor UI page with Servers & Logs overview and Alert Rules tabs - Add sidebar navigation entry under Observability section - Add unit tests for index tracking, alert rules, and usage endpoints (28 tests) Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>
This commit is contained in:
parent
92b3160206
commit
2dfa8c266e
19 changed files with 2654 additions and 0 deletions
83
litellm/proxy/db/mcp_alert_rules.py
Normal file
83
litellm/proxy/db/mcp_alert_rules.py
Normal file
|
|
@ -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
|
||||
)
|
||||
140
litellm/proxy/db/spend_log_mcp_server_index.py
Normal file
140
litellm/proxy/db/spend_log_mcp_server_index.py
Normal file
|
|
@ -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
|
||||
)
|
||||
608
litellm/proxy/management_endpoints/mcp_usage_endpoints.py
Normal file
608
litellm/proxy/management_endpoints/mcp_usage_endpoints.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
158
tests/test_litellm/proxy/db/test_mcp_alert_rules.py
Normal file
158
tests/test_litellm/proxy/db/test_mcp_alert_rules.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
195
tests/test_litellm/proxy/db/test_spend_log_mcp_server_index.py
Normal file
195
tests/test_litellm/proxy/db/test_spend_log_mcp_server_index.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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 == []
|
||||
|
|
@ -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() {
|
|||
<ToolPoliciesView accessToken={accessToken} userRole={userRole} />
|
||||
) : page == "guardrails-monitor" ? (
|
||||
<GuardrailsMonitorView accessToken={accessToken} />
|
||||
) : page == "mcp-monitor" ? (
|
||||
<MCPMonitorView accessToken={accessToken} />
|
||||
) : page == "new_usage" ? (
|
||||
<NewUsagePage
|
||||
teams={(teams as Team[]) ?? []}
|
||||
|
|
|
|||
252
ui/litellm-dashboard/src/components/MCPMonitor/MCPAlertRules.tsx
Normal file
252
ui/litellm-dashboard/src/components/MCPMonitor/MCPAlertRules.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import {
|
||||
BellOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Input, Modal, Spin, Switch, message } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
getMCPAlertRules,
|
||||
createMCPAlertRule,
|
||||
deleteMCPAlertRule,
|
||||
} from "@/components/networking";
|
||||
|
||||
interface MCPAlertRulesProps {
|
||||
accessToken?: string | null;
|
||||
mcpServerName?: string;
|
||||
}
|
||||
|
||||
export function MCPAlertRules({
|
||||
accessToken = null,
|
||||
mcpServerName,
|
||||
}: MCPAlertRulesProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
alert_name: "",
|
||||
tool_name_pattern: "",
|
||||
webhook_url: "",
|
||||
description: "",
|
||||
mcp_server_name: mcpServerName || "",
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["mcp-alert-rules", mcpServerName],
|
||||
queryFn: () => 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 (
|
||||
<div className="bg-white border border-gray-200 rounded-lg">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 flex items-center gap-2">
|
||||
<BellOutlined className="text-amber-500" />
|
||||
Alert Rules
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Get notified when specific MCP tools are invoked (e.g., delete operations)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
New Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spin />
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-500">
|
||||
No alert rules configured. Create one to get notified about specific tool operations.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{rules.map((rule: any) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="px-4 py-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900 text-sm">
|
||||
{rule.alert_name}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 text-[10px] font-medium rounded ${
|
||||
rule.enabled
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-gray-100 text-gray-500 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{rule.enabled ? "Active" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>
|
||||
Pattern:{" "}
|
||||
<code className="bg-gray-100 px-1 rounded">
|
||||
{rule.tool_name_pattern}
|
||||
</code>
|
||||
</span>
|
||||
{rule.mcp_server_name && (
|
||||
<span>Server: {rule.mcp_server_name}</span>
|
||||
)}
|
||||
{rule.description && (
|
||||
<span className="text-gray-400">{rule.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: "Delete Alert Rule",
|
||||
content: `Are you sure you want to delete "${rule.alert_name}"?`,
|
||||
onOk: () => deleteMutation.mutate(rule.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Create MCP Alert Rule"
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onOk={handleCreate}
|
||||
confirmLoading={createMutation.isPending}
|
||||
okText="Create"
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Alert Name *
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g., Delete Operation Alert"
|
||||
value={formData.alert_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, alert_name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tool Name Pattern *
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g., *delete*, *remove*, drop_*"
|
||||
value={formData.tool_name_pattern}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, tool_name_pattern: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Glob-style pattern. Use * as wildcard. Examples: *delete*, *remove*, drop_*
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Webhook URL *
|
||||
</label>
|
||||
<Input
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
value={formData.webhook_url}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, webhook_url: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
MCP Server (optional)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Leave empty to match all servers"
|
||||
value={formData.mcp_server_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, mcp_server_name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description (optional)
|
||||
</label>
|
||||
<Input.TextArea
|
||||
placeholder="Describe what this alert monitors"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
ui/litellm-dashboard/src/components/MCPMonitor/MCPLogViewer.tsx
Normal file
215
ui/litellm-dashboard/src/components/MCPMonitor/MCPLogViewer.tsx
Normal file
|
|
@ -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<string | null>(
|
||||
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 (
|
||||
<div className="bg-white border border-gray-200 rounded-lg">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
{serverName ? `Logs — ${serverName}` : "MCP Request Logs"}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{logsLoading
|
||||
? "Loading…"
|
||||
: logs.length > 0
|
||||
? `Showing ${displayLogs.length} of ${total} entries`
|
||||
: "No logs for this period."}
|
||||
</p>
|
||||
</div>
|
||||
{logs.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500 mr-1">Sample:</span>
|
||||
{sampleSizes.map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
type={sampleSize === size ? "primary" : "default"}
|
||||
size="small"
|
||||
onClick={() => setSampleSize(size)}
|
||||
>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logsLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
{!logsLoading && displayLogs.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
No logs to display. Adjust date range.
|
||||
</div>
|
||||
)}
|
||||
{!logsLoading && displayLogs.length > 0 && (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{displayLogs.map((log) => (
|
||||
<button
|
||||
key={log.id}
|
||||
type="button"
|
||||
onClick={() => handleLogClick(log)}
|
||||
className="w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors flex items-start gap-3"
|
||||
>
|
||||
<ToolOutlined className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700 border border-blue-200">
|
||||
{log.tool_name || "unknown"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{log.timestamp}
|
||||
</span>
|
||||
{log.status && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
log.status === "success"
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{log.user_id && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-gray-500">
|
||||
<UserOutlined className="text-[10px]" />
|
||||
{log.user_id}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{(log.api_key_alias || log.api_key_hash) && (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-gray-500">
|
||||
<KeyOutlined className="text-[10px]" />
|
||||
{log.api_key_alias ||
|
||||
(log.api_key_hash
|
||||
? `${log.api_key_hash.slice(0, 8)}...`
|
||||
: "")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 truncate">
|
||||
{log.input_snippet ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<DownOutlined className="w-4 h-4 text-gray-400 flex-shrink-0 mt-1" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LogDetailsDrawer
|
||||
open={drawerOpen}
|
||||
onClose={handleCloseDrawer}
|
||||
logEntry={selectedLog}
|
||||
accessToken={accessToken}
|
||||
allLogs={selectedLog ? [selectedLog] : []}
|
||||
startTime={startTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<View>({ type: "overview" });
|
||||
const [activeTab, setActiveTab] = useState("servers");
|
||||
|
||||
const initialFrom = useMemo(() => new Date(defaultStart), []);
|
||||
const initialTo = useMemo(() => new Date(defaultEnd), []);
|
||||
|
||||
const [dateValue, setDateValue] = useState<DateRangePickerValue>({
|
||||
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 (
|
||||
<div className="p-6 w-full min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-semibold text-gray-900">MCP Server Monitor</h1>
|
||||
<AdvancedDatePicker
|
||||
value={dateValue}
|
||||
onValueChange={handleDateChange}
|
||||
label=""
|
||||
showTimeRange={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{view.type === "detail" ? (
|
||||
<MCPServerDetail
|
||||
serverName={view.serverName}
|
||||
onBack={handleBack}
|
||||
accessToken={accessToken}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: "servers", label: "Servers & Logs" },
|
||||
{ key: "alerts", label: "Alert Rules" },
|
||||
]}
|
||||
className="mb-4"
|
||||
/>
|
||||
{activeTab === "servers" && (
|
||||
<MCPOverview
|
||||
accessToken={accessToken}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onSelectServer={handleSelectServer}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "alerts" && (
|
||||
<MCPAlertRules accessToken={accessToken} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
ui/litellm-dashboard/src/components/MCPMonitor/MCPOverview.tsx
Normal file
177
ui/litellm-dashboard/src/components/MCPMonitor/MCPOverview.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12 text-red-600">
|
||||
Failed to load MCP usage overview.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const servers = data?.servers ?? [];
|
||||
const totalRequests = data?.total_requests ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||||
<ApiOutlined />
|
||||
<span>Total MCP Requests</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{totalRequests.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||||
<ToolOutlined />
|
||||
<span>Active Servers</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{servers.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-sm mb-1">
|
||||
<TeamOutlined />
|
||||
<span>Unique Users</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-gray-900">
|
||||
{servers.reduce(
|
||||
(sum: number, s: any) => sum + (s.unique_users || 0),
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
MCP Servers
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Click a server to view detailed logs and tool usage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
No MCP server activity found for this date range.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{servers.map((server: any) => (
|
||||
<button
|
||||
key={server.mcp_server_name}
|
||||
type="button"
|
||||
onClick={() => onSelectServer(server.mcp_server_name)}
|
||||
className="w-full text-left px-4 py-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<ToolOutlined className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{server.mcp_server_name}
|
||||
</div>
|
||||
{server.description && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{server.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="text-right">
|
||||
<div className="text-gray-900 font-medium">
|
||||
{server.total_requests.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">requests</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 text-gray-700">
|
||||
<ToolOutlined className="text-xs" />
|
||||
<span>{server.top_tools?.length ?? 0}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">tools</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 text-gray-700">
|
||||
<TeamOutlined className="text-xs" />
|
||||
<span>{server.unique_users}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">users</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-1 text-gray-700">
|
||||
<KeyOutlined className="text-xs" />
|
||||
<span>{server.unique_keys}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">keys</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{server.top_tools && server.top_tools.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{server.top_tools.slice(0, 5).map((tool: any) => (
|
||||
<span
|
||||
key={tool.name}
|
||||
className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-gray-100 text-gray-700"
|
||||
>
|
||||
{tool.name}
|
||||
<span className="ml-1 text-gray-400">
|
||||
({tool.count})
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{server.top_tools.length > 5 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
+{server.top_tools.length - 5} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, number>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
for (const entry of toolEntries) {
|
||||
if (entry.api_key_hash) keys.add(entry.api_key_hash);
|
||||
}
|
||||
return keys.size;
|
||||
}, [toolEntries]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
className="pl-0 mb-4"
|
||||
>
|
||||
Back to Overview
|
||||
</Button>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<ToolOutlined className="text-xl text-blue-500" />
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
{serverName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mt-4">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">Total Requests</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{totalLogs.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
<ToolOutlined className="mr-1" />
|
||||
Tools Used
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{toolsSummary.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
<TeamOutlined className="mr-1" />
|
||||
Unique Users
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{uniqueUsers}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
<KeyOutlined className="mr-1" />
|
||||
Unique Keys
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{uniqueKeys}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolsSummary.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{toolsSummary.slice(0, 8).map((tool) => (
|
||||
<span
|
||||
key={tool.name}
|
||||
className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700 border border-blue-200"
|
||||
>
|
||||
{tool.name}
|
||||
<span className="ml-1 text-blue-400">
|
||||
({tool.count})
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: "logs", label: "Request Logs" },
|
||||
{
|
||||
key: "users",
|
||||
label: "Users & Keys",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeTab === "logs" && (
|
||||
<div className="mt-4">
|
||||
<MCPLogViewer
|
||||
serverName={serverName}
|
||||
logs={logs}
|
||||
logsLoading={logsLoading}
|
||||
totalLogs={totalLogs}
|
||||
accessToken={accessToken}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "users" && (
|
||||
<div className="mt-4">
|
||||
{toolsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<MCPToolUsersTable
|
||||
entries={toolEntries}
|
||||
total={totalToolEntries}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="bg-white border border-gray-200 rounded-lg py-12 text-center text-sm text-gray-500">
|
||||
No tool usage data for this period.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Users & Keys per Tool
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Which users and API keys called which tools — {total} entries
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-gray-50 text-xs text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Tool</th>
|
||||
<th className="px-4 py-2 font-medium">User</th>
|
||||
<th className="px-4 py-2 font-medium">API Key</th>
|
||||
<th className="px-4 py-2 font-medium">Team</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Calls</th>
|
||||
<th className="px-4 py-2 font-medium">Last Called</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{entries.map((entry, idx) => (
|
||||
<tr
|
||||
key={`${entry.tool_name}-${entry.api_key_hash}-${entry.user_id}-${idx}`}
|
||||
className="hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700 border border-blue-200">
|
||||
{entry.tool_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-700">
|
||||
{entry.user_id ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<UserOutlined className="text-[10px] text-gray-400" />
|
||||
{entry.user_id}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-700">
|
||||
{entry.api_key_alias || entry.api_key_hash ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<KeyOutlined className="text-[10px] text-gray-400" />
|
||||
{entry.api_key_alias ||
|
||||
(entry.api_key_hash
|
||||
? `${entry.api_key_hash.slice(0, 12)}...`
|
||||
: "")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-700">
|
||||
{entry.team_id ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<TeamOutlined className="text-[10px] text-gray-400" />
|
||||
{entry.team_id}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-medium text-gray-900">
|
||||
{entry.call_count.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-gray-500">
|
||||
{entry.last_called}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -174,6 +174,13 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <SafetyOutlined />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{
|
||||
key: "mcp-monitor",
|
||||
page: "mcp-monitor",
|
||||
label: "MCP Monitor",
|
||||
icon: <ToolOutlined />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const pageDescriptions: Record<string, string> = {
|
|||
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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue