feat: Add MCP configuration review and invocation logs for troubleshooting

Backend:
- Add call_type and mcp_tool_name filters to /spend/logs/ui endpoint
- Add GET /v1/mcp/server/{server_id}/logs endpoint for per-server invocation logs
- Add GET /v1/mcp/server/{server_id}/diagnose endpoint for connection diagnostics

Frontend:
- Add 'Logs' tab to MCP Server detail view showing invocation history
- Add 'Diagnostics' tab showing pass/fail checks for config, auth, connectivity, network
- Add 'Call Type' filter to main Logs page (MCP Tool Call, Completion, etc.)
- Add fetchMCPServerLogs and fetchMCPServerDiagnostics networking functions

Tests:
- Add 5 Python tests for new backend endpoints (logs + diagnostics)
- Add 9 Vitest tests for new UI components (logs + diagnostics)

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-03-07 08:34:45 +00:00
parent b19cf80c55
commit 0b4a6b2679
11 changed files with 1235 additions and 1 deletions

View file

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

View file

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

View file

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

View file

@ -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(
<MCPServerDiagnostics
serverId="test-server"
accessToken="test-token"
/>
);
});
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(
<MCPServerDiagnostics
serverId="test-server"
accessToken="test-token"
/>
);
});
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(
<MCPServerDiagnostics
serverId="bad-server"
accessToken="test-token"
/>
);
});
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(
<MCPServerDiagnostics
serverId="test-server"
accessToken="test-token"
/>
);
});
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(
<MCPServerDiagnostics
serverId="test-server"
accessToken="test-token"
/>
);
});
await waitFor(() => {
expect(screen.getByText("Re-run")).toBeInTheDocument();
});
});
});

View file

@ -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<string, any>;
}
interface DiagnosticsResult {
server_id: string;
server_name: string;
overall_status: "healthy" | "unhealthy";
checks: DiagnosticCheck[];
suggestions: string[];
}
const CHECK_LABELS: Record<string, string> = {
configuration: "Configuration",
auth_type: "Authentication",
connectivity: "Connectivity",
network: "Network Access",
};
const MCPServerDiagnostics: React.FC<MCPServerDiagnosticsProps> = ({
serverId,
accessToken,
}) => {
const [result, setResult] = useState<DiagnosticsResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Connection Diagnostics</Title>
<Button
icon={<ReloadOutlined />}
onClick={runDiagnostics}
loading={loading}
>
Re-run
</Button>
</div>
{loading && !result && (
<div className="flex justify-center py-8">
<Spin tip="Running diagnostics..." />
</div>
)}
{error && (
<Alert
type="error"
message="Diagnostics Failed"
description={error}
showIcon
className="mb-4"
/>
)}
{result && (
<>
<div className="mb-4">
<Tag
color={result.overall_status === "healthy" ? "green" : "red"}
className="text-sm px-3 py-1"
>
{result.overall_status === "healthy"
? "All Checks Passing"
: "Issues Detected"}
</Tag>
</div>
<List
dataSource={result.checks}
renderItem={(check: DiagnosticCheck) => (
<List.Item>
<List.Item.Meta
avatar={
check.status === "pass" ? (
<CheckCircleOutlined
style={{ fontSize: 20, color: "#52c41a" }}
/>
) : (
<CloseCircleOutlined
style={{ fontSize: 20, color: "#ff4d4f" }}
/>
)
}
title={
<span className="font-medium">
{CHECK_LABELS[check.name] || check.name}
</span>
}
description={
<div>
<Text>{check.message}</Text>
{check.details && (
<div className="mt-1 flex flex-wrap gap-2">
{Object.entries(check.details).map(
([key, value]) =>
value != null && (
<Tag key={key} className="text-xs">
{key}: {String(value)}
</Tag>
)
)}
</div>
)}
</div>
}
/>
</List.Item>
)}
/>
{result.suggestions.length > 0 && (
<Alert
type="warning"
message="Suggestions"
description={
<ul className="list-disc pl-4 mt-1">
{result.suggestions.map((s, i) => (
<li key={i} className="text-sm">
{s}
</li>
))}
</ul>
}
showIcon
className="mt-4"
/>
)}
</>
)}
</Card>
);
};
export default MCPServerDiagnostics;

View file

@ -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(
<MCPServerLogs serverId="test-server" accessToken="test-token" />
);
});
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(
<MCPServerLogs serverId="test-server" accessToken="test-token" />
);
});
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(
<MCPServerLogs serverId="test-server" accessToken="test-token" />
);
});
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(
<MCPServerLogs serverId="my-server-id" accessToken="my-token" />
);
});
await waitFor(() => {
expect(fetchMCPServerLogs).toHaveBeenCalledWith(
"my-token",
"my-server-id",
expect.any(String),
expect.any(String),
1,
25,
);
});
});
});

View file

@ -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<MCPServerLogsProps> = ({
serverId,
accessToken,
}) => {
const [logs, setLogs] = useState<MCPLogEntry[]>([]);
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) => (
<Tooltip title={val}>
<span className="text-xs">{moment(val).format("MMM DD HH:mm:ss")}</span>
</Tooltip>
),
},
{
title: "Tool",
dataIndex: "mcp_namespaced_tool_name",
key: "tool",
render: (val: string | null) => (
<span className="font-mono text-xs">{extractToolName(val)}</span>
),
},
{
title: "Type",
dataIndex: "call_type",
key: "call_type",
width: 130,
render: (val: string) => (
<Tag color={val === "call_mcp_tool" ? "blue" : "default"}>
{val === "call_mcp_tool" ? "Tool Call" : val === "list_mcp_tools" ? "List Tools" : val}
</Tag>
),
},
{
title: "Status",
dataIndex: "status",
key: "status",
width: 90,
render: (val: string | null) => {
const isSuccess = !val || val === "success";
return (
<Tag color={isSuccess ? "green" : "red"}>
{isSuccess ? "OK" : val}
</Tag>
);
},
},
{
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 (
<span className="text-xs truncate max-w-[140px] block">
{alias || record.api_key?.slice(0, 12) || "—"}
</span>
);
},
},
];
return (
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Invocation Logs</Title>
<Select
value={timeRange}
onChange={setTimeRange}
style={{ width: 140 }}
options={[
{ label: "Last 1 hour", value: "1h" },
{ label: "Last 24 hours", value: "24h" },
{ label: "Last 7 days", value: "7d" },
{ label: "Last 30 days", value: "30d" },
]}
/>
</div>
{loading ? (
<div className="flex justify-center py-8">
<Spin />
</div>
) : logs.length === 0 ? (
<Empty
description={
<Text>No MCP invocation logs found for this server in the selected time range.</Text>
}
/>
) : (
<>
<Table
dataSource={logs}
columns={columns}
rowKey="request_id"
pagination={false}
size="small"
scroll={{ x: 900 }}
/>
{total > pageSize && (
<div className="flex justify-end mt-4">
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={(p) => setPage(p)}
showSizeChanger={false}
showTotal={(total) => `${total} logs`}
/>
</div>
)}
</>
)}
</Card>
);
};
export default MCPServerLogs;

View file

@ -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<MCPServerViewProps> = ({
{[
<Tab key="overview">Overview</Tab>,
<Tab key="tools">MCP Tools</Tab>,
<Tab key="logs">Logs</Tab>,
<Tab key="diagnostics">Diagnostics</Tab>,
...(isProxyAdmin ? [<Tab key="settings">Settings</Tab>] : []),
]}
</TabList>
@ -175,6 +179,22 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
/>
</TabPanel>
{/* Logs Panel */}
<TabPanel>
<MCPServerLogs
serverId={mcpServer.server_id}
accessToken={accessToken}
/>
</TabPanel>
{/* Diagnostics Panel */}
<TabPanel>
<MCPServerDiagnostics
serverId={mcpServer.server_id}
accessToken={accessToken}
/>
</TabPanel>
{/* Settings Panel */}
<TabPanel>
<Card>

View file

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

View file

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

View file

@ -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],
);