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:
Cursor Agent 2026-03-06 01:54:12 +00:00
parent 92b3160206
commit 2dfa8c266e
19 changed files with 2654 additions and 0 deletions

View 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
)

View 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
)

View 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)

View file

@ -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)

View file

@ -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())

View file

@ -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,

View 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,
)

View 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)

View file

@ -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 == []

View file

@ -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[]) ?? []}

View 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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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 &amp; 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>
);
}

View file

@ -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],
},
],
},
{

View file

@ -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
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -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",