diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx new file mode 100644 index 00000000000..064a9481325 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx @@ -0,0 +1,383 @@ +import { useState, type FC, type KeyboardEvent, type MouseEvent } from "react"; +import { Dropdown, Tooltip, Typography, Tag } from "antd"; +import type { MenuProps } from "antd"; +import { + CheckOutlined, + DeleteOutlined, + ExclamationCircleFilled, + MoreOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; +import type { MCPServer } from "./types"; +import { getMaskedAndFullUrl } from "./utils"; + +const { Text } = Typography; + +interface MCPServerCardProps { + server: MCPServer; + // Per-user env-var fields this user still needs to fill in for this server. + // Computed by the parent from the bulk /user-env-vars/status response, so + // the card never issues a per-row request (no N+1). + missingUserFields?: string[]; + isLoadingHealth?: boolean; + isRechecking?: boolean; + onClick: () => void; + onRecheckHealth?: () => void; + onByokConnect?: () => void; + onOpenFillFields?: () => void; + onDelete?: () => void; +} + +const HEALTH_TONE: Record = { + healthy: { dot: "bg-green-500" }, + unhealthy: { dot: "bg-red-500" }, + unknown: { dot: "bg-gray-300" }, +}; + +// Stop card-level click handler from firing when an interactive child is used. +const stop = (e: MouseEvent | KeyboardEvent) => e.stopPropagation(); + +const MCPServerCard: FC = ({ + server, + missingUserFields, + isLoadingHealth, + isRechecking, + onClick, + onRecheckHealth, + onByokConnect, + onOpenFillFields, + onDelete, +}) => { + const alias = server.alias || server.server_name || ""; + const name = server.server_name || alias || server.server_id; + // Logo is sourced exclusively from the admin-set `mcp_info.logo_url`. + const candidateLogo = server.mcp_info?.logo_url ?? undefined; + const [failedLogoUrl, setFailedLogoUrl] = useState(null); + const logoUrl = candidateLogo && failedLogoUrl !== candidateLogo ? candidateLogo : undefined; + const transport = server.transport || "http"; + const displayTransport = server.spec_path && transport !== "stdio" ? "openapi" : transport; + const authType = server.auth_type || "none"; + const status = server.status || "unknown"; + const healthTone = HEALTH_TONE[status] ?? HEALTH_TONE.unknown; + const isPublic = server.available_on_public_internet; + const accessGroups = (server.mcp_access_groups ?? []).filter((g): g is string => typeof g === "string"); + + const missing = missingUserFields ?? []; + const needsAttention = missing.length > 0; + + const cardClass = needsAttention + ? "border-2 border-red-300 bg-red-50/40 hover:border-red-400 hover:shadow-md" + : "border border-gray-200 bg-white hover:border-gray-300 hover:shadow-md"; + + const url = server.url || ""; + const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" }; + + // Transport-adapted identifier shown under the title. Every transport has + // something useful here, which keeps the tag row vertically aligned across + // cards in the grid (stdio cards no longer "snap up" because they lack a URL). + let subtitle = ""; + let subtitleTooltip = ""; + if (transport === "stdio") { + const parts = [server.command, ...(server.args ?? [])].filter( + (p): p is string => typeof p === "string" && p.length > 0, + ); + subtitle = parts.join(" "); + subtitleTooltip = subtitle; + } else if (server.spec_path) { + subtitle = server.spec_path; + subtitleTooltip = server.spec_path; + } else if (url) { + subtitle = maskedUrl; + subtitleTooltip = url; + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }; + + const menuItems: MenuProps["items"] = []; + if (onRecheckHealth) { + menuItems.push({ + key: "test-connection", + label: "Test Connection", + icon: , + disabled: isRechecking, + onClick: ({ domEvent }) => { + domEvent.stopPropagation(); + onRecheckHealth(); + }, + }); + } + if (onDelete) { + if (menuItems.length > 0) { + menuItems.push({ key: "divider", type: "divider" }); + } + menuItems.push({ + key: "delete", + label: "Delete", + icon: , + danger: true, + onClick: ({ domEvent }) => { + domEvent.stopPropagation(); + onDelete(); + }, + }); + } + + // Card uses role="button" + nested + + )} + + + {subtitle ? ( + + + {subtitle} + + + ) : ( + // Defensive placeholder: keep the row even when no identifier is + // available so the tag row stays vertically aligned across the grid. +
+ )} + +
+ + {displayTransport.toUpperCase()} + {authType} + + + + {isPublic ? "Public" : "Internal"} + + + {accessGroups.slice(0, 2).map((g) => ( + + {g} + + ))} + {accessGroups.length > 2 && ( + + +{accessGroups.length - 2} + + )} +
+ + {(server.is_byok || needsAttention) && ( +
+ {server.is_byok && } + {needsAttention && ( +
+ +
Missing user fields:
+
    + {missing.map((m) => ( +
  • • {m}
  • + ))} +
+
+ } + > + + + {missing.length} user field + {missing.length === 1 ? "" : "s"} missing + + + {onOpenFillFields && ( + + )} +
+ )} +
+ )} + + ); +}; + +interface HealthChipProps { + status: string; + isLoadingHealth?: boolean; + isRechecking?: boolean; + onRecheck?: () => void; + lastCheck?: string | null; + error?: string | null; + dotClass: string; +} + +const HealthChip: FC = ({ + status, + isLoadingHealth, + isRechecking, + onRecheck, + lastCheck, + error, + dotClass, +}) => { + if (isLoadingHealth || isRechecking) { + return ( + + + + Checking + + + ); + } + const tooltip = ( +
+
Health: {status}
+ {lastCheck &&
Last check: {new Date(lastCheck).toLocaleString()}
} + {error && ( +
+
Error
+
{error}
+
+ )} + {!lastCheck && !error &&
No health data
} + {onRecheck &&
Click to recheck
} +
+ ); + return ( + + { + e.stopPropagation(); + onRecheck(); + } + : undefined + } + > + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + + + ); +}; + +interface ByokRowProps { + connected: boolean; + onConnect?: () => void; +} + +const ByokRow: FC = ({ connected, onConnect }) => { + if (connected) { + return ( +
+ BYOK credential +
+ + Connected + + {onConnect && ( + + )} +
+
+ ); + } + return ( +
+ BYOK credential + {onConnect ? ( + + ) : ( + + )} +
+ ); +}; + +export default MCPServerCard; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 23adf6de794..bfb14707e87 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -1,8 +1,8 @@ import { isAdminRole } from "@/utils/roles"; -import { QuestionCircleOutlined } from "@ant-design/icons"; +import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons"; import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react"; import NewBadge from "../common_components/NewBadge"; -import { Descriptions, Modal, Select, Tooltip, Typography } from "antd"; +import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd"; import React, { useEffect, useState, useMemo, useCallback } from "react"; import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers"; import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMCPServerHealth"; @@ -10,12 +10,11 @@ import NotificationsManager from "../molecules/notifications_manager"; import { deleteMCPServer } from "../networking"; import { MCPSubmissionsTab } from "./MCPSubmissionsTab"; import { MCPToolsetsTab } from "./MCPToolsetsTab"; -import { DataTable } from "../view_logs/table"; import CreateMCPServer from "./create_mcp_server"; import MCPConnect from "./mcp_connect"; -import { mcpServerColumns } from "./mcp_server_columns"; +import MCPServerCard from "./MCPServerCard"; import { MCPServerView } from "./mcp_server_view"; -import { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types"; +import type { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types"; import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings"; import MCPNetworkSettings from "./MCPNetworkSettings"; import MCPDiscovery from "./mcp_discovery"; @@ -23,6 +22,50 @@ import { ByokCredentialModal } from "./ByokCredentialModal"; import { getSecureItem } from "@/utils/secureStorage"; import { TOOLS_OAUTH_UI_STATE_KEY } from "@/hooks/mcpOAuthUtils"; +type SortKey = "created_desc" | "updated_desc" | "name_asc" | "health"; + +const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: "created_desc", label: "Recently created" }, + { value: "updated_desc", label: "Recently updated" }, + { value: "name_asc", label: "Name (A→Z)" }, + { value: "health", label: "Health (unhealthy first)" }, +]; + +const HEALTH_RANK: Record = { + unhealthy: 0, + unknown: 1, + healthy: 2, +}; + +const compareServers = (a: MCPServer, b: MCPServer, sort: SortKey): number => { + switch (sort) { + case "name_asc": { + const nameA = (a.server_name || a.alias || a.server_id).toLowerCase(); + const nameB = (b.server_name || b.alias || b.server_id).toLowerCase(); + return nameA.localeCompare(nameB); + } + case "updated_desc": { + const ta = a.updated_at ? new Date(a.updated_at).getTime() : 0; + const tb = b.updated_at ? new Date(b.updated_at).getTime() : 0; + return tb - ta; + } + case "health": { + const ra = HEALTH_RANK[a.status ?? "unknown"] ?? 1; + const rb = HEALTH_RANK[b.status ?? "unknown"] ?? 1; + if (ra !== rb) return ra - rb; + const ta = a.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b.created_at ? new Date(b.created_at).getTime() : 0; + return tb - ta; + } + case "created_desc": + default: { + const ta = a.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b.created_at ? new Date(b.created_at).getTime() : 0; + return tb - ta; + } + } +}; + const { Text: AntdText, Title: AntdTitle } = Typography; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; @@ -87,6 +130,8 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) const [prefillData, setPrefillData] = useState(null); const [isDeletingServer, setIsDeletingServer] = useState(false); const [byokModalServer, setByokModalServer] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [sortKey, setSortKey] = useState("created_desc"); const isInternalUser = userRole === "Internal User"; useEffect(() => { @@ -192,26 +237,20 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) filterServers(selectedTeam, selectedMcpAccessGroup); }, [serversWithHealth, selectedTeam, selectedMcpAccessGroup, filterServers]); - const columns = React.useMemo( - () => - mcpServerColumns( - userRole ?? "", - (serverId: string) => { - setSelectedServerId(serverId); - setEditServer(false); - }, - (serverId: string) => { - setSelectedServerId(serverId); - setEditServer(true); - }, - handleDelete, - isLoadingHealth, - (server: MCPServer) => setByokModalServer(server), - recheckServerHealth, - recheckingServerIds, - ), - [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds], - ); + // Search + sort layer applied on top of the team/access-group filters. + const displayedServers = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + const matches = q + ? filteredServers.filter((s) => { + const name = (s.server_name || "").toLowerCase(); + const alias = (s.alias || "").toLowerCase(); + const url = (s.url || "").toLowerCase(); + const id = s.server_id.toLowerCase(); + return name.includes(q) || alias.includes(q) || url.includes(q) || id.includes(q); + }) + : filteredServers; + return [...matches].sort((a, b) => compareServers(a, b, sortKey)); + }, [filteredServers, searchQuery, sortKey]); function handleDelete(server_id: string) { setServerToDelete(server_id); @@ -226,6 +265,14 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) setIsDeletingServer(true); await deleteMCPServer(accessToken, serverIdToDelete); NotificationsManager.success("Deleted MCP Server successfully"); + // If the user is currently viewing the detail page of the server they + // just deleted, return them to the All Servers list. Otherwise the + // detail view would stay mounted, fall back to an empty stub server, + // and show a phantom "Unnamed Server" page. + if (selectedServerId === serverIdToDelete) { + setEditServer(false); + setSelectedServerId(null); + } refetch(); } catch (error) { console.error("Error deleting the mcp server:", error); @@ -454,17 +501,73 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) -
-
} - getRowCanExpand={() => false} - isLoading={isLoadingServers} - noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started." - loadingMessage="Loading MCP servers..." - enableSorting={true} +
+ } + placeholder="Search by name, alias, URL, or ID" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + style={{ maxWidth: 320 }} /> +
+ Sort + +
+
+ {displayedServers.length} of {filteredServers.length} servers +
+
+
+ {isLoadingServers ? ( +
+ +
+ ) : displayedServers.length === 0 ? ( +
+ +
+ ) : ( +
+ {displayedServers.map((server) => ( + { + setSelectedServerId(server.server_id); + setEditServer(true); + }} + onRecheckHealth={ + recheckServerHealth ? () => recheckServerHealth(server.server_id) : undefined + } + onByokConnect={server.is_byok ? () => setByokModalServer(server) : undefined} + onDelete={isAdminRole(userRole) ? () => handleDelete(server.server_id) : undefined} + /> + ))} +
+ )}
)} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index e19f3214a2b..8aaac51ad68 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -262,6 +262,41 @@ export interface MCPServer { /** Per-user OAuth token storage settings (interactive OAuth only) */ token_validation?: Record | null; token_storage_ttl_seconds?: number | null; + + /** + * Admin-configured env vars interpolated into static_headers via ${NAME}. + * Stored as a list so the UI can preserve admin-entered ordering. + */ + env_vars?: MCPEnvVar[] | null; +} + +/** One environment variable entry on an MCP server. */ +export type MCPEnvVarScope = "global" | "user"; + +export interface MCPEnvVar { + name: string; + /** For scope="global": the value used in interpolation. + * For scope="user": optional placeholder/description shown to users. */ + value: string; + scope: MCPEnvVarScope; + description?: string | null; +} + +/** One required per-user env var slot returned by the user-env-vars endpoint. */ +export interface MCPUserEnvVarSpec { + name: string; + description?: string | null; + is_set: boolean; +} + +/** Per-server per-user env var status returned by the API. */ +export interface MCPUserEnvVarsStatus { + server_id: string; + server_name?: string | null; + alias?: string | null; + required: MCPUserEnvVarSpec[]; + missing_count: number; + setup_url?: string | null; } export interface MCPServerProps { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/utils.tsx b/ui/litellm-dashboard/src/components/mcp_tools/utils.tsx index 44a06405615..6d9479a13c3 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/utils.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/utils.tsx @@ -1,3 +1,5 @@ +import { MCPEnvVar, MCPEnvVarScope } from "./types"; + export const extractMCPToken = (url: string): { token: string | null; baseUrl: string } => { try { const mcpIndex = url.indexOf("/mcp/"); @@ -51,3 +53,27 @@ export const validateMCPServerName = (value: string) => { ? Promise.reject("Cannot contain '-' (hyphen) or spaces. Please use '_' (underscore) instead.") : Promise.resolve(); }; + +// Normalize the env_vars form list into the payload shape the backend expects. +// Drops empty rows, invalid identifiers, and duplicate names; user-scoped entries never carry a value. +export const normalizeEnvVars = (list: unknown): MCPEnvVar[] => { + if (!Array.isArray(list)) return []; + const seen = new Set(); + const out: MCPEnvVar[] = []; + for (const entry of list) { + if (!entry || typeof entry !== "object") continue; + const record = entry as Record; + const name = String(record.name ?? "").trim(); + if (!name || seen.has(name)) continue; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue; + const scope: MCPEnvVarScope = record.scope === "user" ? "user" : "global"; + out.push({ + name, + value: scope === "user" ? "" : String(record.value ?? ""), + scope, + description: (record.description as string | undefined) || undefined, + }); + seen.add(name); + } + return out; +};