diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index b48db72a536..f18ad9df776 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -1362,3 +1362,257 @@ if MCP_AVAILABLE: "servers": servers, "categories": categories, } + + @router.get( + "/server/{server_id}/logs", + description="Returns invocation logs for a specific MCP server", + dependencies=[Depends(user_api_key_auth)], + ) + async def get_mcp_server_logs( + server_id: str, + start_date: Optional[str] = Query( + None, description="Start date in YYYY-MM-DD HH:MM:SS format" + ), + end_date: Optional[str] = Query( + None, description="End date in YYYY-MM-DD HH:MM:SS format" + ), + page: int = Query(default=1, ge=1, description="Page number"), + page_size: int = Query( + default=25, ge=1, le=100, description="Items per page" + ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """ + Returns paginated invocation logs for a specific MCP server. + + Queries spend logs where mcp_namespaced_tool_name starts with the + server's prefix (server_name or alias). + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, detail="Database not connected" + ) + + mcp_server = global_mcp_server_manager.get_mcp_server_by_id( + server_id + ) + if mcp_server is None: + raise HTTPException( + status_code=404, + detail=f"MCP server {server_id} not found", + ) + + server_prefix = get_server_prefix(mcp_server) + + now = datetime.utcnow() + if start_date is None: + start_dt = now - timedelta(days=7) + else: + try: + start_dt = datetime.strptime( + start_date.strip(), "%Y-%m-%d %H:%M:%S" + ) + except ValueError: + start_dt = datetime.strptime( + start_date.strip(), "%Y-%m-%d" + ) + + if end_date is None: + end_dt = now + else: + try: + end_dt = datetime.strptime( + end_date.strip(), "%Y-%m-%d %H:%M:%S" + ) + except ValueError: + end_dt = datetime.strptime( + end_date.strip(), "%Y-%m-%d" + ) + + skip = (page - 1) * page_size + like_pattern = f"{server_prefix}/%" + + total = await prisma_client.db.query_raw( + """ + SELECT COUNT(*)::int AS cnt + FROM "LiteLLM_SpendLogs" + WHERE mcp_namespaced_tool_name LIKE $1 + AND "startTime" >= $2::timestamptz + AND "startTime" <= $3::timestamptz + """, + like_pattern, + start_dt, + end_dt, + ) + total_count = total[0]["cnt"] if total else 0 + + rows = await prisma_client.db.query_raw( + """ + SELECT + request_id, call_type, api_key, spend, total_tokens, + prompt_tokens, completion_tokens, "startTime", "endTime", + model, model_id, "user", metadata, team_id, + end_user, status, mcp_namespaced_tool_name, + COALESCE(request_duration_ms, + (EXTRACT(EPOCH FROM ("endTime" - "startTime")) * 1000)::INTEGER + ) AS request_duration_ms + FROM "LiteLLM_SpendLogs" + WHERE mcp_namespaced_tool_name LIKE $1 + AND "startTime" >= $2::timestamptz + AND "startTime" <= $3::timestamptz + ORDER BY "startTime" DESC + LIMIT $4 OFFSET $5 + """, + like_pattern, + start_dt, + end_dt, + page_size, + skip, + ) + + total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0 + + return { + "data": rows, + "total": total_count, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "server_id": server_id, + "server_name": mcp_server.server_name, + } + + @router.get( + "/server/{server_id}/diagnose", + description="Run connection diagnostics for an MCP server", + dependencies=[Depends(user_api_key_auth)], + ) + async def diagnose_mcp_server( + server_id: str, + request: Request, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """ + Run connection diagnostics for an MCP server and return structured results. + + Returns auth resolution, connectivity status, and actionable suggestions. + """ + from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug + + mcp_server = global_mcp_server_manager.get_mcp_server_by_id( + server_id + ) + if mcp_server is None: + raise HTTPException( + status_code=404, + detail=f"MCP server {server_id} not found", + ) + + checks: List[Dict[str, Any]] = [] + + # Check 1: Server configuration + config_ok = bool(mcp_server.url or mcp_server.command) + checks.append({ + "name": "configuration", + "status": "pass" if config_ok else "fail", + "message": "Server URL or command is configured" + if config_ok + else "No URL or command configured", + }) + + # Check 2: Auth configuration + auth_type = mcp_server.auth_type or "none" + auth_resolution = MCPDebug.resolve_auth_resolution( + server=mcp_server, + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=None, + ) + checks.append({ + "name": "auth_type", + "status": "pass", + "message": f"Auth type: {auth_type}, resolution: {auth_resolution}", + "details": { + "auth_type": auth_type, + "auth_resolution": auth_resolution, + "has_credentials": bool(mcp_server.authentication_token), + "has_client_credentials": mcp_server.has_client_credentials, + }, + }) + + # Check 3: Connectivity (health check) + health_status = "unknown" + health_error = None + try: + tool_result = await global_mcp_server_manager._get_tools_from_server( + mcp_server + ) + if tool_result is not None: + health_status = "healthy" + tool_count = len(tool_result) + else: + health_status = "unhealthy" + health_error = "No tools returned" + tool_count = 0 + except Exception as e: + health_status = "unhealthy" + health_error = str(e) + tool_count = 0 + + checks.append({ + "name": "connectivity", + "status": "pass" if health_status == "healthy" else "fail", + "message": f"Server is {health_status}" + + (f": {health_error}" if health_error else f" ({tool_count} tools available)"), + "details": { + "health_status": health_status, + "tool_count": tool_count, + "error": health_error, + }, + }) + + # Check 4: Network accessibility + checks.append({ + "name": "network", + "status": "pass", + "message": "Available on public internet" + if mcp_server.available_on_public_internet + else "Restricted to internal network", + "details": { + "available_on_public_internet": mcp_server.available_on_public_internet, + }, + }) + + # Build suggestions + suggestions: List[str] = [] + if health_status == "unhealthy": + if auth_resolution == "no-auth" and auth_type != "none": + suggestions.append( + "Auth type is set to '{}' but no credentials are configured. " + "Add credentials in the server settings.".format(auth_type) + ) + if health_error and "timeout" in health_error.lower(): + suggestions.append( + "Connection timed out. Check that the MCP server URL is reachable " + "from the LiteLLM proxy network." + ) + if health_error and ("401" in health_error or "unauthorized" in health_error.lower()): + suggestions.append( + "Authentication failed. Verify credentials and auth type configuration." + ) + if not suggestions: + suggestions.append( + "Check the MCP server logs and verify the URL is correct." + ) + + all_passing = all(c["status"] == "pass" for c in checks) + + return { + "server_id": server_id, + "server_name": mcp_server.server_name, + "overall_status": "healthy" if all_passing else "unhealthy", + "checks": checks, + "suggestions": suggestions, + } diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 5b58fbe70a0..d936b946df5 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1694,6 +1694,14 @@ async def ui_view_spend_logs( # noqa: PLR0915 default="desc", description="Sort order: asc or desc", ), + call_type: Optional[str] = fastapi.Query( + default=None, + description="Filter logs by call type (e.g., 'call_mcp_tool', 'list_mcp_tools', 'completion')", + ), + mcp_tool_name: Optional[str] = fastapi.Query( + default=None, + description="Filter logs by MCP namespaced tool name (e.g., 'github-mcp/list_repos'). Supports partial match.", + ), ): """ View spend logs with pagination support. @@ -1823,6 +1831,14 @@ async def ui_view_spend_logs( # noqa: PLR0915 if end_user is not None: where_conditions["end_user"] = end_user + if call_type is not None: + where_conditions["call_type"] = call_type + + if mcp_tool_name is not None: + where_conditions["mcp_namespaced_tool_name"] = { + "contains": mcp_tool_name + } + if min_spend is not None or max_spend is not None: where_conditions["spend"] = {} if min_spend is not None: @@ -1913,6 +1929,18 @@ async def ui_view_spend_logs( # noqa: PLR0915 sql_params.append(max_spend) p += 1 + # Call type filter + if call_type is not None: + sql_conditions.append(f"call_type = ${p}") + sql_params.append(call_type) + p += 1 + + # MCP tool name filter (partial match) + if mcp_tool_name is not None: + sql_conditions.append(f"mcp_namespaced_tool_name LIKE ${p}") + sql_params.append(f"%{mcp_tool_name}%") + p += 1 + # Metadata JSON filters (PostgreSQL JSONB operators) if key_alias is not None: sql_conditions.append(f"metadata->>'user_api_key_alias' LIKE ${p}") diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index e81c6264f7b..f45bed9d915 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -1512,3 +1512,193 @@ class TestManagementPayloadValidation: assert len(result) == 1 assert result[0]["server_id"] == "server-1" assert result[0]["status"] == "healthy" + + +class TestMCPServerLogs: + """Tests for the GET /v1/mcp/server/{server_id}/logs endpoint.""" + + @pytest.mark.asyncio + async def test_get_mcp_server_logs_server_not_found(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + get_mcp_server_logs, + ) + + mock_user_auth = generate_mock_user_api_key_auth() + mock_manager = MagicMock() + mock_manager.get_mcp_server_by_id.return_value = None + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), patch( + "litellm.proxy.proxy_server.prisma_client", + MagicMock(), + ): + with pytest.raises(HTTPException) as exc_info: + await get_mcp_server_logs( + server_id="nonexistent", + start_date=None, + end_date=None, + page=1, + page_size=25, + user_api_key_dict=mock_user_auth, + ) + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_get_mcp_server_logs_returns_paginated_results(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + get_mcp_server_logs, + ) + + mock_server = generate_mock_mcp_server_config_record( + server_id="test-server-1", name="github-mcp" + ) + mock_user_auth = generate_mock_user_api_key_auth() + mock_manager = MagicMock() + mock_manager.get_mcp_server_by_id.return_value = mock_server + + mock_prisma = MagicMock() + mock_prisma.db.query_raw = AsyncMock( + side_effect=[ + [{"cnt": 2}], + [ + { + "request_id": "req-1", + "call_type": "call_mcp_tool", + "mcp_namespaced_tool_name": "github-mcp/list_repos", + "status": "success", + "spend": 0.001, + }, + { + "request_id": "req-2", + "call_type": "call_mcp_tool", + "mcp_namespaced_tool_name": "github-mcp/get_file", + "status": "success", + "spend": 0.002, + }, + ], + ] + ) + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), patch( + "litellm.proxy.proxy_server.prisma_client", + mock_prisma, + ): + result = await get_mcp_server_logs( + server_id="test-server-1", + start_date="2025-01-01 00:00:00", + end_date="2025-12-31 23:59:59", + page=1, + page_size=25, + user_api_key_dict=mock_user_auth, + ) + + assert result["total"] == 2 + assert result["page"] == 1 + assert result["server_id"] == "test-server-1" + assert len(result["data"]) == 2 + assert result["data"][0]["request_id"] == "req-1" + + +class TestMCPServerDiagnose: + """Tests for the GET /v1/mcp/server/{server_id}/diagnose endpoint.""" + + @pytest.mark.asyncio + async def test_diagnose_server_not_found(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + diagnose_mcp_server, + ) + + mock_user_auth = generate_mock_user_api_key_auth() + mock_manager = MagicMock() + mock_manager.get_mcp_server_by_id.return_value = None + + mock_request = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ): + with pytest.raises(HTTPException) as exc_info: + await diagnose_mcp_server( + server_id="nonexistent", + request=mock_request, + user_api_key_dict=mock_user_auth, + ) + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_diagnose_healthy_server(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + diagnose_mcp_server, + ) + + mock_server = generate_mock_mcp_server_config_record( + server_id="test-srv", name="test-mcp" + ) + mock_user_auth = generate_mock_user_api_key_auth() + mock_manager = MagicMock() + mock_manager.get_mcp_server_by_id.return_value = mock_server + + mock_tools = [MagicMock(), MagicMock()] + mock_manager._get_tools_from_server = AsyncMock( + return_value=mock_tools + ) + + mock_request = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ): + result = await diagnose_mcp_server( + server_id="test-srv", + request=mock_request, + user_api_key_dict=mock_user_auth, + ) + + assert result["overall_status"] == "healthy" + assert result["server_id"] == "test-srv" + assert len(result["checks"]) == 4 + assert all(c["status"] == "pass" for c in result["checks"]) + assert len(result["suggestions"]) == 0 + + @pytest.mark.asyncio + async def test_diagnose_unhealthy_server(self): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + diagnose_mcp_server, + ) + + mock_server = generate_mock_mcp_server_config_record( + server_id="bad-srv", name="bad-mcp" + ) + mock_user_auth = generate_mock_user_api_key_auth() + mock_manager = MagicMock() + mock_manager.get_mcp_server_by_id.return_value = mock_server + + mock_manager._get_tools_from_server = AsyncMock( + side_effect=Exception("Connection timed out") + ) + + mock_request = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ): + result = await diagnose_mcp_server( + server_id="bad-srv", + request=mock_request, + user_api_key_dict=mock_user_auth, + ) + + assert result["overall_status"] == "unhealthy" + connectivity_check = next( + c for c in result["checks"] if c["name"] == "connectivity" + ) + assert connectivity_check["status"] == "fail" + assert len(result["suggestions"]) > 0 diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_diagnostics.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_diagnostics.test.tsx new file mode 100644 index 00000000000..acb6ce8e372 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_diagnostics.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; +import MCPServerDiagnostics from "./mcp_server_diagnostics"; + +vi.mock("../networking", () => ({ + fetchMCPServerDiagnostics: vi.fn(), +})); + +import { fetchMCPServerDiagnostics } from "../networking"; + +describe("MCPServerDiagnostics", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the component title", async () => { + (fetchMCPServerDiagnostics as any).mockResolvedValue({ + server_id: "test-server", + server_name: "test-mcp", + overall_status: "healthy", + checks: [], + suggestions: [], + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(screen.getByText("Connection Diagnostics")).toBeInTheDocument(); + }); + }); + + it("should show healthy status when all checks pass", async () => { + (fetchMCPServerDiagnostics as any).mockResolvedValue({ + server_id: "test-server", + server_name: "test-mcp", + overall_status: "healthy", + checks: [ + { name: "configuration", status: "pass", message: "OK" }, + { name: "connectivity", status: "pass", message: "OK" }, + ], + suggestions: [], + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(screen.getByText("All Checks Passing")).toBeInTheDocument(); + }); + }); + + it("should show unhealthy status and suggestions when checks fail", async () => { + (fetchMCPServerDiagnostics as any).mockResolvedValue({ + server_id: "bad-server", + server_name: "bad-mcp", + overall_status: "unhealthy", + checks: [ + { name: "configuration", status: "pass", message: "OK" }, + { + name: "connectivity", + status: "fail", + message: "Connection timed out", + }, + ], + suggestions: [ + "Check that the MCP server URL is reachable from the LiteLLM proxy network.", + ], + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(screen.getByText("Issues Detected")).toBeInTheDocument(); + expect(screen.getByText("Suggestions")).toBeInTheDocument(); + expect( + screen.getByText( + /Check that the MCP server URL is reachable/ + ) + ).toBeInTheDocument(); + }); + }); + + it("should show error alert when diagnostics fail", async () => { + (fetchMCPServerDiagnostics as any).mockRejectedValue( + new Error("Network error") + ); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(screen.getByText("Diagnostics Failed")).toBeInTheDocument(); + }); + }); + + it("should have a re-run button", async () => { + (fetchMCPServerDiagnostics as any).mockResolvedValue({ + server_id: "test-server", + server_name: "test-mcp", + overall_status: "healthy", + checks: [], + suggestions: [], + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(screen.getByText("Re-run")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_diagnostics.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_diagnostics.tsx new file mode 100644 index 00000000000..a1c09c5bf41 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_diagnostics.tsx @@ -0,0 +1,173 @@ +import React, { useState, useEffect } from "react"; +import { Card, Title, Text } from "@tremor/react"; +import { Alert, Button, Spin, Tag, List } from "antd"; +import { + CheckCircleOutlined, + CloseCircleOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import { fetchMCPServerDiagnostics } from "../networking"; + +interface MCPServerDiagnosticsProps { + serverId: string; + accessToken: string | null; +} + +interface DiagnosticCheck { + name: string; + status: "pass" | "fail"; + message: string; + details?: Record; +} + +interface DiagnosticsResult { + server_id: string; + server_name: string; + overall_status: "healthy" | "unhealthy"; + checks: DiagnosticCheck[]; + suggestions: string[]; +} + +const CHECK_LABELS: Record = { + configuration: "Configuration", + auth_type: "Authentication", + connectivity: "Connectivity", + network: "Network Access", +}; + +const MCPServerDiagnostics: React.FC = ({ + serverId, + accessToken, +}) => { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const runDiagnostics = async () => { + if (!accessToken) return; + setLoading(true); + setError(null); + try { + const data = await fetchMCPServerDiagnostics(accessToken, serverId); + setResult(data); + } catch (err: any) { + setError(err.message || "Failed to run diagnostics"); + setResult(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + runDiagnostics(); + }, [serverId, accessToken]); + + return ( + + + Connection Diagnostics + } + onClick={runDiagnostics} + loading={loading} + > + Re-run + + + + {loading && !result && ( + + + + )} + + {error && ( + + )} + + {result && ( + <> + + + {result.overall_status === "healthy" + ? "All Checks Passing" + : "Issues Detected"} + + + + ( + + + ) : ( + + ) + } + title={ + + {CHECK_LABELS[check.name] || check.name} + + } + description={ + + {check.message} + {check.details && ( + + {Object.entries(check.details).map( + ([key, value]) => + value != null && ( + + {key}: {String(value)} + + ) + )} + + )} + + } + /> + + )} + /> + + {result.suggestions.length > 0 && ( + + {result.suggestions.map((s, i) => ( + + {s} + + ))} + + } + showIcon + className="mt-4" + /> + )} + > + )} + + ); +}; + +export default MCPServerDiagnostics; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_logs.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_logs.test.tsx new file mode 100644 index 00000000000..9470ab36ab1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_logs.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import MCPServerLogs from "./mcp_server_logs"; + +vi.mock("../networking", () => ({ + fetchMCPServerLogs: vi.fn(), +})); + +import { fetchMCPServerLogs } from "../networking"; + +describe("MCPServerLogs", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the component title", async () => { + (fetchMCPServerLogs as any).mockResolvedValue({ + data: [], + total: 0, + page: 1, + page_size: 25, + total_pages: 0, + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(screen.getByText("Invocation Logs")).toBeInTheDocument(); + }); + }); + + it("should show empty state when no logs", async () => { + (fetchMCPServerLogs as any).mockResolvedValue({ + data: [], + total: 0, + page: 1, + page_size: 25, + total_pages: 0, + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect( + screen.getByText( + /No MCP invocation logs found/ + ) + ).toBeInTheDocument(); + }); + }); + + it("should display log entries when data is returned", async () => { + (fetchMCPServerLogs as any).mockResolvedValue({ + data: [ + { + request_id: "req-1", + call_type: "call_mcp_tool", + mcp_namespaced_tool_name: "github-mcp/list_repos", + status: "success", + spend: 0.001, + total_tokens: null, + request_duration_ms: 250, + startTime: "2025-06-01T10:00:00Z", + endTime: "2025-06-01T10:00:01Z", + api_key: "sk-test123", + team_id: null, + end_user: null, + metadata: {}, + }, + ], + total: 1, + page: 1, + page_size: 25, + total_pages: 1, + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(screen.getByText("list_repos")).toBeInTheDocument(); + expect(screen.getByText("Tool Call")).toBeInTheDocument(); + expect(screen.getByText("250ms")).toBeInTheDocument(); + }); + }); + + it("should call fetchMCPServerLogs with correct params", async () => { + (fetchMCPServerLogs as any).mockResolvedValue({ + data: [], + total: 0, + page: 1, + page_size: 25, + total_pages: 0, + }); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => { + expect(fetchMCPServerLogs).toHaveBeenCalledWith( + "my-token", + "my-server-id", + expect.any(String), + expect.any(String), + 1, + 25, + ); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_logs.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_logs.tsx new file mode 100644 index 00000000000..3197f4313fa --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_logs.tsx @@ -0,0 +1,226 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Card, Title, Text } from "@tremor/react"; +import { Table, Tag, Tooltip, Pagination, Empty, Spin, Select } from "antd"; +import moment from "moment"; +import { fetchMCPServerLogs } from "../networking"; + +interface MCPServerLogsProps { + serverId: string; + accessToken: string | null; +} + +interface MCPLogEntry { + request_id: string; + call_type: string; + mcp_namespaced_tool_name: string | null; + status: string | null; + spend: number; + total_tokens: number | null; + request_duration_ms: number | null; + startTime: string; + endTime: string | null; + api_key: string | null; + team_id: string | null; + end_user: string | null; + metadata: any; +} + +const MCPServerLogs: React.FC = ({ + serverId, + accessToken, +}) => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(25); + const [timeRange, setTimeRange] = useState("7d"); + + const { startDate, endDate } = useMemo(() => { + const end = moment().utc().format("YYYY-MM-DD HH:mm:ss"); + let start: string; + switch (timeRange) { + case "1h": + start = moment().utc().subtract(1, "hours").format("YYYY-MM-DD HH:mm:ss"); + break; + case "24h": + start = moment().utc().subtract(24, "hours").format("YYYY-MM-DD HH:mm:ss"); + break; + case "7d": + start = moment().utc().subtract(7, "days").format("YYYY-MM-DD HH:mm:ss"); + break; + case "30d": + start = moment().utc().subtract(30, "days").format("YYYY-MM-DD HH:mm:ss"); + break; + default: + start = moment().utc().subtract(7, "days").format("YYYY-MM-DD HH:mm:ss"); + } + return { startDate: start, endDate: end }; + }, [timeRange]); + + useEffect(() => { + const fetchLogs = async () => { + if (!accessToken) return; + setLoading(true); + try { + const result = await fetchMCPServerLogs( + accessToken, + serverId, + startDate, + endDate, + page, + pageSize, + ); + setLogs(result.data || []); + setTotal(result.total || 0); + } catch (err) { + console.error("Failed to fetch MCP server logs:", err); + setLogs([]); + setTotal(0); + } finally { + setLoading(false); + } + }; + fetchLogs(); + }, [accessToken, serverId, startDate, endDate, page, pageSize]); + + const extractToolName = (namespacedName: string | null) => { + if (!namespacedName) return "—"; + const parts = namespacedName.split("/"); + return parts.length > 1 ? parts.slice(1).join("/") : namespacedName; + }; + + const columns = [ + { + title: "Time", + dataIndex: "startTime", + key: "startTime", + width: 180, + render: (val: string) => ( + + {moment(val).format("MMM DD HH:mm:ss")} + + ), + }, + { + title: "Tool", + dataIndex: "mcp_namespaced_tool_name", + key: "tool", + render: (val: string | null) => ( + {extractToolName(val)} + ), + }, + { + title: "Type", + dataIndex: "call_type", + key: "call_type", + width: 130, + render: (val: string) => ( + + {val === "call_mcp_tool" ? "Tool Call" : val === "list_mcp_tools" ? "List Tools" : val} + + ), + }, + { + title: "Status", + dataIndex: "status", + key: "status", + width: 90, + render: (val: string | null) => { + const isSuccess = !val || val === "success"; + return ( + + {isSuccess ? "OK" : val} + + ); + }, + }, + { + title: "Latency", + dataIndex: "request_duration_ms", + key: "latency", + width: 100, + render: (val: number | null) => { + if (val == null) return "—"; + if (val < 1000) return `${val}ms`; + return `${(val / 1000).toFixed(1)}s`; + }, + }, + { + title: "Cost", + dataIndex: "spend", + key: "spend", + width: 80, + render: (val: number) => (val > 0 ? `$${val.toFixed(4)}` : "—"), + }, + { + title: "Caller", + key: "caller", + width: 150, + render: (_: any, record: MCPLogEntry) => { + const alias = record.metadata?.user_api_key_alias; + return ( + + {alias || record.api_key?.slice(0, 12) || "—"} + + ); + }, + }, + ]; + + return ( + + + Invocation Logs + + + + {loading ? ( + + + + ) : logs.length === 0 ? ( + No MCP invocation logs found for this server in the selected time range. + } + /> + ) : ( + <> + + {total > pageSize && ( + + setPage(p)} + showSizeChanger={false} + showTotal={(total) => `${total} logs`} + /> + + )} + > + )} + + ); +}; + +export default MCPServerLogs; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx index 4f8ba4e1307..8abef4b352c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx @@ -7,6 +7,8 @@ import { MCPServer, handleTransport, handleAuth } from "./types"; import { MCPToolsViewer } from "."; import MCPServerEdit from "./mcp_server_edit"; import MCPServerCostDisplay from "./mcp_server_cost_display"; +import MCPServerLogs from "./mcp_server_logs"; +import MCPServerDiagnostics from "./mcp_server_diagnostics"; import { getMaskedAndFullUrl } from "./utils"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { CheckIcon, CopyIcon } from "lucide-react"; @@ -120,6 +122,8 @@ export const MCPServerView: React.FC = ({ {[ Overview, MCP Tools, + Logs, + Diagnostics, ...(isProxyAdmin ? [Settings] : []), ]} @@ -175,6 +179,22 @@ export const MCPServerView: React.FC = ({ /> + {/* Logs Panel */} + + + + + {/* Diagnostics Panel */} + + + + {/* Settings Panel */} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 30c1d3c5b81..64d2d3b5545 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2524,6 +2524,10 @@ interface UiSpendLogsParams { sort_order?: "asc" | "desc"; min_spend?: number; max_spend?: number; + /** Filter by call type (e.g. "call_mcp_tool", "completion") */ + call_type?: string; + /** Filter by MCP namespaced tool name (partial match) */ + mcp_tool_name?: string; } interface UiSpendLogsCallOptions { @@ -9408,3 +9412,54 @@ export const deleteToolPolicyOverride = async ( } return response.json(); }; + +export const fetchMCPServerLogs = async ( + accessToken: string, + serverId: string, + startDate: string, + endDate: string, + page: number = 1, + pageSize: number = 25, +) => { + const q = new URLSearchParams(); + q.set("start_date", startDate); + q.set("end_date", endDate); + q.set("page", page.toString()); + q.set("page_size", pageSize.toString()); + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/logs?${q.toString()}` + : `/v1/mcp/server/${serverId}/logs?${q.toString()}`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.text(); + throw new Error(errorData); + } + return response.json(); +}; + +export const fetchMCPServerDiagnostics = async ( + accessToken: string, + serverId: string, +) => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/diagnose` + : `/v1/mcp/server/${serverId}/diagnose`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + const errorData = await response.text(); + throw new Error(errorData); + } + return response.json(); +}; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index ab70126a5a8..0596b8bf4b7 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -466,6 +466,22 @@ export default function SpendLogsTable({ label: "Error Message", isSearchable: false, }, + { + name: "Call Type", + label: "Call Type", + isSearchable: false, + options: [ + { label: "All", value: "" }, + { label: "MCP Tool Call", value: "call_mcp_tool" }, + { label: "MCP List Tools", value: "list_mcp_tools" }, + { label: "Completion", value: "completion" }, + { label: "Embedding", value: "embedding" }, + { label: "Image Generation", value: "image_generation" }, + { label: "Audio Transcription", value: "audio_transcription" }, + { label: "Text to Speech", value: "speech" }, + { label: "Rerank", value: "rerank" }, + ], + }, ]; const formatTimeUnit = (value: number, unit: string) => { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 400e86d19ee..71735badd65 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -20,6 +20,7 @@ const FILTER_KEYS = { KEY_ALIAS: "Key Alias", ERROR_CODE: "Error Code", ERROR_MESSAGE: "Error Message", + CALL_TYPE: "Call Type", } as const; export type FilterKey = keyof typeof FILTER_KEYS; @@ -64,6 +65,7 @@ export function useLogFilterLogic({ [FILTER_KEYS.KEY_ALIAS]: "", [FILTER_KEYS.ERROR_CODE]: "", [FILTER_KEYS.ERROR_MESSAGE]: "", + [FILTER_KEYS.CALL_TYPE]: "", }), [], ); @@ -108,6 +110,7 @@ export function useLogFilterLogic({ key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined, error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined, error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined, + call_type: filters[FILTER_KEYS.CALL_TYPE] || undefined, sort_by: sortBy, sort_order: sortOrder, }, @@ -143,7 +146,8 @@ export function useLogFilterLogic({ filters[FILTER_KEYS.END_USER] || filters[FILTER_KEYS.ERROR_CODE] || filters[FILTER_KEYS.ERROR_MESSAGE] || - filters[FILTER_KEYS.MODEL] + filters[FILTER_KEYS.MODEL] || + filters[FILTER_KEYS.CALL_TYPE] ), [filters], );