feat: redesign MCP servers list as card grid with search and sort

- Replace MCP servers DataTable with a responsive card grid (MCPServerCard) showing logo, name, alias, transport-aware subtitle, health status, auth type, visibility, and access groups
- Add search input (name/alias/URL/ID) and sort controls (created, updated, name, health) above the grid, with result count and empty/loading states
- Introduce host-based logo guessing via a curated WELL_KNOWN_LOGOS registry in utils, shared by MCPLogoSelector and the create/edit forms to auto-suggest logos from server URLs without overwriting manual picks
- Prefill logo from discovery icon_url on curated server creation
- Add Delete Server button to MCPServerView header and auto-open the Settings tab when entering edit mode
- Simplify MCPLogoSelector by removing the separate preview banner; selection is now shown via the highlighted grid tile, with custom URL input populated for non-registry values
- Add unit tests for guessLogoFromUrl and update MCPLogoSelector tests for the new selection UX
- Ignore generated tsconfig.tsbuildinfo
This commit is contained in:
mateo-berri 2026-05-20 19:00:08 -07:00
parent 745379e401
commit b2e25b3915
11 changed files with 907 additions and 135 deletions

2
.gitignore vendored
View file

@ -120,4 +120,4 @@ crash.log
crash.*.log
# .terraform.lock.hcl is intentionally NOT ignored — it pins provider versions
# and should be committed.
.vscode
.vscodeui/litellm-dashboard/tsconfig.tsbuildinfo

View file

@ -14,23 +14,27 @@ describe("MCPLogoSelector", () => {
expect(screen.getByPlaceholderText(/paste a custom logo URL/i)).toBeInTheDocument();
});
it("should show a preview when a value is provided", () => {
it("should show the selected grid tile with a highlighted state", () => {
render(<MCPLogoSelector value="/ui/assets/logos/github.svg" />);
expect(screen.getByAltText("Selected logo")).toBeInTheDocument();
// Selection is conveyed by the blue border on the matching tile button,
// not a separate preview banner. We check via the alt text on the inner
// <img>, which equals the logo name.
const githubImg = screen.getByAltText("GitHub");
const tileButton = githubImg.closest("button");
expect(tileButton).not.toBeNull();
expect(tileButton?.className).toContain("border-blue-500");
});
it("should not show a preview when no value is provided", () => {
render(<MCPLogoSelector />);
expect(screen.queryByAltText("Selected logo")).not.toBeInTheDocument();
it("should leave the custom URL input empty when a grid logo is selected", () => {
render(<MCPLogoSelector value="/ui/assets/logos/github.svg" />);
expect(screen.getByPlaceholderText(/paste a custom logo URL/i)).toHaveValue("");
});
it("should call onChange with undefined when the clear button is clicked", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<MCPLogoSelector value="/ui/assets/logos/github.svg" onChange={onChange} />);
await user.click(screen.getByRole("button", { name: /✕/ }));
expect(onChange).toHaveBeenCalledWith(undefined);
it("should populate the custom URL input when value is not in the grid", () => {
render(<MCPLogoSelector value="https://cdn.example.com/custom.png" />);
expect(screen.getByPlaceholderText(/paste a custom logo URL/i)).toHaveValue(
"https://cdn.example.com/custom.png",
);
});
it("should call onChange with the logo URL when a grid logo is clicked", async () => {

View file

@ -1,31 +1,7 @@
import React, { useState } from "react";
import { Input, Tooltip } from "antd";
import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons";
const logos = "/ui/assets/logos/";
const WELL_KNOWN_LOGOS: { name: string; url: string }[] = [
{ name: "GitHub", url: `${logos}github.svg` },
{ name: "Slack", url: `${logos}slack.svg` },
{ name: "Notion", url: `${logos}notion.svg` },
{ name: "Linear", url: `${logos}linear.svg` },
{ name: "Jira", url: `${logos}jira.svg` },
{ name: "Figma", url: `${logos}figma.svg` },
{ name: "Gmail", url: `${logos}gmail.svg` },
{ name: "Google Drive", url: `${logos}google_drive.svg` },
{ name: "Stripe", url: `${logos}stripe.svg` },
{ name: "Shopify", url: `${logos}shopify.svg` },
{ name: "Salesforce", url: `${logos}salesforce.svg` },
{ name: "HubSpot", url: `${logos}hubspot.svg` },
{ name: "Twilio", url: `${logos}twilio.svg` },
{ name: "Cloudflare", url: `${logos}cloudflare.svg` },
{ name: "Sentry", url: `${logos}sentry.svg` },
{ name: "PostgreSQL", url: `${logos}postgresql.svg` },
{ name: "Snowflake", url: `${logos}snowflake.svg` },
{ name: "Zapier", url: `${logos}zapier.svg` },
{ name: "Google", url: `${logos}google.svg` },
{ name: "GitLab", url: `${logos}gitlab.svg` },
];
import { WELL_KNOWN_LOGOS } from "./utils";
interface MCPLogoSelectorProps {
value?: string;
@ -52,28 +28,6 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
</Tooltip>
</div>
{/* Preview */}
{value && (
<div className="flex items-center gap-3 mb-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<img
src={value}
alt="Selected logo"
className="w-10 h-10 object-contain rounded"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-500 truncate">{value}</div>
</div>
<button
type="button"
onClick={() => onChange?.(undefined)}
className="text-xs text-gray-400 hover:text-red-500 cursor-pointer bg-transparent border-none"
>
✕
</button>
</div>
)}
{/* Well-known logo grid */}
<div className="grid grid-cols-10 gap-1.5 mb-3">
{WELL_KNOWN_LOGOS.map((logo) => {

View file

@ -0,0 +1,429 @@
import { useEffect, 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";
import {
getEnvVarDefinitions,
getMissingUserFields,
subscribeEnvVarsChanged,
} from "./mock/mockMcpEnvVars";
const { Text } = Typography;
interface MCPServerCardProps {
server: MCPServer;
userID: 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,
userID,
isLoadingHealth,
isRechecking,
onClick,
onRecheckHealth,
onByokConnect,
onOpenFillFields,
onDelete,
}) => {
// Re-render whenever the mock env var store changes, so the missing-fields
// badge updates as soon as the user fills in values in the modal.
const [, setEnvTick] = useState(0);
useEffect(
() => subscribeEnvVarsChanged(() => setEnvTick((t) => t + 1)),
[],
);
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`.
// We deliberately do not guess from the server name/alias — that's brittle
// (renames break the icon, slug arithmetic 404s on unknown brands). The
// create/edit form auto-fills `logo_url` from discovery `icon_url` and
// from a host-keyed lookup so the common cases don't require manual work.
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 envDefs = alias ? getEnvVarDefinitions(alias) : [];
const perUserCount = envDefs.filter((d) => d.scope === "per_user").length;
const missingUserFields = alias ? getMissingUserFields(alias, userID) : [];
const needsAttention = perUserCount > 0 && missingUserFields.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">
{missingUserFields.map((m) => (
<li key={m}>• {m}</li>
))}
</ul>
</div>
}
>
<span className="inline-flex items-center gap-1 font-semibold text-red-700">
<ExclamationCircleFilled />
{missingUserFields.length} user field
{missingUserFields.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

@ -19,7 +19,11 @@ import {
EnvVarDefinition,
} from "./mock/mockMcpEnvVars";
import { isAdminRole } from "@/utils/roles";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import {
validateMCPServerUrl,
validateMCPServerName,
guessLogoFromUrl,
} from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { useTestMCPConnection } from "@/hooks/useTestMCPConnection";
@ -258,6 +262,12 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
transport: transport,
};
// Curated discovery entries carry their own icon — use it as the default
// logo so the user doesn't have to re-pick one from the grid.
if (prefillData.icon_url) {
setLogoUrl(prefillData.icon_url);
}
if (transport === "stdio") {
const stdioObj: Record<string, any> = {};
if (prefillData.command) stdioObj.command = prefillData.command;
@ -524,6 +534,17 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
}, [formValues.server_name]);
// Suggest a logo from the URL host when the admin hasn't picked one yet.
// Driven by an explicit host allow-list (see WELL_KNOWN_LOGOS in utils),
// not slug arithmetic on the server name — so renaming the server later
// can't change the icon. Only fires when `logoUrl` is unset, so it never
// clobbers a manually selected logo or a discovery prefill.
React.useEffect(() => {
if (logoUrl) return;
const suggested = guessLogoFromUrl(formValues.url);
if (suggested) setLogoUrl(suggested);
}, [formValues.url, logoUrl]);
// Clear formValues when modal closes to reset child components
React.useEffect(() => {
if (!isModalVisible) {

View file

@ -9,7 +9,11 @@ import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPLogoSelector from "./MCPLogoSelector";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import {
validateMCPServerUrl,
validateMCPServerName,
guessLogoFromUrl,
} from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
@ -60,6 +64,17 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
// Watch form fields that affect tool fetching
const currentUrl = Form.useWatch("url", form);
// Suggest a logo from the URL host only when the admin hasn't picked one
// (e.g. an existing server saved without a logo, where the user later
// edits the URL). Never overwrites an existing pick — see create form
// for the same pattern keyed on the explicit WELL_KNOWN_LOGOS host list.
useEffect(() => {
if (logoUrl) return;
const suggested = guessLogoFromUrl(currentUrl);
if (suggested) setLogoUrl(suggested);
}, [currentUrl, logoUrl]);
const currentSpecPath = Form.useWatch("spec_path", form);
const currentServerName = Form.useWatch("server_name", form);
const currentAuthType = Form.useWatch("auth_type", form);
@ -1115,7 +1130,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
/>
</div>
<div className="flex justify-end gap-2">
<div className="mt-6 flex justify-end gap-2">
<AntdButton onClick={onCancel}>Cancel</AntdButton>
<Button type="submit">Save Changes</Button>
</div>

View file

@ -11,6 +11,7 @@ import { getMaskedAndFullUrl } from "./utils";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Button as AntdButton } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
interface MCPServerViewProps {
mcpServer: MCPServer;
@ -21,6 +22,7 @@ interface MCPServerViewProps {
userRole: string | null;
userID: string | null;
availableAccessGroups: string[];
onDelete?: (serverId: string) => void;
}
export const MCPServerView: React.FC<MCPServerViewProps> = ({
@ -32,11 +34,18 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
userRole,
userID,
availableAccessGroups,
onDelete,
}) => {
const [editing, setEditing] = useState(isEditing);
const [showFullUrl, setShowFullUrl] = useState(false);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
// When the caller opens this view directly into edit mode, jump to the
// Settings tab (index 2) so the edit form and the Danger zone are visible
// without an extra click. Non-admins don't see the Settings tab, so they
// stay on Overview.
const [selectedTabIndex, setSelectedTabIndex] = useState(
isEditing && isProxyAdmin ? 2 : 0,
);
const handleSuccess = (updated: MCPServer) => {
setEditing(false);
@ -77,40 +86,53 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
<Button icon={ArrowLeftIcon} variant="light" className="mb-4" onClick={onBack}>
Back to All Servers
</Button>
<div className="flex items-center gap-2">
<Title className="text-2xl">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</Title>
<AntdButton
type="text"
size="small"
icon={copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(mcpServer.server_name || mcpServer.alias, "mcp-server_name")}
className={`transition-all duration-200 ${copiedStates["mcp-server_name"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
}`}
/>
{mcpServer.alias && mcpServer.server_name && mcpServer.alias !== mcpServer.server_name && (
<span className="ml-2 inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-600 border border-gray-200 font-mono">
{mcpServer.alias}
</span>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Title className="text-2xl">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</Title>
<AntdButton
type="text"
size="small"
icon={copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
onClick={() => copyToClipboard(mcpServer.server_name || mcpServer.alias, "mcp-server_name")}
className={`transition-all duration-200 ${copiedStates["mcp-server_name"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
}`}
/>
{mcpServer.alias && mcpServer.server_name && mcpServer.alias !== mcpServer.server_name && (
<span className="ml-2 inline-flex items-center text-xs font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-600 border border-gray-200 font-mono">
{mcpServer.alias}
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-1">
<Text className="text-gray-400 font-mono text-xs">{mcpServer.server_id}</Text>
<AntdButton
type="text"
size="small"
icon={copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />}
onClick={() => copyToClipboard(mcpServer.server_id, "mcp-server-id")}
className={`transition-all duration-200 ${copiedStates["mcp-server-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-300 hover:text-gray-500 hover:bg-gray-50"
}`}
/>
</div>
{mcpServer.description && (
<Text className="text-gray-500 mt-2">{mcpServer.description}</Text>
)}
</div>
{isProxyAdmin && onDelete && (
<AntdButton
danger
icon={<DeleteOutlined />}
onClick={() => onDelete(mcpServer.server_id)}
>
Delete Server
</AntdButton>
)}
</div>
<div className="flex items-center gap-1.5 mt-1">
<Text className="text-gray-400 font-mono text-xs">{mcpServer.server_id}</Text>
<AntdButton
type="text"
size="small"
icon={copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />}
onClick={() => copyToClipboard(mcpServer.server_id, "mcp-server-id")}
className={`transition-all duration-200 ${copiedStates["mcp-server-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-300 hover:text-gray-500 hover:bg-gray-50"
}`}
/>
</div>
{mcpServer.description && (
<Text className="text-gray-500 mt-2">{mcpServer.description}</Text>
)}
</div>
{/* TODO: magic number for index */}

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";
@ -24,6 +23,54 @@ import { getSecureItem } from "@/utils/secureStorage";
import FillUserFieldsModal from "./mock/FillUserFieldsModal";
import MockClaudeCodeModal from "./mock/MockClaudeCodeModal";
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";
@ -69,6 +116,8 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
// PROTOTYPE: state for the per-user fields demo
const [fillFieldsServer, setFillFieldsServer] = useState<MCPServer | null>(null);
const [mockDemoServer, setMockDemoServer] = useState<MCPServer | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");
const [sortKey, setSortKey] = useState<SortKey>("created_desc");
const isInternalUser = userRole === "Internal User";
// PROTOTYPE: deep-link via ?fill_fields=<alias> (used by the mock Claude
@ -185,29 +234,25 @@ 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,
userID ?? "",
(server: MCPServer) => setFillFieldsServer(server),
(server: MCPServer) => setMockDemoServer(server),
),
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, userID],
);
// 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);
@ -402,6 +447,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
userID={userID}
userRole={userRole}
availableAccessGroups={uniqueMcpAccessGroups}
onDelete={handleDelete}
/>
) : (
<div className="w-full h-full">
@ -446,17 +492,82 @@ 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 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}
userID={userID || ""}
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
}
onOpenFillFields={() => setFillFieldsServer(server)}
onDelete={
isAdminRole(userRole)
? () => handleDelete(server.server_id)
: undefined
}
/>
))}
</div>
)}
</div>
</div>
)}

View file

@ -5,6 +5,7 @@ import {
getMaskedAndFullUrl,
validateMCPServerUrl,
validateMCPServerName,
guessLogoFromUrl,
} from "./utils";
describe("extractMCPToken", () => {
@ -73,3 +74,56 @@ describe("validateMCPServerName", () => {
await expect(validateMCPServerName("my server")).rejects.toBeDefined();
});
});
describe("guessLogoFromUrl", () => {
it("should match an exact host against the registry", () => {
expect(guessLogoFromUrl("https://github.com/org/repo")).toBe(
"/ui/assets/logos/github.svg",
);
expect(guessLogoFromUrl("https://figma.com")).toBe(
"/ui/assets/logos/figma.svg",
);
});
it("should match wildcard subdomains", () => {
expect(guessLogoFromUrl("https://api.github.com/user")).toBe(
"/ui/assets/logos/github.svg",
);
expect(guessLogoFromUrl("https://acme.atlassian.net/jira/api")).toBe(
"/ui/assets/logos/jira.svg",
);
expect(guessLogoFromUrl("https://api.linear.app/graphql")).toBe(
"/ui/assets/logos/linear.svg",
);
expect(guessLogoFromUrl("https://shop.myshopify.com")).toBe(
"/ui/assets/logos/shopify.svg",
);
});
it("should be case-insensitive on the host", () => {
expect(guessLogoFromUrl("https://API.GitHub.com")).toBe(
"/ui/assets/logos/github.svg",
);
});
it("should return undefined for unknown hosts", () => {
expect(
guessLogoFromUrl("https://internal-tools.example.com"),
).toBeUndefined();
expect(guessLogoFromUrl("https://localhost:4000/mcp")).toBeUndefined();
});
it("should return undefined for unparseable URLs and empty input", () => {
expect(guessLogoFromUrl(undefined)).toBeUndefined();
expect(guessLogoFromUrl(null)).toBeUndefined();
expect(guessLogoFromUrl("")).toBeUndefined();
expect(guessLogoFromUrl("not a url")).toBeUndefined();
});
it("should not match a wildcard pattern against an unrelated host", () => {
// `*.github.com` must not match `github.com.evil.example`.
expect(
guessLogoFromUrl("https://github.com.evil.example/path"),
).toBeUndefined();
});
});

View file

@ -51,3 +51,166 @@ export const validateMCPServerName = (value: string) => {
? Promise.reject("Cannot contain '-' (hyphen) or spaces. Please use '_' (underscore) instead.")
: Promise.resolve();
};
// Local SVG asset directory used for the curated logo set. Kept as a
// constant so MCPLogoSelector and any URL-based guesser stay in sync.
export const LOGOS_DIR = "/ui/assets/logos/";
export interface WellKnownLogo {
/** Display name shown in tooltips / pickers. */
name: string;
/** Local SVG asset URL (served from /ui/assets/logos/). */
url: string;
/**
* Hostnames whose URL should pre-select this logo at create/edit time.
* Each entry is matched as an exact host or a `*.suffix` wildcard. The
* list is small and explicit on purpose — no slug arithmetic, no
* third-party CDN lookups, no surprise 404s. Add a host here when a
* brand reliably exposes its API on it.
*/
hosts?: ReadonlyArray<string>;
}
/**
* Curated logo registry. Single source of truth shared by `MCPLogoSelector`
* (the picker grid) and `guessLogoFromUrl` (the create-form host lookup).
*/
export const WELL_KNOWN_LOGOS: ReadonlyArray<WellKnownLogo> = [
{
name: "GitHub",
url: `${LOGOS_DIR}github.svg`,
hosts: ["github.com", "*.github.com", "*.githubusercontent.com"],
},
{
name: "GitLab",
url: `${LOGOS_DIR}gitlab.svg`,
hosts: ["gitlab.com", "*.gitlab.com"],
},
{
name: "Slack",
url: `${LOGOS_DIR}slack.svg`,
hosts: ["slack.com", "*.slack.com"],
},
{
name: "Notion",
url: `${LOGOS_DIR}notion.svg`,
hosts: ["notion.com", "*.notion.com", "notion.so", "*.notion.so"],
},
{
name: "Linear",
url: `${LOGOS_DIR}linear.svg`,
hosts: ["linear.app", "*.linear.app"],
},
{
name: "Jira",
url: `${LOGOS_DIR}jira.svg`,
hosts: ["*.atlassian.net", "*.atlassian.com", "atlassian.com"],
},
{
name: "Figma",
url: `${LOGOS_DIR}figma.svg`,
hosts: ["figma.com", "*.figma.com"],
},
{
name: "Gmail",
url: `${LOGOS_DIR}gmail.svg`,
hosts: ["mail.google.com"],
},
{
name: "Google Drive",
url: `${LOGOS_DIR}google_drive.svg`,
hosts: ["drive.google.com"],
},
{
name: "Google",
url: `${LOGOS_DIR}google.svg`,
hosts: ["google.com", "*.googleapis.com"],
},
{
name: "Stripe",
url: `${LOGOS_DIR}stripe.svg`,
hosts: ["stripe.com", "*.stripe.com"],
},
{
name: "Shopify",
url: `${LOGOS_DIR}shopify.svg`,
hosts: ["shopify.com", "*.shopify.com", "*.myshopify.com"],
},
{
name: "Salesforce",
url: `${LOGOS_DIR}salesforce.svg`,
hosts: ["salesforce.com", "*.salesforce.com", "*.force.com"],
},
{
name: "HubSpot",
url: `${LOGOS_DIR}hubspot.svg`,
hosts: ["hubspot.com", "*.hubspot.com", "*.hubapi.com"],
},
{
name: "Twilio",
url: `${LOGOS_DIR}twilio.svg`,
hosts: ["twilio.com", "*.twilio.com"],
},
{
name: "Cloudflare",
url: `${LOGOS_DIR}cloudflare.svg`,
hosts: ["cloudflare.com", "*.cloudflare.com"],
},
{
name: "Sentry",
url: `${LOGOS_DIR}sentry.svg`,
hosts: ["sentry.io", "*.sentry.io"],
},
{
name: "PostgreSQL",
url: `${LOGOS_DIR}postgresql.svg`,
},
{
name: "Snowflake",
url: `${LOGOS_DIR}snowflake.svg`,
hosts: ["*.snowflakecomputing.com"],
},
{
name: "Zapier",
url: `${LOGOS_DIR}zapier.svg`,
hosts: ["zapier.com", "*.zapier.com"],
},
];
const matchesHostPattern = (host: string, pattern: string): boolean => {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(2).toLowerCase();
const lower = host.toLowerCase();
return lower === suffix || lower.endsWith(`.${suffix}`);
}
return host.toLowerCase() === pattern.toLowerCase();
};
/**
* Best-effort logo suggestion based on the upstream URL host.
*
* Used at create/edit time to pre-select a logo from the server URL the
* admin typed (e.g. `https://api.github.com/...` → GitHub). The match
* table is the explicit `WELL_KNOWN_LOGOS.hosts` list — no slug
* arithmetic on the server's name, no third-party CDN lookups. Returns
* `undefined` when the URL is unparseable or its host isn't in the
* registry; the caller should leave the logo unset in that case.
*/
export const guessLogoFromUrl = (
url: string | null | undefined,
): string | undefined => {
if (!url) return undefined;
let host: string;
try {
host = new URL(url).hostname;
} catch {
return undefined;
}
if (!host) return undefined;
for (const logo of WELL_KNOWN_LOGOS) {
if (logo.hosts?.some((p) => matchesHostPattern(host, p))) {
return logo.url;
}
}
return undefined;
};

File diff suppressed because one or more lines are too long