mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
3bf91ed9fe
commit
bddbf155d3
7 changed files with 206 additions and 3 deletions
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue