fix(ui): show MCP servers in playground multi-select

Anchor MultiSelect popup to chips, forward refs on ComboboxChips, harden
MCP list response parsing, and load servers with the active key so
existing MCP servers appear in the dropdown
This commit is contained in:
mubashir1osmani 2026-08-06 15:14:44 -07:00
parent ba339cac0c
commit 609f7beddf
4 changed files with 132 additions and 45 deletions

View file

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

View file

@ -274,21 +274,50 @@ const ChatUI: React.FC<ChatUIProps> = ({
const chatEndRef = useRef<HTMLDivElement>(null);
const normalizeListResponse = <T,>(payload: unknown): T[] => {
if (Array.isArray(payload)) {
return payload as T[];
}
if (payload && typeof payload === "object") {
const record = payload as Record<string, unknown>;
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<MCPServer>(serversPayload);
const toolsets = normalizeListResponse<MCPToolset>(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<ChatUIProps> = ({
if (!simplified) {
void loadModels();
}
void loadMCPServers();
void loadMCPServers(userApiKey);
return () => {
cancelled = true;
@ -629,17 +658,24 @@ const ChatUI: React.FC<ChatUIProps> = ({
});
}
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<ChatUIProps> = ({
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<ChatUIProps> = ({
: 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"
/>
) : (
<MultiSelect
value={selectedMCPServers}
onValueChange={handleMcpServersChange}
placeholder="Select MCP servers"
emptyText={isLoadingMCPServers ? "Loading..." : "No MCP servers"}
disabled={!MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType)}
loading={isLoadingMCPServers}
options={mcpServerOptions}
className="mb-2"
/>
<div className="mb-2 space-y-1">
<MultiSelect
value={selectedMCPServers}
onValueChange={handleMcpServersChange}
placeholder="Select MCP servers"
emptyText={
isLoadingMCPServers
? "Loading..."
: mcpServers.length === 0
? "No MCP servers configured"
: "No matching MCP servers"
}
disabled={!MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType)}
loading={isLoadingMCPServers}
options={mcpServerOptions}
/>
{!isLoadingMCPServers && mcpServers.length === 0 && (
<p className="text-xs text-muted-foreground">
No MCP servers available for this key. Add servers on the MCP page.
</p>
)}
</div>
)}
{endpointType === EndpointType.MCP &&

View file

@ -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}
>
<ComboboxChips className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
<ComboboxChips ref={anchor} className={`min-h-8 w-full py-1 text-sm ${className ?? ""}`}>
<ComboboxValue>
{(selected: MultiSelectOption[]) =>
selected.map((option) => (
@ -105,16 +115,20 @@ export function MultiSelect({
aria-label={placeholder}
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxContent
anchor={anchor}
side="bottom"
collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}
>
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
<ComboboxList>
{(option: MultiSelectOption) => (
<ComboboxItem key={option.value} value={option}>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.description && (
{option.description ? (
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
)}
) : null}
</span>
</ComboboxItem>
)}

View file

@ -190,12 +190,13 @@ function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
const ComboboxChips = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props
>(({ className, ...props }, ref) => {
return (
<ComboboxPrimitive.Chips
ref={ref}
data-slot="combobox-chips"
className={cn(
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
@ -204,7 +205,8 @@ function ComboboxChips({
{...props}
/>
);
}
});
ComboboxChips.displayName = "ComboboxChips";
function ComboboxChip({
className,