);
},
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
index fedbb535736..f7adfc06d0c 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
@@ -9,6 +9,7 @@ import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPLogoSelector from "./MCPLogoSelector";
+import EnvVarsSection from "./EnvVarsSection";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
@@ -1112,6 +1113,11 @@ const MCPServerEdit: React.FC
= ({
>
)}
+ {/* Environment Variables Section */}
+
+
+
+
{/* Permission Management / Access Control Section */}
= {
+ 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";
@@ -66,10 +113,15 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID })
const [prefillData, setPrefillData] = useState(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState(null);
+ // Per-user env-var fill modal target + bulk status across accessible servers.
const [envVarsModalServer, setEnvVarsModalServer] = useState(null);
const [envVarStatusByServer, setEnvVarStatusByServer] = useState>({});
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortKey, setSortKey] = useState("created_desc");
const isInternalUser = userRole === "Internal User";
+ // Single bulk fetch of this user's per-server env-var status. Drives the
+ // red "N user fields missing" footer on each card with no per-row request.
const refetchEnvVarStatus = useCallback(async () => {
if (!accessToken) {
setEnvVarStatusByServer({});
@@ -91,26 +143,38 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID })
refetchEnvVarStatus();
}, [refetchEnvVarStatus, mcpServers]);
- // Deep-link support: open the modal automatically when the URL contains
- // ?fill_env_vars=. This is the link users follow from the
- // friendly error returned by the proxy when a per-user var is missing.
- useEffect(() => {
- if (typeof window === "undefined" || !mcpServers) {
- return;
+ // Per-server list of per-user fields this user still needs to fill in.
+ const missingFieldsByServer = useMemo(() => {
+ const map: Record = {};
+ for (const [serverId, status] of Object.entries(envVarStatusByServer)) {
+ map[serverId] = (status.required ?? [])
+ .filter((spec) => !spec.is_set)
+ .map((spec) => spec.name);
}
+ return map;
+ }, [envVarStatusByServer]);
+
+ // Deep-link via ?fill_env_vars= — the link users follow from the
+ // friendly error the proxy returns when a per-user var is missing. Opens the
+ // fill modal for the matching server, then strips the param.
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ if (!serversWithHealth || serversWithHealth.length === 0) return;
const params = new URLSearchParams(window.location.search);
const targetId = params.get("fill_env_vars");
if (!targetId) return;
- const target = mcpServers.find((s) => s.server_id === targetId);
- if (target) {
- setEnvVarsModalServer(target);
- // Strip the query param so the modal doesn't re-open on every render.
+ const match = serversWithHealth.find((s) => s.server_id === targetId);
+ if (match) {
+ setEnvVarsModalServer(match);
params.delete("fill_env_vars");
- const cleaned = params.toString();
- const newUrl = `${window.location.pathname}${cleaned ? `?${cleaned}` : ""}${window.location.hash}`;
- window.history.replaceState(null, "", newUrl);
+ const newSearch = params.toString();
+ const newUrl =
+ window.location.pathname +
+ (newSearch ? `?${newSearch}` : "") +
+ window.location.hash;
+ window.history.replaceState({}, "", newUrl);
}
- }, [mcpServers]);
+ }, [serversWithHealth]);
useEffect(() => {
if (typeof window === "undefined") {
@@ -202,28 +266,25 @@ 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,
- envVarStatusByServer,
- (server: MCPServer) => setEnvVarsModalServer(server),
- ),
- [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, envVarStatusByServer],
- );
+ // 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);
@@ -238,6 +299,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);
@@ -462,17 +531,82 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID })