diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx index 278749dff57..036eb9f2872 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx @@ -19,7 +19,14 @@ vi.mock("@/components/networking", () => ({ getGuardrailsList: vi.fn().mockResolvedValue({ data: [] }), getPoliciesList: vi.fn().mockResolvedValue({ data: [] }), modelHubCall: vi.fn().mockResolvedValue({ data: [] }), - fetchMCPServers: vi.fn().mockResolvedValue([]), + fetchMCPServers: vi.fn().mockResolvedValue([ + { + server_id: "mcp-server-1", + server_name: "Demo MCP Server", + alias: "Demo MCP Server", + description: "A demo MCP server", + }, + ]), fetchMCPToolsets: vi.fn().mockResolvedValue([]), listMCPTools: vi.fn().mockResolvedValue({ tools: [] }), callMCPTool: vi.fn(), @@ -235,6 +242,11 @@ describe("ChatUI", () => { await waitFor(() => { expect(mcpInput()).not.toBeDisabled(); }); + + await userEvent.setup().click(mcpInput()); + await waitFor(() => { + expect(screen.getByText("Demo MCP Server")).toBeInTheDocument(); + }); }); it("should show Simulate failure to test fallbacks in Model Settings when chat endpoint is selected", async () => { @@ -458,7 +470,7 @@ describe("ChatUI", () => { expect(screen.getByText("MCP Servers")).toBeInTheDocument(); - const mcpInput = screen.getByLabelText("Select MCP servers"); + const mcpInput = await screen.findByLabelText("Select MCP servers"); expect(mcpInput).toBeInTheDocument(); expect(mcpInput).not.toBeDisabled(); @@ -466,6 +478,7 @@ describe("ChatUI", () => { await waitFor(() => { expect(screen.getByText("All MCP Servers")).toBeInTheDocument(); + expect(screen.getByText("Demo MCP Server")).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx index cb8b05e24f5..56905c5dfe4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx @@ -274,21 +274,50 @@ const ChatUI: React.FC = ({ const chatEndRef = useRef(null); + const normalizeListResponse = (payload: unknown): T[] => { + if (Array.isArray(payload)) { + return payload as T[]; + } + if (payload && typeof payload === "object") { + const record = payload as Record; + if (Array.isArray(record.data)) { + return record.data as T[]; + } + if (Array.isArray(record.servers)) { + return record.servers as T[]; + } + if (Array.isArray(record.toolsets)) { + return record.toolsets as T[]; + } + } + return []; + }; + // Fetch MCP servers and toolsets - const loadMCPServers = async () => { - const userApiKey = apiKeySource === "session" ? accessToken : apiKey; - if (!userApiKey) return; + const loadMCPServers = async (overrideKey?: string | null) => { + const userApiKey = + overrideKey !== undefined ? overrideKey : apiKeySource === "session" ? accessToken : apiKey.trim(); + if (!userApiKey) { + setMCPServers([]); + setMCPToolsets([]); + return; + } setIsLoadingMCPServers(true); try { - const [servers, toolsets] = await Promise.all([ + const [serversPayload, toolsetsPayload] = await Promise.all([ fetchMCPServers(userApiKey), fetchMCPToolsets(userApiKey).catch(() => []), ]); - setMCPServers(Array.isArray(servers) ? servers : servers.data || []); - setMCPToolsets(Array.isArray(toolsets) ? toolsets : []); + const servers = normalizeListResponse(serversPayload); + const toolsets = normalizeListResponse(toolsetsPayload); + setMCPServers(servers); + setMCPToolsets(toolsets); } catch (error) { console.error("Error fetching MCP servers:", error); + setMCPServers([]); + setMCPToolsets([]); + NotificationsManager.error("Unable to load MCP servers for this key"); } finally { setIsLoadingMCPServers(false); } @@ -456,7 +485,7 @@ const ChatUI: React.FC = ({ if (!simplified) { void loadModels(); } - void loadMCPServers(); + void loadMCPServers(userApiKey); return () => { cancelled = true; @@ -629,17 +658,24 @@ const ChatUI: React.FC = ({ }); } for (const toolset of mcpToolsets) { + if (!toolset?.toolset_id) { + continue; + } + const toolCount = Array.isArray(toolset.tools) ? toolset.tools.length : 0; options.push({ value: `toolset:${toolset.toolset_id}`, - label: toolset.toolset_name, - description: toolset.description || `Toolset (${toolset.tools.length} tools)`, + label: toolset.toolset_name || toolset.toolset_id, + description: toolset.description || `Toolset (${toolCount} tools)`, }); } for (const server of mcpServers) { + if (!server?.server_id) { + continue; + } options.push({ value: server.server_id, label: server.alias || server.server_name || server.server_id, - description: server.description, + description: server.description || undefined, }); } return options; @@ -858,7 +894,7 @@ const ChatUI: React.FC = ({ const requestProxyBaseUrl = simplified && proxySettings - ? (proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined) + ? proxySettings.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings.PROXY_BASE_URL ?? undefined : customProxyBaseUrl || undefined; await makeOpenAIChatCompletionRequest( apiChatHistory, @@ -1509,23 +1545,45 @@ const ChatUI: React.FC = ({ : undefined } placeholder="Select MCP server" - emptyText={isLoadingMCPServers ? "Loading..." : "No MCP servers"} + emptyText={ + isLoadingMCPServers + ? "Loading..." + : mcpServerOptions.length === 0 + ? "No MCP servers configured" + : "No matching MCP servers" + } disabled={!MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType) || isLoadingMCPServers} onValueChange={(value) => handleMcpServersChange(value ? [value] : [])} - options={mcpServerOptions} + options={mcpServerOptions.map((option) => ({ + value: option.value, + label: option.label, + sublabel: option.description, + }))} className="mb-2" /> ) : ( - +
+ + {!isLoadingMCPServers && mcpServers.length === 0 && ( +

+ No MCP servers available for this key. Add servers on the MCP page. +

+ )} +
)} {endpointType === EndpointType.MCP && diff --git a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx index f084572bc0c..7b304a69a5c 100644 --- a/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx +++ b/ui/litellm-dashboard/src/components/shared/MultiSelect.tsx @@ -11,6 +11,7 @@ import { ComboboxItem, ComboboxList, ComboboxValue, + useComboboxAnchor, } from "@/components/ui/combobox"; export interface MultiSelectOption { @@ -33,12 +34,13 @@ interface MultiSelectProps { const matchesQuery = (option: MultiSelectOption, query: string): boolean => { const normalizedQuery = query.trim().toLowerCase(); - return ( - !normalizedQuery || - option.label.toLowerCase().includes(normalizedQuery) || - option.value.toLowerCase().includes(normalizedQuery) || - (option.description?.toLowerCase().includes(normalizedQuery) ?? false) - ); + if (!normalizedQuery) { + return true; + } + const label = option.label?.toLowerCase() ?? ""; + const value = option.value?.toLowerCase() ?? ""; + const description = option.description?.toLowerCase() ?? ""; + return label.includes(normalizedQuery) || value.includes(normalizedQuery) || description.includes(normalizedQuery); }; export function MultiSelect({ @@ -53,10 +55,17 @@ export function MultiSelect({ className, }: MultiSelectProps) { const [query, setQuery] = useState(""); - const safeOptions = options.filter( - (option): option is MultiSelectOption => - option != null && typeof option.value === "string" && option.value.length > 0, - ); + const anchor = useComboboxAnchor(); + const safeOptions = options + .filter( + (option): option is MultiSelectOption => + option != null && typeof option.value === "string" && option.value.length > 0, + ) + .map((option) => ({ + value: option.value, + label: option.label?.trim() || option.value, + description: option.description || undefined, + })); const selectedOptions = value .filter((selectedValue): selectedValue is string => typeof selectedValue === "string" && selectedValue.length > 0) .map( @@ -79,17 +88,18 @@ export function MultiSelect({ items={items} value={selectedOptions} onValueChange={(selected: MultiSelectOption[]) => { - onValueChange(selected.map((option) => option.value)); + onValueChange(selected.map((option) => option.value).filter(Boolean)); setQuery(""); }} inputValue={query} onInputValueChange={setQuery} isItemEqualToValue={(option: MultiSelectOption, selected: MultiSelectOption) => option.value === selected.value} itemToStringLabel={(option: MultiSelectOption) => option.label} + itemToStringValue={(option: MultiSelectOption) => option.value} filter={matchesQuery} disabled={disabled || loading} > - + {(selected: MultiSelectOption[]) => selected.map((option) => ( @@ -105,16 +115,20 @@ export function MultiSelect({ aria-label={placeholder} /> - + {emptyText} {(option: MultiSelectOption) => ( {option.label} - {option.description && ( + {option.description ? ( {option.description} - )} + ) : null} )} diff --git a/ui/litellm-dashboard/src/components/ui/combobox.tsx b/ui/litellm-dashboard/src/components/ui/combobox.tsx index 541ad8bb25c..b4d5fd5e228 100644 --- a/ui/litellm-dashboard/src/components/ui/combobox.tsx +++ b/ui/litellm-dashboard/src/components/ui/combobox.tsx @@ -190,12 +190,13 @@ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator. ); } -function ComboboxChips({ - className, - ...props -}: React.ComponentPropsWithRef & ComboboxPrimitive.Chips.Props) { +const ComboboxChips = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef & ComboboxPrimitive.Chips.Props +>(({ className, ...props }, ref) => { return ( ); -} +}); +ComboboxChips.displayName = "ComboboxChips"; function ComboboxChip({ className,