feat(ui/mcp): card-grid MCP servers list, frontend-only

Card slice of #28917 with env-vars wiring stripped; no networking or migration
This commit is contained in:
Tin Chi Lo 2026-06-10 11:49:12 -07:00
parent 37d6047884
commit 8b0c33a585
4 changed files with 582 additions and 35 deletions

View file

@ -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<string, { dot: string }> = {
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<MCPServerCardProps> = ({
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<string | null>(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<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
};
const menuItems: MenuProps["items"] = [];
if (onRecheckHealth) {
menuItems.push({
key: "test-connection",
label: "Test Connection",
icon: <ThunderboltOutlined />,
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: <DeleteOutlined />,
danger: true,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
onDelete();
},
});
}
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the
// recheck-health Tag), so a real <button> wrapper would produce invalid
// nested-interactive HTML. The role + tabIndex + Enter/Space handler keeps
// the whole card clickable and keyboard-accessible.
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className={`group relative flex h-full cursor-pointer flex-col gap-3 rounded-lg p-4 transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${cardClass}`}
>
<div className="flex items-start gap-3">
{logoUrl ? (
<img
src={logoUrl}
alt={`${name} logo`}
className="h-10 w-10 flex-shrink-0 rounded object-contain"
onError={() => setFailedLogoUrl(logoUrl)}
/>
) : (
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-gray-100 font-semibold text-gray-500">
{(name || "?").slice(0, 2).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="block w-full truncate text-left font-semibold text-gray-900" title={name}>
{name}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
{alias && <span className="truncate">{alias}</span>}
{alias && <span className="text-gray-300">·</span>}
<Tooltip title={server.server_id}>
<span className="font-mono text-blue-600">{server.server_id.slice(0, 7)}</span>
</Tooltip>
</div>
</div>
{menuItems.length > 0 && (
<Dropdown menu={{ items: menuItems }} trigger={["click"]} placement="bottomRight">
<button
type="button"
onClick={stop}
onKeyDown={stop}
aria-label="Server actions"
className="-mr-1 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600"
>
<MoreOutlined style={{ fontSize: 20 }} />
</button>
</Dropdown>
)}
</div>
{subtitle ? (
<Tooltip title={subtitleTooltip}>
<Text className="truncate font-mono text-xs text-gray-500" ellipsis>
{subtitle}
</Text>
</Tooltip>
) : (
// Defensive placeholder: keep the row even when no identifier is
// available so the tag row stays vertically aligned across the grid.
<div className="h-[18px]" aria-hidden />
)}
<div className="flex flex-wrap items-center gap-1.5">
<HealthChip
status={status}
isLoadingHealth={isLoadingHealth}
isRechecking={isRechecking}
onRecheck={onRecheckHealth}
lastCheck={server.last_health_check}
error={server.health_check_error}
dotClass={healthTone.dot}
/>
<Tag className="m-0">{displayTransport.toUpperCase()}</Tag>
<Tag className="m-0">{authType}</Tag>
<Tag color={isPublic ? "green" : "orange"} className="m-0">
<span className="inline-flex items-center gap-1">
<span className={`h-1.5 w-1.5 rounded-full ${isPublic ? "bg-green-500" : "bg-orange-500"}`} />
{isPublic ? "Public" : "Internal"}
</span>
</Tag>
{accessGroups.slice(0, 2).map((g) => (
<Tooltip key={g} title={g}>
<Tag className="m-0 max-w-[120px] truncate">{g}</Tag>
</Tooltip>
))}
{accessGroups.length > 2 && (
<Tooltip title={accessGroups.slice(2).join(", ")}>
<Tag className="m-0">+{accessGroups.length - 2}</Tag>
</Tooltip>
)}
</div>
{(server.is_byok || needsAttention) && (
<div className="mt-auto flex flex-col gap-2">
{server.is_byok && <ByokRow connected={!!server.has_user_credential} onConnect={onByokConnect} />}
{needsAttention && (
<div className="flex items-center justify-between gap-2 text-xs">
<Tooltip
title={
<div>
<div className="font-semibold mb-1">Missing user fields:</div>
<ul className="ml-3">
{missing.map((m) => (
<li key={m}> {m}</li>
))}
</ul>
</div>
}
>
<span className="inline-flex items-center gap-1 font-semibold text-red-700">
<ExclamationCircleFilled />
{missing.length} user field
{missing.length === 1 ? "" : "s"} missing
</span>
</Tooltip>
{onOpenFillFields && (
<button
type="button"
onClick={(e) => {
stop(e);
onOpenFillFields();
}}
className="rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white shadow-sm transition-colors hover:bg-red-700"
>
Set
</button>
)}
</div>
)}
</div>
)}
</div>
);
};
interface HealthChipProps {
status: string;
isLoadingHealth?: boolean;
isRechecking?: boolean;
onRecheck?: () => void;
lastCheck?: string | null;
error?: string | null;
dotClass: string;
}
const HealthChip: FC<HealthChipProps> = ({
status,
isLoadingHealth,
isRechecking,
onRecheck,
lastCheck,
error,
dotClass,
}) => {
if (isLoadingHealth || isRechecking) {
return (
<Tag className="m-0">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-500">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-gray-300" />
Checking
</span>
</Tag>
);
}
const tooltip = (
<div className="max-w-xs">
<div className="font-semibold mb-1">Health: {status}</div>
{lastCheck && <div className="text-xs mb-1">Last check: {new Date(lastCheck).toLocaleString()}</div>}
{error && (
<div className="text-xs">
<div className="font-medium text-red-300 mb-1">Error</div>
<div className="break-words">{error}</div>
</div>
)}
{!lastCheck && !error && <div className="text-xs text-gray-400">No health data</div>}
{onRecheck && <div className="mt-1 text-xs text-gray-300">Click to recheck</div>}
</div>
);
return (
<Tooltip title={tooltip} placement="top">
<Tag
className={`m-0 ${onRecheck ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
onClick={
onRecheck
? (e) => {
e.stopPropagation();
onRecheck();
}
: undefined
}
>
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</Tag>
</Tooltip>
);
};
interface ByokRowProps {
connected: boolean;
onConnect?: () => void;
}
const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
if (connected) {
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-500">BYOK credential</span>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full border border-green-200 bg-green-50 px-2 py-0.5 font-medium text-green-700">
<CheckOutlined style={{ fontSize: 10 }} /> Connected
</span>
{onConnect && (
<button
type="button"
onClick={(e) => {
stop(e);
onConnect();
}}
className="text-xs text-gray-400 transition-colors hover:text-blue-600"
>
Update
</button>
)}
</div>
</div>
);
}
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-500">BYOK credential</span>
{onConnect ? (
<button
type="button"
onClick={(e) => {
stop(e);
onConnect();
}}
className="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white shadow-sm transition-colors hover:bg-blue-700"
>
Connect
</button>
) : (
<span className="text-gray-400"></span>
)}
</div>
);
};
export default MCPServerCard;

View file

@ -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<string, number> = {
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<MCPServerProps> = ({ accessToken, userRole, userID })
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");
const [sortKey, setSortKey] = useState<SortKey>("created_desc");
const isInternalUser = userRole === "Internal User";
useEffect(() => {
@ -192,26 +237,20 @@ const MCPServers: React.FC<MCPServerProps> = ({ 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<MCPServerProps> = ({ 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<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
</div>
</div>
<div className="w-full mt-6">
<DataTable
data={filteredServers}
columns={columns}
renderSubComponent={() => <div></div>}
getRowCanExpand={() => false}
isLoading={isLoadingServers}
noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started."
loadingMessage="Loading MCP servers..."
enableSorting={true}
<div className="mt-4 flex flex-wrap items-center gap-3">
<Input
allowClear
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Search by name, alias, URL, or ID"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ maxWidth: 320 }}
/>
<div className="flex items-center gap-2">
<Text className="whitespace-nowrap text-sm font-medium text-gray-600">Sort</Text>
<Select
value={sortKey}
onChange={(v: SortKey) => setSortKey(v)}
style={{ width: 220 }}
size="middle"
>
{SORT_OPTIONS.map((opt) => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</div>
<div className="ml-auto text-xs text-gray-500">
{displayedServers.length} of {filteredServers.length} servers
</div>
</div>
<div className="mt-4 w-full">
{isLoadingServers ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-gray-200 bg-white p-12">
<Spin tip="Loading MCP servers..." />
</div>
) : displayedServers.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 bg-white p-12">
<Empty
description={
filteredServers.length === 0
? "No MCP servers configured. Click '+ Add New MCP Server' to get started."
: "No servers match the current filters or search."
}
/>
</div>
) : (
<div
data-testid="mcp-servers-grid"
className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
>
{displayedServers.map((server) => (
<MCPServerCard
key={server.server_id}
server={server}
isLoadingHealth={isLoadingHealth}
isRechecking={recheckingServerIds?.has(server.server_id)}
onClick={() => {
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}
/>
))}
</div>
)}
</div>
</div>
)}

View file

@ -262,6 +262,41 @@ export interface MCPServer {
/** Per-user OAuth token storage settings (interactive OAuth only) */
token_validation?: Record<string, any> | 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 {

View file

@ -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<string>();
const out: MCPEnvVar[] = [];
for (const entry of list) {
if (!entry || typeof entry !== "object") continue;
const record = entry as Record<string, unknown>;
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;
};