feat(ui): add MCP server logo support across admin and chat UIs

- New MCPLogoSelector component with grid of well-known logos (GitHub,
  Slack, Notion, Linear, Jira, etc.) and custom URL input
- Create MCP Server form: logo picker with preview, OpenAPI presets
  auto-fill logo from registry icon_url
- Edit MCP Server form: logo picker pre-populated from mcp_info.logo_url
- Admin table: logos rendered next to server name in Name column
- Chat MCPAppsPanel: logos on server cards (list + detail view) with
  graceful fallback to letter avatars
- Chat MCPConnectPicker: logos next to server names in toggle list
- Fix pre-existing bug: setTools -> clearTools in create form cancel
- All 321 vitest files / 3211 tests pass

Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-03-11 00:12:05 +00:00
parent 3bf91ed9fe
commit bddbf155d3
7 changed files with 206 additions and 3 deletions

View file

@ -170,9 +170,25 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{/* Avatar + name + connect */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 20, marginBottom: 28 }}>
{detailServer.mcp_info?.logo_url ? (
<img
src={detailServer.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 64, height: 64, borderRadius: 16,
objectFit: "contain", flexShrink: 0,
background: "#f9fafb",
}}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
}}
/>
) : null}
<div style={{
width: 64, height: 64, borderRadius: 16,
background: color, display: "flex",
background: color, display: detailServer.mcp_info?.logo_url ? "none" : "flex",
alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 28, flexShrink: 0,
}}>
@ -351,9 +367,26 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }}
>
{server.mcp_info?.logo_url ? (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 38, height: 38, borderRadius: 10,
objectFit: "contain", flexShrink: 0,
background: "#f9fafb",
}}
onError={(e) => {
const el = e.target as HTMLImageElement;
el.style.display = "none";
if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex";
}}
/>
) : null}
<div style={{
width: 38, height: 38, borderRadius: 10, background: color,
display: "flex", alignItems: "center", justifyContent: "center",
display: server.mcp_info?.logo_url ? "none" : "flex",
alignItems: "center", justifyContent: "center",
color: "#fff", fontWeight: 700, fontSize: 16, flexShrink: 0,
}}>
{name.charAt(0).toUpperCase()}

View file

@ -112,6 +112,18 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
gap: 12,
}}
>
{server.mcp_info?.logo_url && (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
style={{
width: 24, height: 24, borderRadius: 6,
objectFit: "contain", flexShrink: 0,
marginTop: 1,
}}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{

View file

@ -0,0 +1,121 @@
import React, { useState } from "react";
import { Input, Tooltip } from "antd";
import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons";
const WELL_KNOWN_LOGOS: { name: string; url: string }[] = [
{ name: "GitHub", url: "https://cdn.simpleicons.org/github" },
{ name: "Slack", url: "https://cdn.simpleicons.org/slack" },
{ name: "Notion", url: "https://cdn.simpleicons.org/notion" },
{ name: "Linear", url: "https://cdn.simpleicons.org/linear" },
{ name: "Jira", url: "https://cdn.simpleicons.org/jira" },
{ name: "Figma", url: "https://cdn.simpleicons.org/figma" },
{ name: "Gmail", url: "https://cdn.simpleicons.org/gmail" },
{ name: "Google Drive", url: "https://cdn.simpleicons.org/googledrive" },
{ name: "Stripe", url: "https://cdn.simpleicons.org/stripe" },
{ name: "Shopify", url: "https://cdn.simpleicons.org/shopify" },
{ name: "Salesforce", url: "https://cdn.simpleicons.org/salesforce" },
{ name: "HubSpot", url: "https://cdn.simpleicons.org/hubspot" },
{ name: "Twilio", url: "https://cdn.simpleicons.org/twilio" },
{ name: "Cloudflare", url: "https://cdn.simpleicons.org/cloudflare" },
{ name: "Sentry", url: "https://cdn.simpleicons.org/sentry" },
{ name: "PostgreSQL", url: "https://cdn.simpleicons.org/postgresql" },
{ name: "Snowflake", url: "https://cdn.simpleicons.org/snowflake" },
{ name: "Zapier", url: "https://cdn.simpleicons.org/zapier" },
{ name: "Confluence", url: "https://cdn.simpleicons.org/confluence" },
{ name: "GitLab", url: "https://cdn.simpleicons.org/gitlab" },
];
interface MCPLogoSelectorProps {
value?: string;
onChange?: (url: string | undefined) => void;
}
const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) => {
const [imgErrors, setImgErrors] = useState<Set<string>>(new Set());
const handleSelect = (url: string) => {
onChange?.(value === url ? undefined : url);
};
const handleImgError = (url: string) => {
setImgErrors((prev) => new Set(prev).add(url));
};
return (
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-700">Logo</span>
<Tooltip title="Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.">
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
</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) => {
const isSelected = value === logo.url;
const hasFailed = imgErrors.has(logo.url);
if (hasFailed) return null;
return (
<Tooltip key={logo.name} title={logo.name}>
<button
type="button"
onClick={() => handleSelect(logo.url)}
className={`flex items-center justify-center p-2 rounded-lg border transition-all cursor-pointer
${isSelected
? "border-blue-500 bg-blue-50 shadow-sm"
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
}`}
style={{ width: 40, height: 40 }}
>
<img
src={logo.url}
alt={logo.name}
className="w-5 h-5 object-contain"
onError={() => handleImgError(logo.url)}
/>
</button>
</Tooltip>
);
})}
</div>
{/* Custom URL input */}
<Input
prefix={<LinkOutlined className="text-gray-400" />}
placeholder="Or paste a custom logo URL..."
value={value && !WELL_KNOWN_LOGOS.some((l) => l.url === value) ? value : ""}
onChange={(e) => {
const v = e.target.value.trim();
onChange?.(v || undefined);
}}
className="rounded-lg"
size="small"
/>
</div>
);
};
export default MCPLogoSelector;

View file

@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps {
onValuesChange: (updates: Record<string, any>) => void;
/** Called when key tools change (from registry preset selection). */
onKeyToolsChange?: (tools: OpenAPIKeyTool[]) => void;
/** Called when a preset is selected so the parent can set the logo URL from icon_url. */
onLogoUrlChange?: (url: string | undefined) => void;
}
/**
@ -24,12 +26,14 @@ const OpenAPIFormSection: React.FC<OpenAPIFormSectionProps> = ({
accessToken,
onValuesChange,
onKeyToolsChange,
onLogoUrlChange,
}) => {
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const handlePresetSelect = (entry: OpenAPIRegistryEntry) => {
setSelectedPreset(entry.name);
onKeyToolsChange?.(entry.key_tools ?? []);
onLogoUrlChange?.(entry.icon_url || undefined);
const updates: Record<string, any> = {
spec_path: entry.spec_url,
};

View file

@ -11,6 +11,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPPermissionManagement from "./MCPPermissionManagement";
import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
import MCPLogoSelector from "./MCPLogoSelector";
import { isAdminRole } from "@/utils/roles";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
@ -61,6 +62,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const [keyTools, setKeyTools] = useState<OpenAPIKeyTool[]>([]);
const [searchValue, setSearchValue] = useState<string>("");
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
// Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests.
const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = useTestMCPConnection({
@ -91,6 +93,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
allowedTools,
searchValue,
aliasManuallyEdited,
logoUrl,
}),
);
} catch (err) {
@ -197,6 +200,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
if (typeof parsed.aliasManuallyEdited === "boolean") {
setAliasManuallyEdited(parsed.aliasManuallyEdited);
}
if (parsed.logoUrl) {
setLogoUrl(parsed.logoUrl);
}
} catch (err) {
console.error("Failed to restore MCP create state", err);
} finally {
@ -361,6 +367,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
mcp_info: {
server_name: restValues.server_name || restValues.url,
description: restValues.description,
logo_url: logoUrl || undefined,
mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null,
},
mcp_access_groups: accessGroups,
@ -398,6 +405,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
clearTools();
setAllowedTools([]);
setAliasManuallyEdited(false);
setLogoUrl(undefined);
setModalVisible(false);
onCreateSuccess(response);
}
@ -415,9 +423,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const handleCancel = () => {
form.resetFields();
setCostConfig({});
setTools([]);
clearTools();
setAllowedTools([]);
setAliasManuallyEdited(false);
setLogoUrl(undefined);
setModalVisible(false);
};
@ -594,6 +603,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
/>
</Form.Item>
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
<Form.Item
label={<span className="text-sm font-medium text-gray-700">GitHub / Source URL</span>}
name="source_url"
@ -649,6 +660,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
setFormValues((prev) => ({ ...prev, ...updates }))
}
onKeyToolsChange={setKeyTools}
onLogoUrlChange={setLogoUrl}
/>
)}

View file

@ -31,6 +31,23 @@ export const mcpServerColumns = (
accessorKey: "server_name",
header: "Name",
enableSorting: true,
cell: ({ row }) => {
const logoUrl = row.original.mcp_info?.logo_url;
const name = row.original.server_name;
return (
<div className="flex items-center gap-2">
{logoUrl ? (
<img
src={logoUrl}
alt={`${name ?? "MCP"} logo`}
className="h-5 w-5 rounded object-contain flex-shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : null}
<span>{name}</span>
</div>
);
},
},
{
accessorKey: "alias",

View file

@ -8,6 +8,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPLogoSelector from "./MCPLogoSelector";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
@ -41,6 +42,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const [toolNameToDisplayName, setToolNameToDisplayName] = useState<Record<string, string>>({});
const [toolNameToDescription, setToolNameToDescription] = useState<Record<string, string>>({});
const [pendingRestoredValues, setPendingRestoredValues] = useState<Record<string, any> | null>(null);
const [logoUrl, setLogoUrl] = useState<string | undefined>(mcpServer.mcp_info?.logo_url || undefined);
const authType = Form.useWatch("auth_type", form) as string | undefined;
const transportType = Form.useWatch("transport", form) as string | undefined;
const isStdioTransport = transportType === "stdio";
@ -538,6 +540,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
mcp_info: {
server_name: mcpInfoServerName,
description: restValues.description,
logo_url: logoUrl || undefined,
mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null,
},
mcp_access_groups: accessGroups,
@ -604,6 +607,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<Form.Item label="Description" name="description">
<Input className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500" />
</Form.Item>
<MCPLogoSelector value={logoUrl} onChange={setLogoUrl} />
<Form.Item label="Transport Type" name="transport" rules={[{ required: true }]}>
<Select onChange={handleTransportChange}>
<Select.Option value="http">Streamable HTTP (Recommended)</Select.Option>