mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
745379e401
commit
b2e25b3915
11 changed files with 907 additions and 135 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
429
ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx
Normal file
429
ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx
Normal 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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue