diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx new file mode 100644 index 00000000000..559b72c5d92 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_discovery.tsx @@ -0,0 +1,309 @@ +import React, { useState, useMemo, useEffect } from "react"; +import { Modal, Input } from "antd"; +import { Text } from "@tremor/react"; +import { fetchDiscoverableMCPServers } from "../networking"; +import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "./types"; + +const { Search } = Input; + +interface MCPDiscoveryProps { + isVisible: boolean; + onClose: () => void; + onSelectServer: (server: DiscoverableMCPServer) => void; + onCustomServer: () => void; + accessToken: string | null; +} + +const INITIAL_COLORS = [ + "#3B82F6", + "#10B981", + "#F59E0B", + "#EF4444", + "#8B5CF6", + "#EC4899", + "#06B6D4", + "#84CC16", +]; + +function getInitialAvatar(name: string) { + const initial = name.charAt(0).toUpperCase(); + const colorIndex = + name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % + INITIAL_COLORS.length; + return { initial, backgroundColor: INITIAL_COLORS[colorIndex] }; +} + +const MCPDiscovery: React.FC = ({ + isVisible, + onClose, + onSelectServer, + onCustomServer, + accessToken, +}) => { + const [servers, setServers] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("All"); + + useEffect(() => { + if (isVisible && accessToken) { + setLoading(true); + setError(null); + fetchDiscoverableMCPServers(accessToken) + .then((data: DiscoverMCPServersResponse) => { + setServers(data.servers || []); + setCategories(data.categories || []); + }) + .catch((err: Error) => { + setError(err.message || "Failed to load MCP servers"); + }) + .finally(() => { + setLoading(false); + }); + } + }, [isVisible, accessToken]); + + useEffect(() => { + if (isVisible) { + setSearchQuery(""); + setSelectedCategory("All"); + } + }, [isVisible]); + + const filteredServers = useMemo(() => { + let result = servers; + if (selectedCategory !== "All") { + result = result.filter((s) => s.category === selectedCategory); + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (s) => + s.name.toLowerCase().includes(q) || + s.title.toLowerCase().includes(q) || + s.description.toLowerCase().includes(q), + ); + } + return result; + }, [servers, selectedCategory, searchQuery]); + + const groupedServers = useMemo(() => { + const groups: Record = {}; + for (const server of filteredServers) { + const cat = server.category || "Other"; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(server); + } + return groups; + }, [filteredServers]); + + return ( + + + Add MCP Server + + + + } + open={isVisible} + onCancel={onClose} + footer={null} + width={640} + styles={{ + body: { maxHeight: "70vh", overflowY: "auto", padding: "12px 24px 24px" }, + header: { padding: "16px 24px 0", border: "none" }, + }} + > + {/* Filter pills */} +
+ {["All", ...categories].map((cat) => { + const isSelected = selectedCategory === cat; + return ( + + ); + })} +
+ + {/* Search */} + setSearchQuery(e.target.value)} + style={{ marginBottom: 12 }} + size="small" + allowClear + /> + + {/* Loading skeleton */} + {loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ )} + + {error && ( +
+ Failed to load servers: {error} +
+ )} + + {!loading && !error && filteredServers.length === 0 && ( +
+ + No servers found.{" "} + + Add a custom server + + +
+ )} + + {/* Server list grouped by category */} + {!loading && + !error && + Object.entries(groupedServers).map(([category, categoryServers]) => ( +
+
+ {category} +
+ {categoryServers.map((server) => { + const avatar = getInitialAvatar(server.title || server.name); + return ( +
onSelectServer(server)} + style={{ + display: "flex", + alignItems: "center", + padding: "6px 8px", + borderRadius: 4, + cursor: "pointer", + transition: "background 0.1s ease", + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = "#f9fafb"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = "transparent"; + }} + > + {server.icon_url ? ( + {server.title} { + const target = e.currentTarget; + target.style.display = "none"; + const next = target.nextElementSibling as HTMLElement; + if (next) next.style.display = "flex"; + }} + /> + ) : null} +
+ {avatar.initial} +
+ + {server.title || server.name} + + + › + +
+ ); + })} +
+ ))} + + ); +}; + +export default MCPDiscovery;