From 28883ea80f95394ab4a91d01ccdbebd86ec99d2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 02:41:04 +0000 Subject: [PATCH] feat(mcp/ui): match env-vars UI to card-grid design from #28399 Adopts the desired UI from the prototype PR while keeping the working backend (bulk /user-env-vars/status, scope global/user): - Replace the MCP servers table with a card grid (MCPServerCard) plus search and sort. Per-user status renders as a red "N user fields missing / Set" footer on each card, driven by the bulk status endpoint (no per-card N+1 fetch). - Restyle EnvVarsSection as a purple 3-column editor (name / value / scope) with scope labeled Instance / Per-user; value disabled for per-user rows. Surface it as a top-level section in the create and edit forms instead of inside the collapsed Permission panel. - Restyle UserEnvVarsModal to match the prototype fill modal (Per-user tag, masked inputs, "Save Credentials"). - Revert the now-unused env-var chip in mcp_server_columns to baseline. https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh --- .../components/mcp_tools/EnvVarsSection.tsx | 161 ++++--- .../mcp_tools/MCPPermissionManagement.tsx | 3 - .../components/mcp_tools/MCPServerCard.tsx | 413 ++++++++++++++++++ .../components/mcp_tools/UserEnvVarsModal.tsx | 147 +++---- .../mcp_tools/create_mcp_server.tsx | 6 + .../mcp_tools/mcp_server_columns.tsx | 33 +- .../components/mcp_tools/mcp_server_edit.tsx | 6 + .../src/components/mcp_tools/mcp_servers.tsx | 241 +++++++--- 8 files changed, 787 insertions(+), 223 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx index fff99da93e4..02eed25a1ac 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx @@ -1,11 +1,15 @@ import React from "react"; -import { Form, Input, Select, Space, Button, Tooltip, Typography } from "antd"; -import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; +import { Form, Input, Select, Button, Tooltip, Typography } from "antd"; +import { + InfoCircleOutlined, + MinusCircleOutlined, + PlusOutlined, +} from "@ant-design/icons"; const { Text } = Typography; const SCOPE_OPTIONS = [ - { value: "global", label: "Global" }, + { value: "global", label: "Instance" }, { value: "user", label: "Per-user" }, ]; @@ -13,56 +17,60 @@ const SCOPE_OPTIONS = [ * Form section for admin-configured MCP environment variables. * * Each row has: name | value | scope. Variables can be interpolated into - * Static Headers via ${NAME}. ``scope=global`` values are used as-is. - * ``scope=user`` values are filled in by each user — the admin-entered - * value is just a placeholder/description. + * Static Headers via ${NAME}. ``scope=global`` (shown as "Instance") values + * are used as-is. ``scope=user`` (shown as "Per-user") values are filled in + * by each user via the MCP Gateway dashboard. * - * The parent form must render this inside a ``
`` and read the - * ``env_vars`` field from the form values. + * The parent form reads the ``env_vars`` field from the form values. */ const EnvVarsSection: React.FC = () => { return ( - +
+
+ Environment Variables - -
- Define variables that get interpolated into Static Headers via{" "} - {"${NAME}"} syntax. -
-
- Global: value is used for every user. -
-
- Per-user: each user fills in their own value via the - MCP Gateway dashboard. The value you enter here is shown to - the user as a placeholder/description. -
-
- } - > - - - - } - required={false} - > - - Reference these in Static Headers like{" "} - {"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@..."} + + + Define variables you can interpolate in Static Headers or + Authentication using {"${VAR_NAME}"}.
+ Instance: admin-defined value used for every user. +
+ Per-user: each user supplies their own value (e.g. personal + credentials) via the MCP Gateway dashboard. + + } + > + +
+
+ + Reference these in Static Headers or Authentication as{" "} + {"${VAR_NAME}"}. For example:{" "} + + {"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@${DB_HOSTNAME}"} + + {(fields, { add, remove }) => ( -
+
+ {fields.length > 0 && ( +
+
Variable Name
+
Value
+
Scope
+
+
+ )} {fields.map(({ key, name, ...restField }) => ( - +
{ message: "Use letters, digits, underscores; cannot start with a digit.", }, ]} - > - - - - + +
+ remove(name)} + className="text-gray-500 hover:text-red-500 cursor-pointer" + /> +
+
))}
)} - +
+ ); +}; + +// Disables the value field when scope=user (those values come from each +// user later), keeping the column visible so the row layout stays consistent. +const ValueField: React.FC<{ + fieldName: number; + value?: string; + onChange?: (v: string) => void; +}> = ({ fieldName, value, onChange }) => { + const scope = Form.useWatch(["env_vars", fieldName, "scope"]); + const isPerUser = scope === "user"; + return ( + onChange?.(e.target.value)} + placeholder={isPerUser ? "Defined per user" : "e.g. postgresql"} + disabled={isPerUser} + className="rounded-md font-mono" + /> ); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx index a131b5d3461..7c0aeff9516 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx @@ -2,7 +2,6 @@ import React, { useEffect } from "react"; import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd"; import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; import { MCPServer, AUTH_TYPE } from "./types"; -import EnvVarsSection from "./EnvVarsSection"; const { Panel } = Collapse; interface MCPPermissionManagementProps { @@ -283,8 +282,6 @@ const MCPPermissionManagement: React.FC = ({ )} - -
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx new file mode 100644 index 00000000000..017e8f0d51a --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx @@ -0,0 +1,413 @@ +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 = { + 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 = ({ + 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(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) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }; + + const menuItems: MenuProps["items"] = []; + if (onRecheckHealth) { + menuItems.push({ + key: "test-connection", + label: "Test Connection", + icon: , + 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: , + danger: true, + onClick: ({ domEvent }) => { + domEvent.stopPropagation(); + onDelete(); + }, + }); + } + + // Card uses role="button" + nested + + )} + + + {subtitle ? ( + + + {subtitle} + + + ) : ( + // Defensive placeholder: keep the row even when no identifier is + // available so the tag row stays vertically aligned across the grid. +
+ )} + +
+ + {displayTransport.toUpperCase()} + {authType} + + + + {isPublic ? "Public" : "Internal"} + + + {accessGroups.slice(0, 2).map((g) => ( + + {g} + + ))} + {accessGroups.length > 2 && ( + + +{accessGroups.length - 2} + + )} +
+ + {(server.is_byok || needsAttention) && ( +
+ {server.is_byok && ( + + )} + {needsAttention && ( +
+ +
Missing user fields:
+
    + {missing.map((m) => ( +
  • • {m}
  • + ))} +
+
+ } + > + + + {missing.length} user field + {missing.length === 1 ? "" : "s"} missing + + + {onOpenFillFields && ( + + )} +
+ )} +
+ )} + + ); +}; + +interface HealthChipProps { + status: string; + isLoadingHealth?: boolean; + isRechecking?: boolean; + onRecheck?: () => void; + lastCheck?: string | null; + error?: string | null; + dotClass: string; +} + +const HealthChip: FC = ({ + status, + isLoadingHealth, + isRechecking, + onRecheck, + lastCheck, + error, + dotClass, +}) => { + if (isLoadingHealth || isRechecking) { + return ( + + + + Checking + + + ); + } + const tooltip = ( +
+
Health: {status}
+ {lastCheck && ( +
+ Last check: {new Date(lastCheck).toLocaleString()} +
+ )} + {error && ( +
+
Error
+
{error}
+
+ )} + {!lastCheck && !error && ( +
No health data
+ )} + {onRecheck &&
Click to recheck
} +
+ ); + return ( + + { + e.stopPropagation(); + onRecheck(); + } + : undefined + } + > + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + + + ); +}; + +interface ByokRowProps { + connected: boolean; + onConnect?: () => void; +} + +const ByokRow: FC = ({ connected, onConnect }) => { + if (connected) { + return ( +
+ BYOK credential +
+ + Connected + + {onConnect && ( + + )} +
+
+ ); + } + return ( +
+ BYOK credential + {onConnect ? ( + + ) : ( + — + )} +
+ ); +}; + +export default MCPServerCard; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/UserEnvVarsModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/UserEnvVarsModal.tsx index c9d1190a4f9..17d1f57c44b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/UserEnvVarsModal.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/UserEnvVarsModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Modal, Form, Input, Button, Alert, Typography } from "antd"; +import { Modal, Form, Input, Button, Alert, Spin, Tag, Typography } from "antd"; import { MCPServer, MCPUserEnvVarsStatus } from "./types"; import { getMCPUserEnvVars, @@ -7,7 +7,7 @@ import { } from "../networking"; import NotificationsManager from "../molecules/notifications_manager"; -const { Text, Title, Paragraph } = Typography; +const { Text, Title } = Typography; interface UserEnvVarsModalProps { server: MCPServer | null; @@ -77,7 +77,7 @@ const UserEnvVarsModal: React.FC = ({ } const saved = await storeMCPUserEnvVars(accessToken, server.server_id, trimmed); setStatus(saved); - NotificationsManager.success("Environment variables saved"); + NotificationsManager.success("Credentials saved"); if (onSaved) onSaved(saved); onClose(); } catch (err) { @@ -89,86 +89,83 @@ const UserEnvVarsModal: React.FC = ({ } }; - const displayName = server?.alias || server?.server_name || server?.server_id || "MCP Server"; + const displayName = server?.server_name || server?.alias || server?.server_id || "MCP Server"; + const required = status?.required ?? []; return ( - - Set your credentials for {displayName} - - - These values are stored only for you and are injected into the MCP server's - request headers when you use it. - - - } open={open} onCancel={onClose} footer={null} - width={580} + width={520} destroyOnHidden - > - {status && status.required.length === 0 ? ( - - ) : ( - - {status?.missing_count ? ( - - ) : null} - - {(status?.required ?? []).map((spec) => ( - {spec.name}} - extra={spec.description || undefined} - rules={[{ required: true, message: `${spec.name} is required` }]} - > - - - ))} - - {(status?.required ?? []).length === 0 && !isLoading && ( - - No per-user variables required for this server. - - )} - -
- - + title={ +
+
+ + Set your credentials + + Per-user
- - )} + + {displayName} + +
+ } + > +
+ {isLoading ? ( +
+ +
+ ) : required.length === 0 ? ( + + ) : ( + <> + + These values are private to you. Your admin configured this MCP + server to require these per-user credentials: + +
+ {required.map((spec) => ( + + {spec.name} + + } + extra={spec.description || undefined} + rules={[{ required: true, message: `${spec.name} is required` }]} + > + + + ))} +
+ + +
+
+ + )} +
); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index eca94f4da8e..81ca2d0933b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -12,6 +12,7 @@ import StdioConfiguration from "./StdioConfiguration"; import MCPPermissionManagement from "./MCPPermissionManagement"; import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection"; import MCPLogoSelector from "./MCPLogoSelector"; +import EnvVarsSection from "./EnvVarsSection"; import { isAdminRole } from "@/utils/roles"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; @@ -1018,6 +1019,11 @@ const CreateMCPServer: React.FC = ({
+ {/* Environment Variables Section */} +
+ +
+ {/* Permission Management / Access Control Section */}
void, onRecheckHealth?: (serverId: string) => void, recheckingServerIds?: Set, - envVarStatusByServer?: Record, - onSetEnvVars?: (server: MCPServer) => void, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -115,15 +113,8 @@ export const mcpServerColumns = ( cell: ({ row }) => { const logoUrl = row.original.mcp_info?.logo_url; const name = row.original.server_name; - const status = envVarStatusByServer?.[row.original.server_id]; - const missing = status?.missing_count ?? 0; - const showWarning = missing > 0; return ( -
+
{logoUrl ? ( { (e.target as HTMLImageElement).style.display = "none"; }} /> ) : null} - {name} - {showWarning && ( - - - - )} + {name}
); }, 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 })
-
-
} - getRowCanExpand={() => false} - isLoading={isLoadingServers} - noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started." - loadingMessage="Loading MCP servers..." - enableSorting={true} +
+ } + placeholder="Search by name, alias, URL, or ID" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + style={{ maxWidth: 320 }} /> +
+ + Sort + + +
+
+ {displayedServers.length} of {filteredServers.length} servers +
+
+
+ {isLoadingServers ? ( +
+ +
+ ) : displayedServers.length === 0 ? ( +
+ +
+ ) : ( +
+ {displayedServers.map((server) => ( + { + setSelectedServerId(server.server_id); + setEditServer(true); + }} + onRecheckHealth={ + recheckServerHealth + ? () => recheckServerHealth(server.server_id) + : undefined + } + onByokConnect={ + server.is_byok ? () => setByokModalServer(server) : undefined + } + onOpenFillFields={() => setEnvVarsModalServer(server)} + onDelete={ + isAdminRole(userRole) + ? () => handleDelete(server.server_id) + : undefined + } + /> + ))} +
+ )}
)} @@ -510,12 +644,15 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) /> )} + {/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */} setEnvVarsModalServer(null)} onSaved={() => { + // Refresh the bulk status so the red "N user fields missing" footer + // on each card clears once the user has filled in their values. refetchEnvVarStatus(); }} />