mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
37d6047884
commit
8b0c33a585
4 changed files with 582 additions and 35 deletions
383
ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx
Normal file
383
ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue