refactor(ui): migrate mcp-servers, tag-management and tool-policies to shadcn

Replaces antd and Tremor with shadcn primitives across the 18 files these three
routes exclusively own. Markup only: no behaviour, data flow or copy changed, and
no shared or form-bearing component is touched, so the blast radius stops at
these pages.

The 12 tests covering these components are unchanged from the previous commit and
still pass, which is the evidence that the rewrite preserved behaviour. Also
prunes the six antd no-restricted-imports suppressions these files no longer
need.
This commit is contained in:
Yuneng Jiang 2026-07-23 23:30:14 -07:00
parent f2d531737a
commit 428d23249a
No known key found for this signature in database
19 changed files with 1792 additions and 1863 deletions

View file

@ -838,15 +838,7 @@
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPLogoSelector.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPNetworkSettings.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/immutability": {
"count": 2
}
@ -861,11 +853,6 @@
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPServerCard.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
@ -904,9 +891,6 @@
}
},
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -947,11 +931,6 @@
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/TruePassthroughWarning.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/UserEnvVarsModal.tsx": {
"no-nested-ternary": {
"count": 2
@ -999,9 +978,6 @@
},
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
@ -1011,9 +987,6 @@
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1021,17 +994,11 @@
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_config.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_cost_display.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx": {
@ -1057,9 +1024,6 @@
"src/app/(dashboard)/mcp-servers/_components/mcp_server_view.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx": {
@ -1072,9 +1036,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1082,9 +1043,6 @@
"src/app/(dashboard)/mcp-servers/_components/mcp_tool_configuration.tsx": {
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_tools.tsx": {
@ -1097,9 +1055,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1864,9 +1819,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2435,11 +2387,6 @@
"count": 1
}
},
"src/components/Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterTestPanel.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/AdminSettings/PluginSettings/PluginSettings.tsx": {
"no-restricted-imports": {
"count": 1
@ -2605,20 +2552,7 @@
}
},
"src/components/ToolDetail.tsx": {
"no-restricted-imports": {
"count": 1
},
"unused-imports/no-unused-imports": {
"count": 2
}
},
"src/components/ToolPolicies/PolicySelect.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/ToolPolicies/ToolPoliciesTableColumns.tsx": {
"no-restricted-imports": {
"count": 1
}
},

View file

@ -1,6 +1,8 @@
import React from "react";
import { Input, Tooltip } from "antd";
import { InfoCircleOutlined, LinkOutlined } from "@ant-design/icons";
import { Info, Link as LinkIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { cn } from "@/lib/cva.config";
import { Logo } from "@/components/molecules/logo/Logo";
import githubLogo from "../../../../../public/assets/logos/github.svg";
import slackLogo from "../../../../../public/assets/logos/slack.svg";
@ -61,72 +63,83 @@ const MCPLogoSelector: React.FC<MCPLogoSelectorProps> = ({ value, onChange }) =>
};
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">
<Logo
src={selectedWellKnown?.src ?? value}
label="Selected"
className="w-10 h-10 object-contain rounded-sm"
/>
<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>
<TooltipProvider>
<div>
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">Logo</span>
<Tooltip>
<TooltipTrigger
render={<Info className="size-4 cursor-help text-muted-foreground" aria-label="About the logo" />}
/>
<TooltipContent>
Select a well-known logo or paste a URL to any image. The logo is shown on the admin and chat pages.
</TooltipContent>
</Tooltip>
</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;
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-xs"
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
}`}
style={{ width: 40, height: 40 }}
>
<img src={logo.src} alt={logo.name} className="w-5 h-5 object-contain" />
</button>
</Tooltip>
);
})}
{/* Preview */}
{value && (
<div className="mb-3 flex items-center gap-3 rounded-lg border border-border bg-muted p-3">
<Logo
src={selectedWellKnown?.src ?? value}
label="Selected"
className="h-10 w-10 rounded-sm object-contain"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-muted-foreground">{value}</div>
</div>
<button
type="button"
onClick={() => onChange?.(undefined)}
className="cursor-pointer border-none bg-transparent text-xs text-muted-foreground hover:text-destructive"
>
✕
</button>
</div>
)}
{/* Well-known logo grid */}
<div className="mb-3 grid grid-cols-10 gap-1.5">
{WELL_KNOWN_LOGOS.map((logo) => {
const isSelected = value === logo.url;
return (
<Tooltip key={logo.name}>
<TooltipTrigger
render={
<button
type="button"
onClick={() => handleSelect(logo.url)}
className={cn(
"flex size-10 cursor-pointer items-center justify-center rounded-lg border p-2 transition-all",
isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent",
)}
>
<img src={logo.src} alt={logo.name} className="h-5 w-5 object-contain" />
</button>
}
/>
<TooltipContent>{logo.name}</TooltipContent>
</Tooltip>
);
})}
</div>
{/* Custom URL input */}
<InputGroup>
<InputGroupAddon>
<LinkIcon className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Or paste a custom logo URL..."
value={value && !selectedWellKnown ? value : ""}
onChange={(e) => {
const v = e.target.value.trim();
onChange?.(v || undefined);
}}
/>
</InputGroup>
</div>
{/* Custom URL input */}
<Input
prefix={<LinkOutlined className="text-gray-400" />}
placeholder="Or paste a custom logo URL..."
value={value && !selectedWellKnown ? value : ""}
onChange={(e) => {
const v = e.target.value.trim();
onChange?.(v || undefined);
}}
className="rounded-lg"
size="small"
/>
</div>
</TooltipProvider>
);
};

View file

@ -1,6 +1,10 @@
import React, { useState, useEffect } from "react";
import { Select, Button, Card, Typography, Spin, Tag } from "antd";
import { SaveOutlined, PlusOutlined } from "@ant-design/icons";
import { Save, Plus, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { DeprecationBanner } from "@/components/DeprecationBanner";
import {
getGeneralSettingsCall,
@ -9,8 +13,6 @@ import {
fetchMCPClientIp,
} from "@/components/networking";
const { Text } = Typography;
interface MCPNetworkSettingsProps {
accessToken: string | null;
}
@ -29,6 +31,7 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
const [saving, setSaving] = useState(false);
const [privateRanges, setPrivateRanges] = useState<string[]>([]);
const [currentIp, setCurrentIp] = useState<string | null>(null);
const [rangeDraft, setRangeDraft] = useState("");
useEffect(() => {
loadSettings();
@ -82,10 +85,22 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
}
};
// Commas separate entries, matching the old tokenised input.
const commitDraft = () => {
const added = rangeDraft
.split(",")
.map((r) => r.trim())
.filter((r) => r !== "" && !privateRanges.includes(r));
if (added.length > 0) {
setPrivateRanges([...privateRanges, ...added]);
}
setRangeDraft("");
};
if (loading) {
return (
<div className="flex justify-center py-12">
<Spin />
<UiLoadingSpinner className="size-6 text-muted-foreground" />
</div>
);
}
@ -96,55 +111,75 @@ const MCPNetworkSettings: React.FC<MCPNetworkSettingsProps> = ({ accessToken })
<div className="space-y-6 p-4">
<DeprecationBanner featureName="MCP Network Settings and the internal-network-only flag" />
<div>
<Text className="text-lg font-semibold">Private IP Ranges</Text>
<p className="text-sm text-gray-500 mt-1">
<p className="text-lg font-semibold">Private IP Ranges</p>
<p className="mt-1 text-sm text-muted-foreground">
Define which IP ranges are part of your private network. Callers from these IPs can see all MCP servers.
Callers from any other IP can only see servers marked &quot;Available on Public Internet&quot;.
</p>
</div>
<Card>
<Card className="p-6">
{currentIp && (
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
<Text className="text-sm text-blue-700">
<div className="mb-4 rounded-lg bg-muted p-3">
<p className="text-sm">
Your current IP: <span className="font-mono font-medium">{currentIp}</span>
</Text>
</p>
{suggestedRange && !privateRanges.includes(suggestedRange) && (
<div className="mt-1">
<Text className="text-sm text-blue-600">Suggested range: </Text>
<Tag
<div className="mt-1 flex items-center gap-2">
<p className="text-sm">Suggested range: </p>
<Badge
variant="outline"
className="cursor-pointer font-mono"
color="blue"
icon={<PlusOutlined />}
onClick={() => addSuggestedRange(suggestedRange)}
>
<Plus />
{suggestedRange}
</Tag>
</Badge>
</div>
)}
</div>
)}
<div className="flex items-center mb-2">
<Text className="font-medium">Your Private Network Ranges</Text>
<div className="mb-2 flex items-center">
<p className="text-sm font-medium">Your Private Network Ranges</p>
</div>
<Select
mode="tags"
value={privateRanges}
onChange={setPrivateRanges}
{privateRanges.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{privateRanges.map((range) => (
<Badge key={range} variant="secondary" className="font-mono">
{range}
<button
type="button"
aria-label={`Remove ${range}`}
onClick={() => setPrivateRanges(privateRanges.filter((r) => r !== range))}
className="ml-1 cursor-pointer"
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
)}
<Input
value={rangeDraft}
placeholder="Leave empty to use defaults: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8"
tokenSeparators={[","]}
className="w-full"
size="large"
allowClear
onChange={(e) => setRangeDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commitDraft();
}
}}
/>
<p className="text-xs text-gray-400 mt-2">
<p className="mt-2 text-xs text-muted-foreground">
Enter CIDR ranges (e.g., 10.0.0.0/8). When empty, standard private IP ranges are used.
</p>
</Card>
<div className="flex justify-end">
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
<Button onClick={handleSave} disabled={saving}>
<Save />
Save
</Button>
</div>

View file

@ -1,19 +1,20 @@
import { type FC, type KeyboardEvent, type MouseEvent } from "react";
import { Dropdown, Tooltip, Typography, Tag } from "antd";
import type { MenuProps } from "antd";
import { Check, CircleAlert, Ellipsis, Trash2, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CheckOutlined,
DeleteOutlined,
ExclamationCircleFilled,
MoreOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
import { AUTH_TYPE, type MCPServer } from "@/components/mcp_tools/types";
import { Logo } from "@/components/molecules/logo/Logo";
import { getMaskedAndFullUrl } from "./utils";
const { Text } = Typography;
interface MCPServerCardProps {
server: MCPServer;
// Per-user env-var fields this user still needs to fill in for this server.
@ -73,8 +74,8 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
const needsAttention = missing.length > 0;
const cardClass = needsAttention
? "border-2 border-red-300 bg-red-50/40 hover:border-red-400 hover:shadow-md"
: "border border-gray-200 bg-white hover:border-gray-300 hover:shadow-md";
? "border-2 border-destructive/40 bg-destructive/5 hover:border-destructive/60 hover:shadow-md"
: "border border-border bg-card hover:shadow-md";
const url = server.url || "";
const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" };
@ -105,174 +106,198 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
}
};
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();
},
});
}
const hasMenu = !!onRecheckHealth || !!onDelete;
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the
// recheck-health Tag), so a real <button> wrapper would produce invalid
// recheck-health Badge), 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-hidden focus-visible:ring-2 focus-visible:ring-blue-400 ${cardClass}`}
>
<div className="flex items-start gap-3">
{candidateLogo ? (
<Logo src={candidateLogo} label={name} className="h-10 w-10 shrink-0 rounded-sm object-contain" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-gray-100 font-semibold text-gray-500">
{(name || "?").slice(0, 2).toUpperCase()}
</div>
<TooltipProvider>
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className={cn(
"group relative flex h-full cursor-pointer flex-col gap-3 rounded-lg p-4 transition-all duration-150 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
cardClass,
)}
<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 className="flex items-start gap-3">
{candidateLogo ? (
<Logo src={candidateLogo} label={name} className="h-10 w-10 shrink-0 rounded-sm object-contain" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-sm bg-muted font-semibold text-muted-foreground">
{(name || "?").slice(0, 2).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div className="block w-full truncate text-left font-semibold" title={name}>
{name}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
{alias && <span className="truncate">{alias}</span>}
{alias && <span>·</span>}
<Tooltip>
<TooltipTrigger
render={<span className="font-mono text-primary">{server.server_id.slice(0, 7)}</span>}
/>
<TooltipContent>{server.server_id}</TooltipContent>
</Tooltip>
</div>
</div>
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<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-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Ellipsis className="size-5" />
</button>
}
/>
<DropdownMenuContent align="end">
{onRecheckHealth && (
<DropdownMenuItem
disabled={isRechecking}
onClick={(e) => {
stop(e);
onRecheckHealth();
}}
>
<Zap />
Test Connection
</DropdownMenuItem>
)}
{onRecheckHealth && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
stop(e);
onDelete();
}}
>
<Trash2 />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</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>
{oauthFlowUnset && (
<Tooltip title="This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow Type so LiteLLM authenticates it as you intend.">
<Tag color="warning" className="m-0">
<span className="inline-flex items-center gap-1">
<ExclamationCircleFilled />
OAuth flow not set
</span>
</Tag>
{subtitle ? (
<Tooltip>
<TooltipTrigger render={<p className="truncate font-mono text-xs text-muted-foreground">{subtitle}</p>} />
<TooltipContent>{subtitleTooltip}</TooltipContent>
</Tooltip>
) : (
// Defensive placeholder: keep the row even when no identifier is
// available so the badge row stays vertically aligned across the grid.
<div className="h-[18px]" aria-hidden />
)}
<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"}`} />
<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}
/>
<Badge variant="outline">{displayTransport.toUpperCase()}</Badge>
<Badge variant="outline">{authType}</Badge>
{oauthFlowUnset && (
<Tooltip>
<TooltipTrigger
render={
<Badge variant="outline">
<CircleAlert />
OAuth flow not set
</Badge>
}
/>
<TooltipContent>
This OAuth server has no flow set (Machine-to-Machine vs Interactive). Open it and choose an OAuth Flow
Type so LiteLLM authenticates it as you intend.
</TooltipContent>
</Tooltip>
)}
<Badge variant="outline">
<span className={cn("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>
</Badge>
{accessGroups.slice(0, 2).map((g) => (
<Tooltip key={g}>
<TooltipTrigger
render={
<Badge variant="outline" className="max-w-[120px] truncate">
{g}
</Badge>
}
/>
<TooltipContent>{g}</TooltipContent>
</Tooltip>
))}
{accessGroups.length > 2 && (
<Tooltip>
<TooltipTrigger render={<Badge variant="outline">+{accessGroups.length - 2}</Badge>} />
<TooltipContent>{accessGroups.slice(2).join(", ")}</TooltipContent>
</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>
{(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>
<TooltipTrigger
render={
<span className="inline-flex items-center gap-1 font-semibold text-destructive">
<CircleAlert className="size-3.5" />
{missing.length} user field
{missing.length === 1 ? "" : "s"} missing
</span>
}
/>
<TooltipContent>
<div className="mb-1 font-semibold">Missing user fields:</div>
<ul className="ml-3">
{missing.map((m) => (
<li key={m}>• {m}</li>
))}
</ul>
</div>
}
>
<span className="inline-flex items-center gap-1 font-semibold text-red-700">
<ExclamationCircleFilled />
{missing.length} user field
{missing.length === 1 ? "" : "s"} missing
</span>
</Tooltip>
{onOpenFillFields && (
<button
type="button"
onClick={(e) => {
stop(e);
onOpenFillFields();
}}
className="rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white shadow-xs transition-colors hover:bg-red-700"
>
Set
</button>
)}
</div>
)}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
{onOpenFillFields && (
<Button
variant="destructive"
size="sm"
onClick={(e) => {
stop(e);
onOpenFillFields();
}}
>
Set
</Button>
)}
</div>
)}
</div>
)}
</div>
</TooltipProvider>
);
};
@ -297,46 +322,45 @@ const HealthChip: FC<HealthChipProps> = ({
}) => {
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>
<Badge variant="outline" className="text-muted-foreground">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground" />
Checking
</Badge>
);
}
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="wrap-break-word">{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
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
className={onRecheck ? "cursor-pointer hover:opacity-80" : "cursor-default"}
onClick={
onRecheck
? (e) => {
e.stopPropagation();
onRecheck();
}
: undefined
}
>
<span className={cn("h-1.5 w-1.5 rounded-full", dotClass)} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
}
>
<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>
/>
<TooltipContent side="top" className="max-w-xs">
<div className="mb-1 font-semibold">Health: {status}</div>
{lastCheck && <div className="mb-1 text-xs">Last check: {new Date(lastCheck).toLocaleString()}</div>}
{error && (
<div className="text-xs">
<div className="mb-1 font-medium">Error</div>
<div className="wrap-break-word">{error}</div>
</div>
)}
{!lastCheck && !error && <div className="text-xs">No health data</div>}
{onRecheck && <div className="mt-1 text-xs">Click to recheck</div>}
</TooltipContent>
</Tooltip>
);
};
@ -350,22 +374,22 @@ 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>
<span className="text-muted-foreground">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>
<Badge variant="outline">
<Check /> Connected
</Badge>
{onConnect && (
<button
type="button"
<Button
variant="link"
size="sm"
onClick={(e) => {
stop(e);
onConnect();
}}
className="text-xs text-gray-400 transition-colors hover:text-blue-600"
>
Update
</button>
</Button>
)}
</div>
</div>
@ -373,20 +397,19 @@ const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
}
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-500">BYOK credential</span>
<span className="text-muted-foreground">BYOK credential</span>
{onConnect ? (
<button
type="button"
<Button
size="sm"
onClick={(e) => {
stop(e);
onConnect();
}}
className="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white shadow-xs transition-colors hover:bg-blue-700"
>
Connect
</button>
</Button>
) : (
<span className="text-gray-400">—</span>
<span className="text-muted-foreground">—</span>
)}
</div>
);

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Spin } from "antd";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import { fetchOpenAPIRegistry } from "@/components/networking";
export interface OpenAPIKeyTool {
@ -49,9 +50,9 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
if (loading) {
return (
<div className="mb-4">
<span className="text-sm font-medium text-gray-700">Popular APIs</span>
<span className="text-sm font-medium">Popular APIs</span>
<div className="flex justify-center py-6">
<Spin size="small" />
<UiLoadingSpinner className="size-5 text-muted-foreground" />
</div>
</div>
);
@ -61,7 +62,7 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
return (
<div className="mb-4">
<span className="text-sm font-medium text-gray-700 block mb-2">Popular APIs</span>
<span className="mb-2 block text-sm font-medium">Popular APIs</span>
<div className="grid grid-cols-5 gap-2">
{apis.map((api) => {
@ -73,32 +74,30 @@ const OpenAPIQuickPicker: React.FC<OpenAPIQuickPickerProps> = ({ accessToken, se
type="button"
title={api.description}
onClick={() => onSelect(api)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer
${
isSelected
? "border-blue-500 bg-blue-50 shadow-xs"
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
}`}
className={cn(
"flex cursor-pointer flex-col items-center gap-1.5 rounded-lg border p-3 transition-all",
isSelected ? "border-primary bg-accent shadow-xs" : "border-border hover:bg-accent",
)}
>
{imgFailed ? (
<span className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-sm font-bold text-gray-600">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground">
{api.title.charAt(0)}
</span>
) : (
<img
src={api.icon_url}
alt={api.title}
className="w-7 h-7 object-contain"
className="h-7 w-7 object-contain"
onError={() => handleImgError(api.name)}
/>
)}
<span className="text-xs text-gray-600 text-center leading-tight font-medium">{api.title}</span>
<span className="text-center text-xs leading-tight font-medium text-muted-foreground">{api.title}</span>
</button>
);
})}
</div>
<p className="text-xs text-gray-400 mt-2">
<p className="mt-2 text-xs text-muted-foreground">
Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter your own spec URL below.
</p>
</div>

View file

@ -1,5 +1,6 @@
import React from "react";
import { Alert } from "antd";
import { TriangleAlert } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { AUTH_TYPE } from "@/components/mcp_tools/types";
/**
@ -10,12 +11,15 @@ import { AUTH_TYPE } from "@/components/mcp_tools/types";
export default function TruePassthroughWarning({ authType }: { authType?: string | null }) {
if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null;
return (
<Alert
type="warning"
showIcon
className="mb-4 rounded-lg"
message="True Passthrough disables LiteLLM authentication for this server"
description="Anyone who can reach the gateway can call this server without a LiteLLM key. The caller's Authorization header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply, and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers should still authenticate to LiteLLM."
/>
<Alert className="mb-4">
<TriangleAlert />
<AlertTitle>True Passthrough disables LiteLLM authentication for this server</AlertTitle>
<AlertDescription>
Anyone who can reach the gateway can call this server without a LiteLLM key. The caller&apos;s Authorization
header is forwarded to the upstream verbatim, per-key and per-team rate limits and spend tracking do not apply,
and the upstream is fully responsible for authenticating callers. Choose OAuth Delegate instead if callers
should still authenticate to LiteLLM.
</AlertDescription>
</Alert>
);
}

View file

@ -1,7 +1,10 @@
import React from "react";
import { Button, Spin, Alert, Collapse } from "antd";
import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined } from "@ant-design/icons";
import { Card, Title, Text } from "@tremor/react";
import { CircleCheck, CircleAlert, RefreshCw, Wrench, Info } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface MCPConnectionStatusProps {
formValues: Record<string, any>;
@ -31,27 +34,26 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
}
return (
<Card>
<Card className="p-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<CheckCircleOutlined className="text-blue-600" />
<Title>Connection Status</Title>
<CircleCheck className="size-4 text-muted-foreground" />
<h3 className="text-lg font-medium">Connection Status</h3>
</div>
{!canFetchTools && (formValues.url || formValues.spec_path) && (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>Complete required fields to test connection</Text>
<br />
<Text className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</Text>
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<Wrench className="mx-auto mb-2 size-6" />
<p className="text-sm">Complete required fields to test connection</p>
<p className="text-sm">Fill in URL, Transport, and Authentication to test MCP server connection</p>
</div>
)}
{canFetchTools && (
<div>
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex items-center justify-between">
<div>
<Text className="text-gray-700 font-medium">
<p className="text-sm font-medium">
{isLoadingTools
? "Testing connection to MCP server..."
: tools.length > 0
@ -61,97 +63,84 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({
? "Ready to submit"
: "Connection failed"
: "Ready to test connection"}
</Text>
<br />
<Text className="text-gray-500 text-sm">Server: {formValues.url || formValues.spec_path}</Text>
</p>
<p className="text-sm text-muted-foreground">Server: {formValues.url || formValues.spec_path}</p>
</div>
{isLoadingTools && (
<div className="flex items-center text-blue-600">
<Spin size="small" className="mr-2" />
<Text className="text-blue-600">Connecting...</Text>
<div className="flex items-center gap-2 text-muted-foreground">
<UiLoadingSpinner className="size-4" />
<p className="text-sm">Connecting...</p>
</div>
)}
{!isLoadingTools && !toolsError && tools.length > 0 && (
<div className="flex items-center text-green-600">
<CheckCircleOutlined className="mr-1" />
<Text className="text-green-600 font-medium">Connected</Text>
<div className="flex items-center gap-1">
<CircleCheck className="size-4" />
<p className="text-sm font-medium">Connected</p>
</div>
)}
{toolsError && !isPreviewForbidden && (
<div className="flex items-center text-red-600">
<ExclamationCircleOutlined className="mr-1" />
<Text className="text-red-600 font-medium">Failed</Text>
<div className="flex items-center gap-1 text-destructive">
<CircleAlert className="size-4" />
<p className="text-sm font-medium">Failed</p>
</div>
)}
</div>
{isLoadingTools && (
<div className="flex items-center justify-center py-6">
<Spin size="large" />
<Text className="ml-3">Testing connection and loading tools...</Text>
<div className="flex items-center justify-center gap-3 py-6">
<UiLoadingSpinner className="size-6 text-muted-foreground" />
<p className="text-sm">Testing connection and loading tools...</p>
</div>
)}
{toolsError && isPreviewForbidden && (
<Alert message="Tool preview unavailable" description={toolsError} type="info" showIcon />
<Alert>
<Info />
<AlertTitle>Tool preview unavailable</AlertTitle>
<AlertDescription>{toolsError}</AlertDescription>
</Alert>
)}
{toolsError && !isPreviewForbidden && (
<Alert
message="Connection Failed"
description={
<div>
<div>{toolsError}</div>
{toolsErrorStackTrace && (
<Collapse
items={[
{
key: "stack-trace",
label: "Stack Trace",
children: (
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontSize: "12px",
fontFamily: "monospace",
margin: 0,
padding: "8px",
backgroundColor: "#f5f5f5",
borderRadius: "4px",
maxHeight: "400px",
overflow: "auto",
}}
>
{toolsErrorStackTrace}
</pre>
),
},
]}
style={{ marginTop: "12px" }}
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>Connection Failed</AlertTitle>
<AlertDescription>
<div>{toolsError}</div>
{toolsErrorStackTrace && (
<Collapsible className="mt-3">
<CollapsibleTrigger
render={
<Button variant="link" size="sm" className="h-auto p-0">
Stack Trace
</Button>
}
/>
)}
</div>
}
type="error"
showIcon
action={
<Button icon={<ReloadOutlined />} onClick={fetchTools} size="small">
<CollapsibleContent>
<pre className="mt-2 max-h-100 overflow-auto rounded-sm bg-muted p-2 font-mono text-xs break-words whitespace-pre-wrap">
{toolsErrorStackTrace}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</AlertDescription>
<div className="mt-3">
<Button variant="outline" size="sm" onClick={fetchTools}>
<RefreshCw />
Retry
</Button>
}
/>
</div>
</Alert>
)}
{!isLoadingTools && tools.length === 0 && !toolsError && (
<div className="text-center py-6 text-gray-500 border rounded-lg border-dashed">
<CheckCircleOutlined className="text-2xl mb-2 text-green-500" />
<Text className="text-green-600 font-medium">Connection successful!</Text>
<br />
<Text className="text-gray-500">No tools found for this MCP server</Text>
<div className="rounded-lg border border-dashed py-6 text-center">
<CircleCheck className="mx-auto mb-2 size-6" />
<p className="text-sm font-medium">Connection successful!</p>
<p className="text-sm text-muted-foreground">No tools found for this MCP server</p>
</div>
)}
</div>

View file

@ -1,13 +1,15 @@
import React, { useState, useMemo, useEffect } from "react";
import { Modal, Input, Typography } from "antd";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/cva.config";
import { fetchDiscoverableMCPServers } from "@/components/networking";
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "@/components/mcp_tools/types";
import { mcpLogoImg } from "./create_mcp_server";
import { resolveLogoSrc } from "@/lib/assetPaths";
const { Search } = Input;
const { Text } = Typography;
interface MCPDiscoveryProps {
isVisible: boolean;
onClose: () => void;
@ -16,12 +18,21 @@ interface MCPDiscoveryProps {
accessToken: string | null;
}
const INITIAL_COLORS = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"];
const INITIAL_COLORS = [
"bg-blue-500",
"bg-emerald-500",
"bg-amber-500",
"bg-red-500",
"bg-violet-500",
"bg-pink-500",
"bg-cyan-500",
"bg-lime-500",
];
function getInitialAvatar(name: string) {
const initial = name.charAt(0).toUpperCase();
const colorIndex = name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % INITIAL_COLORS.length;
return { initial, backgroundColor: INITIAL_COLORS[colorIndex] };
return { initial, backgroundClass: INITIAL_COLORS[colorIndex] };
}
const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
@ -91,214 +102,126 @@ const MCPDiscovery: React.FC<MCPDiscoveryProps> = ({
}, [filteredServers]);
return (
<Modal
title={
<div className="flex items-center justify-between pb-4 border-b border-gray-100">
<div className="flex items-center space-x-3">
<img
src={resolveLogoSrc(mcpLogoImg)}
alt="MCP Logo"
className="w-8 h-8 object-contain"
style={{
height: "20px",
width: "20px",
marginRight: "8px",
objectFit: "contain",
}}
/>
<h2 className="text-xl font-semibold text-gray-900">Add MCP Server</h2>
</div>
<button
onClick={onCustomServer}
className="text-sm text-blue-600 hover:text-blue-800 cursor-pointer bg-transparent border-none font-medium"
>
+ Custom Server
</button>
</div>
}
open={isVisible}
onCancel={onClose}
footer={null}
width={1000}
className="top-8"
styles={{
body: { padding: "24px", maxHeight: "70vh", overflowY: "auto" },
header: { padding: "24px 24px 0 24px", border: "none" },
}}
>
{/* Filter pills */}
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
{["All", ...categories].map((cat) => {
const isSelected = selectedCategory === cat;
return (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
style={{
padding: "4px 12px",
borderRadius: 4,
border: isSelected ? "1px solid #111827" : "1px solid #e5e7eb",
background: isSelected ? "#111827" : "#fff",
color: isSelected ? "#fff" : "#4b5563",
cursor: "pointer",
fontSize: 12,
fontWeight: isSelected ? 500 : 400,
lineHeight: "20px",
}}
>
{cat}
</button>
);
})}
</div>
{/* Search */}
<Search
placeholder="Search servers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ marginBottom: 16 }}
allowClear
/>
{/* Loading skeleton */}
{loading && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
style={{
height: 36,
borderRadius: 6,
background: "#f9fafb",
}}
/>
))}
</div>
)}
{error && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
<Text>Failed to load servers: {error}</Text>
</div>
)}
{!loading && !error && filteredServers.length === 0 && (
<div style={{ textAlign: "center", padding: "32px 0", color: "#9ca3af" }}>
<Text>
No servers found.{" "}
<a onClick={onCustomServer} style={{ color: "#2563eb", cursor: "pointer" }}>
Add a custom server
</a>
</Text>
</div>
)}
{/* Server list grouped by category — 2 columns */}
{!loading &&
!error &&
Object.entries(groupedServers).map(([category, categoryServers]) => (
<div key={category} style={{ marginBottom: 16 }}>
<div
style={{
fontSize: 11,
fontWeight: 500,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
padding: "6px 0",
borderBottom: "1px solid #f3f4f6",
marginBottom: 4,
}}
>
{category}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0 16px",
}}
>
{categoryServers.map((server) => {
const avatar = getInitialAvatar(server.title || server.name);
return (
<div
key={server.name}
onClick={() => onSelectServer(server)}
style={{
display: "flex",
alignItems: "center",
padding: "8px 10px",
borderRadius: 6,
cursor: "pointer",
transition: "background 0.1s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f9fafb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
{server.icon_url ? (
<img
src={resolveLogoSrc(server.icon_url)}
alt={server.title}
style={{
width: 20,
height: 20,
objectFit: "contain",
flexShrink: 0,
marginRight: 12,
}}
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
const next = target.nextElementSibling as HTMLElement;
if (next) next.style.display = "flex";
}}
/>
) : null}
<div
style={{
width: 20,
height: 20,
borderRadius: 4,
backgroundColor: avatar.backgroundColor,
color: "#fff",
display: server.icon_url ? "none" : "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 600,
fontSize: 11,
flexShrink: 0,
marginRight: 12,
}}
>
{avatar.initial}
</div>
<span
style={{
fontSize: 14,
fontWeight: 400,
color: "#111827",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{server.title || server.name}
</span>
<span style={{ color: "#d1d5db", fontSize: 14, flexShrink: 0, marginLeft: 8 }}>&#8250;</span>
</div>
);
})}
<Dialog open={isVisible} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[1000px]">
<DialogHeader>
<div className="flex items-center justify-between border-b border-border pb-4">
<div className="flex items-center space-x-3">
<img src={resolveLogoSrc(mcpLogoImg)} alt="MCP Logo" className="mr-2 size-5 object-contain" />
<DialogTitle className="text-xl font-semibold">Add MCP Server</DialogTitle>
</div>
<Button variant="link" size="sm" onClick={onCustomServer}>
+ Custom Server
</Button>
</div>
))}
</Modal>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto">
{/* Filter pills */}
<div className="mb-3 flex flex-wrap gap-1.5">
{["All", ...categories].map((cat) => {
const isSelected = selectedCategory === cat;
return (
<Button
key={cat}
size="sm"
variant={isSelected ? "default" : "outline"}
onClick={() => setSelectedCategory(cat)}
>
{cat}
</Button>
);
})}
</div>
{/* Search */}
<InputGroup className="mb-4 w-full">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search servers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
{/* Loading skeleton */}
{loading && (
<div className="flex flex-col gap-1">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-9 rounded-md" />
))}
</div>
)}
{error && (
<div className="py-8 text-center text-muted-foreground">
<p className="text-sm">Failed to load servers: {error}</p>
</div>
)}
{!loading && !error && filteredServers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
<p className="text-sm">
No servers found.{" "}
<Button variant="link" size="sm" onClick={onCustomServer}>
Add a custom server
</Button>
</p>
</div>
)}
{/* Server list grouped by category — 2 columns */}
{!loading &&
!error &&
Object.entries(groupedServers).map(([category, categoryServers]) => (
<div key={category} className="mb-4">
<div className="mb-1 border-b border-border py-1.5 text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
{category}
</div>
<div className="grid grid-cols-2 gap-x-4">
{categoryServers.map((server) => {
const avatar = getInitialAvatar(server.title || server.name);
return (
<div
key={server.name}
onClick={() => onSelectServer(server)}
className="flex cursor-pointer items-center rounded-md px-2.5 py-2 transition-colors hover:bg-accent"
>
{server.icon_url ? (
<img
src={resolveLogoSrc(server.icon_url)}
alt={server.title}
className="mr-3 size-5 shrink-0 object-contain"
onError={(e) => {
const target = e.currentTarget;
target.style.display = "none";
const next = target.nextElementSibling as HTMLElement;
if (next) next.style.display = "flex";
}}
/>
) : null}
<div
className={cn(
"mr-3 size-5 shrink-0 items-center justify-center rounded-sm text-[11px] font-semibold text-white",
avatar.backgroundClass,
server.icon_url ? "hidden" : "flex",
)}
>
{avatar.initial}
</div>
<span className="flex-1 truncate text-sm">{server.title || server.name}</span>
<span className="ml-2 shrink-0 text-sm text-muted-foreground">&#8250;</span>
</div>
);
})}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -1,7 +1,10 @@
import React from "react";
import { Tooltip, InputNumber, Collapse, Badge } from "antd";
import { InfoCircleOutlined, DollarOutlined, ToolOutlined } from "@ant-design/icons";
import { Card, Title, Text } from "@tremor/react";
import React, { useState } from "react";
import { Info, DollarSign, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
interface MCPServerCostConfigProps {
@ -11,6 +14,47 @@ interface MCPServerCostConfigProps {
disabled?: boolean;
}
interface CostInputProps {
value: number | null | undefined;
placeholder: string;
disabled?: boolean;
className?: string;
onChange: (cost: number | null) => void;
}
/**
* Costs are shown to four decimal places when idle, but the field keeps the raw
* keystrokes while it is being edited so partial input like "0." survives.
*/
const CostInput: React.FC<CostInputProps> = ({ value, placeholder, disabled, className, onChange }) => {
const [draft, setDraft] = useState<string | null>(null);
const display = draft ?? (value === null || value === undefined ? "" : value.toFixed(4));
const handleChange = (next: string) => {
setDraft(next);
const parsed = Number(next);
onChange(next.trim() === "" || Number.isNaN(parsed) ? null : parsed);
};
return (
<InputGroup className={className}>
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
type="text"
inputMode="decimal"
placeholder={placeholder}
disabled={disabled}
value={display}
onFocus={() => setDraft(value === null || value === undefined ? "" : String(value))}
onBlur={() => setDraft(null)}
onChange={(e) => handleChange(e.target.value)}
/>
</InputGroup>
);
};
const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
value = {},
onChange,
@ -37,124 +81,126 @@ const MCPServerCostConfig: React.FC<MCPServerCostConfigProps> = ({
};
return (
<Card>
<div className="space-y-6">
<div className="flex items-center gap-2 mb-4">
<DollarOutlined className="text-green-600" />
<Title>Cost Configuration</Title>
<Tooltip title="Configure costs for this MCP server's tool calls. Set a default rate and per-tool overrides.">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default Cost per Query ($)
<Tooltip title="Default cost charged for each tool call to this server.">
<InfoCircleOutlined className="ml-1 text-gray-400" />
</Tooltip>
</label>
<InputNumber
min={0}
step={0.0001}
precision={4}
placeholder="0.0000"
value={value.default_cost_per_query}
onChange={handleDefaultCostChange}
disabled={disabled}
style={{ width: "200px" }}
addonBefore="$"
/>
<Text className="block mt-1 text-gray-500 text-sm">
Set a default cost for all tool calls to this server
</Text>
<TooltipProvider>
<Card className="p-6">
<div className="space-y-6">
<div className="mb-4 flex items-center gap-2">
<DollarSign className="size-4 text-muted-foreground" />
<h3 className="text-lg font-medium">Cost Configuration</h3>
<Tooltip>
<TooltipTrigger
render={<Info className="size-4 text-muted-foreground" aria-label="About cost configuration" />}
/>
<TooltipContent>
Configure costs for this MCP server&apos;s tool calls. Set a default rate and per-tool overrides.
</TooltipContent>
</Tooltip>
</div>
{tools.length > 0 && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700">
Tool-Specific Costs ($)
<Tooltip title="Override the default cost for specific tools. Leave blank to use the default rate.">
<InfoCircleOutlined className="ml-1 text-gray-400" />
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">
Default Cost per Query ($)
<Tooltip>
<TooltipTrigger
render={
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About the default cost" />
}
/>
<TooltipContent>Default cost charged for each tool call to this server.</TooltipContent>
</Tooltip>
</label>
<Collapse
items={[
{
key: "1",
label: (
<div className="flex items-center">
<ToolOutlined className="mr-2 text-blue-500" />
<span className="font-medium">Available Tools</span>
<Badge
count={tools.length}
style={{
backgroundColor: "#52c41a",
marginLeft: "8px",
}}
/>
</div>
),
children: (
<div className="space-y-3 max-h-64 overflow-y-auto">
{tools.map((tool, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex-1">
<Text className="font-medium text-gray-900">{tool.name}</Text>
{tool.description && (
<Text className="text-gray-500 text-sm block mt-1">{tool.description}</Text>
)}
</div>
<div className="ml-4">
<InputNumber
min={0}
step={0.0001}
precision={4}
placeholder="Use default"
value={value.tool_name_to_cost_per_query?.[tool.name]}
onChange={(cost) => handleToolCostChange(tool.name, cost)}
disabled={disabled}
style={{ width: "120px" }}
addonBefore="$"
/>
</div>
</div>
))}
</div>
),
},
]}
<CostInput
value={value.default_cost_per_query}
placeholder="0.0000"
disabled={disabled}
className="w-50"
onChange={handleDefaultCostChange}
/>
<p className="mt-1 block text-sm text-muted-foreground">
Set a default cost for all tool calls to this server
</p>
</div>
{tools.length > 0 && (
<div className="space-y-4">
<label className="block text-sm font-medium">
Tool-Specific Costs ($)
<Tooltip>
<TooltipTrigger
render={
<Info className="ml-1 inline size-4 text-muted-foreground" aria-label="About per-tool costs" />
}
/>
<TooltipContent>
Override the default cost for specific tools. Leave blank to use the default rate.
</TooltipContent>
</Tooltip>
</label>
<Collapsible className="rounded-lg border border-border">
<CollapsibleTrigger
render={
<button type="button" className="flex w-full items-center gap-2 p-3 text-left">
<Wrench className="size-4 text-muted-foreground" />
<span className="font-medium">Available Tools</span>
<Badge variant="secondary">{tools.length}</Badge>
</button>
}
/>
<CollapsibleContent>
<div className="max-h-64 space-y-3 overflow-y-auto p-3">
{tools.map((tool, index) => (
<div key={index} className="flex items-center justify-between rounded-lg bg-muted p-3">
<div className="flex-1">
<p className="text-sm font-medium">{tool.name}</p>
{tool.description && (
<p className="mt-1 block text-sm text-muted-foreground">{tool.description}</p>
)}
</div>
<div className="ml-4">
<CostInput
value={value.tool_name_to_cost_per_query?.[tool.name]}
placeholder="Use default"
disabled={disabled}
className="w-40"
onChange={(cost) => handleToolCostChange(tool.name, cost)}
/>
</div>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
{(value.default_cost_per_query ||
(value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && (
<div className="mt-6 rounded-lg border border-border bg-muted p-4">
<p className="text-sm font-medium">Cost Summary:</p>
<div className="mt-2 space-y-1">
{value.default_cost_per_query && (
<p className="text-sm text-muted-foreground">
• Default cost: ${value.default_cost_per_query.toFixed(4)} per query
</p>
)}
{value.tool_name_to_cost_per_query &&
Object.entries(value.tool_name_to_cost_per_query).map(
([toolName, cost]) =>
cost !== null &&
cost !== undefined && (
<p key={toolName} className="text-sm text-muted-foreground">
• {toolName}: ${cost.toFixed(4)} per query
</p>
),
)}
</div>
</div>
)}
</div>
{(value.default_cost_per_query ||
(value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<Text className="text-blue-800 font-medium">Cost Summary:</Text>
<div className="mt-2 space-y-1">
{value.default_cost_per_query && (
<Text className="text-blue-700">
• Default cost: ${value.default_cost_per_query.toFixed(4)} per query
</Text>
)}
{value.tool_name_to_cost_per_query &&
Object.entries(value.tool_name_to_cost_per_query).map(
([toolName, cost]) =>
cost !== null &&
cost !== undefined && (
<Text key={toolName} className="text-blue-700">
• {toolName}: ${cost.toFixed(4)} per query
</Text>
),
)}
</div>
</div>
)}
</div>
</Card>
</Card>
</TooltipProvider>
);
};

View file

@ -1,5 +1,4 @@
import React from "react";
import { Text } from "@tremor/react";
import { MCPServerCostInfo } from "@/components/mcp_tools/types";
interface MCPServerCostDisplayProps {
@ -15,12 +14,12 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
if (!hasCostConfig) {
return (
<div className="mt-6 pt-6 border-t border-gray-200">
<div className="mt-6 border-t border-border pt-6">
<div className="space-y-4">
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
<Text className="text-gray-600">
<div className="rounded-lg border border-border bg-muted p-4">
<p className="text-sm text-muted-foreground">
No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call.
</Text>
</p>
</div>
</div>
</div>
@ -28,28 +27,28 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
}
return (
<div className="mt-6 pt-6 border-t border-gray-200">
<div className="mt-6 border-t border-border pt-6">
<div className="space-y-4">
{hasDefaultCost &&
costConfig?.default_cost_per_query !== undefined &&
costConfig?.default_cost_per_query !== null && (
<div>
<Text className="font-medium">Default Cost per Query</Text>
<div className="text-green-600 font-mono">${costConfig.default_cost_per_query.toFixed(4)}</div>
<p className="text-sm font-medium">Default Cost per Query</p>
<div className="font-mono text-sm">${costConfig.default_cost_per_query.toFixed(4)}</div>
</div>
)}
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
<div>
<Text className="font-medium">Tool-Specific Costs</Text>
<p className="text-sm font-medium">Tool-Specific Costs</p>
<div className="mt-2 space-y-2">
{Object.entries(costConfig.tool_name_to_cost_per_query).map(
([toolName, cost]) =>
cost !== null &&
cost !== undefined && (
<div key={toolName} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<Text className="font-medium">{toolName}</Text>
<Text className="text-green-600 font-mono">${cost.toFixed(4)} per query</Text>
<div key={toolName} className="flex items-center justify-between rounded-lg bg-muted p-3">
<p className="text-sm font-medium">{toolName}</p>
<p className="font-mono text-sm">${cost.toFixed(4)} per query</p>
</div>
),
)}
@ -57,20 +56,20 @@ const MCPServerCostDisplay: React.FC<MCPServerCostDisplayProps> = ({ costConfig
</div>
)}
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<Text className="text-blue-800 font-medium">Cost Summary:</Text>
<div className="mt-4 rounded-lg border border-border bg-muted p-4">
<p className="text-sm font-medium">Cost Summary:</p>
<div className="mt-2 space-y-1">
{hasDefaultCost &&
costConfig?.default_cost_per_query !== undefined &&
costConfig?.default_cost_per_query !== null && (
<Text className="text-blue-700">
<p className="text-sm text-muted-foreground">
• Default cost: ${costConfig.default_cost_per_query.toFixed(4)} per query
</Text>
</p>
)}
{hasToolCosts && costConfig?.tool_name_to_cost_per_query && (
<Text className="text-blue-700">
<p className="text-sm text-muted-foreground">
• {Object.keys(costConfig.tool_name_to_cost_per_query).length} tool(s) with custom pricing
</Text>
</p>
)}
</div>
</div>

View file

@ -1,6 +1,9 @@
import React, { useState } from "react";
import { ArrowLeftIcon, EyeIcon, EyeOffIcon } from "@heroicons/react/outline";
import { Title, Card, Button, Text, Grid, TabGroup, TabList, TabPanel, TabPanels, Tab, Icon } from "@tremor/react";
import { ArrowLeft, Eye, EyeOff } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types";
// TODO: Move Tools viewer from index file
@ -11,7 +14,6 @@ import MCPServerCostDisplay from "./mcp_server_cost_display";
import { getMaskedAndFullUrl } from "./utils";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Button as AntdButton } from "antd";
interface MCPServerViewProps {
mcpServer: MCPServer;
@ -86,335 +88,306 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
}
};
const getTransportBadge = (transport: string) => {
const label = transport.toUpperCase();
return (
<span className="inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded-sm border bg-gray-50 text-gray-700 border-gray-200">
{label}
</span>
);
};
const getTransportBadge = (transport: string) => <Badge variant="outline">{transport.toUpperCase()}</Badge>;
const getAuthBadge = (authType: string) => {
return (
<span className="inline-flex items-center text-sm font-medium px-2.5 py-0.5 rounded-sm border bg-gray-50 text-gray-700 border-gray-200">
{authType}
</span>
);
};
const getAuthBadge = (authType: string) => <Badge variant="outline">{authType}</Badge>;
return (
<div className="p-4 max-w-full">
<div className="max-w-full p-4">
<div className="mb-6">
<Button icon={ArrowLeftIcon} variant="light" className="mb-4" onClick={onBack}>
<Button variant="ghost" className="mb-4" onClick={onBack}>
<ArrowLeft />
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} />}
<h1 className="text-2xl font-semibold">{mcpServer.server_name || mcpServer.alias || "Unnamed Server"}</h1>
<Button
variant="ghost"
size="icon-sm"
aria-label="Copy server name"
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"
}`}
/>
>
{copiedStates["mcp-server_name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
</Button>
{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-sm bg-gray-100 text-gray-600 border border-gray-200 font-mono">
<Badge variant="secondary" className="ml-2 font-mono">
{mcpServer.alias}
</span>
</Badge>
)}
</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} />}
<div className="mt-1 flex items-center gap-1.5">
<p className="font-mono text-xs text-muted-foreground">{mcpServer.server_id}</p>
<Button
variant="ghost"
size="icon-sm"
aria-label="Copy server id"
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"
}`}
/>
>
{copiedStates["mcp-server-id"] ? <CheckIcon size={10} /> : <CopyIcon size={10} />}
</Button>
</div>
{mcpServer.description && <Text className="text-gray-500 mt-2">{mcpServer.description}</Text>}
{mcpServer.description && <p className="mt-2 text-sm text-muted-foreground">{mcpServer.description}</p>}
</div>
{/* TODO: magic number for index */}
<TabGroup index={selectedTabIndex} onIndexChange={setSelectedTabIndex}>
<TabList className="mb-4">
{[
<Tab key="overview">Overview</Tab>,
<Tab key="tools">MCP Tools</Tab>,
...(isProxyAdmin ? [<Tab key="settings">Settings</Tab>] : []),
]}
</TabList>
<Tabs value={String(selectedTabIndex)} onValueChange={(v: unknown) => setSelectedTabIndex(Number(v))}>
<TabsList className="mb-4">
<TabsTrigger value="0" className="flex-none">
Overview
</TabsTrigger>
<TabsTrigger value="1" className="flex-none">
MCP Tools
</TabsTrigger>
{isProxyAdmin && (
<TabsTrigger value="2" className="flex-none">
Settings
</TabsTrigger>
)}
</TabsList>
<TabPanels>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-4">
<Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Transport</Text>
<div className="mt-3">
{getTransportBadge(
handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined),
)}
</div>
</Card>
<Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Authentication</Text>
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
</Card>
<Card className="p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Host URL</Text>
<div className="mt-3 flex items-center gap-2">
<Text className="break-all overflow-wrap-anywhere font-mono text-sm">
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
</Text>
{/* Only proxy admins may reveal the raw URL — non-admins
receive a sanitized server object from the backend
with `url=null`, but hide the toggle anyway as
defense-in-depth in case the URL ever leaks back
into the response. */}
{hasToken && isProxyAdmin && (
<button
onClick={() => setShowFullUrl(!showFullUrl)}
className="p-1 hover:bg-gray-100 rounded-sm shrink-0"
>
<Icon icon={showFullUrl ? EyeOffIcon : EyeIcon} size="sm" className="text-gray-500" />
</button>
)}
</div>
</Card>
</Grid>
<Card className="mt-4 p-4">
<Text className="text-xs font-medium text-gray-500 uppercase tracking-wide">Cost Configuration</Text>
{/* Overview Panel */}
<TabsContent value="0">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card className="p-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Transport</p>
<div className="mt-3">
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
{getTransportBadge(handleTransport(mcpServer.transport ?? undefined, mcpServer.spec_path ?? undefined))}
</div>
</Card>
</TabPanel>
{/* Tool Panel */}
<TabPanel>
<MCPToolsViewer
serverId={mcpServer.server_id}
accessToken={accessToken}
auth_type={mcpServer.auth_type}
oauth2_flow={mcpServer.oauth2_flow}
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
dcr_bridge={mcpServer.dcr_bridge}
tokenUrl={mcpServer.token_url}
userRole={userRole}
userID={userID}
serverAlias={mcpServer.alias}
extraHeaders={mcpServer.extra_headers}
/>
</TabPanel>
<Card className="p-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Authentication</p>
<div className="mt-3">{getAuthBadge(handleAuth(mcpServer.auth_type ?? undefined))}</div>
</Card>
{/* Settings Panel */}
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">
<Title>MCP Server Settings</Title>
{editing ? null : (
<Button variant="light" onClick={() => setEditing(true)}>
Edit Settings
<Card className="p-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Host URL</p>
<div className="mt-3 flex items-center gap-2">
<p className="overflow-wrap-anywhere font-mono text-sm break-all">
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
</p>
{/* Only proxy admins may reveal the raw URL — non-admins
receive a sanitized server object from the backend
with `url=null`, but hide the toggle anyway as
defense-in-depth in case the URL ever leaks back
into the response. */}
{hasToken && isProxyAdmin && (
<Button
variant="ghost"
size="icon-sm"
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
onClick={() => setShowFullUrl(!showFullUrl)}
>
{showFullUrl ? <EyeOff /> : <Eye />}
</Button>
)}
</div>
{editing ? (
<MCPServerEdit
mcpServer={mcpServer}
accessToken={accessToken}
userID={userID}
onCancel={() => setEditing(false)}
onSuccess={handleSuccess}
availableAccessGroups={availableAccessGroups}
/>
) : (
<div className="divide-y divide-gray-100">
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Server Name</Text>
<div className="col-span-2 text-sm text-gray-900">
{mcpServer.server_name || <span className="text-gray-400">—</span>}
</div>
</Card>
</div>
<Card className="mt-4 p-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Cost Configuration</p>
<div className="mt-3">
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
</div>
</Card>
</TabsContent>
{/* Tool Panel */}
<TabsContent value="1">
<MCPToolsViewer
serverId={mcpServer.server_id}
accessToken={accessToken}
auth_type={mcpServer.auth_type}
oauth2_flow={mcpServer.oauth2_flow}
delegate_auth_to_upstream={mcpServer.delegate_auth_to_upstream}
dcr_bridge={mcpServer.dcr_bridge}
tokenUrl={mcpServer.token_url}
userRole={userRole}
userID={userID}
serverAlias={mcpServer.alias}
extraHeaders={mcpServer.extra_headers}
/>
</TabsContent>
{/* Settings Panel */}
<TabsContent value="2">
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-medium">MCP Server Settings</h2>
{editing ? null : (
<Button variant="outline" onClick={() => setEditing(true)}>
Edit Settings
</Button>
)}
</div>
{editing ? (
<MCPServerEdit
mcpServer={mcpServer}
accessToken={accessToken}
userID={userID}
onCancel={() => setEditing(false)}
onSuccess={handleSuccess}
availableAccessGroups={availableAccessGroups}
/>
) : (
<div className="divide-y divide-border">
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Server Name</p>
<div className="col-span-2 text-sm">
{mcpServer.server_name || <span className="text-muted-foreground">—</span>}
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Alias</Text>
<div className="col-span-2 text-sm font-mono text-gray-900">
{mcpServer.alias || <span className="text-gray-400">—</span>}
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Alias</p>
<div className="col-span-2 font-mono text-sm">
{mcpServer.alias || <span className="text-muted-foreground">—</span>}
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Description</Text>
<div className="col-span-2 text-sm text-gray-900">
{mcpServer.description || <span className="text-gray-400">—</span>}
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Description</p>
<div className="col-span-2 text-sm">
{mcpServer.description || <span className="text-muted-foreground">—</span>}
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">URL</Text>
<div className="col-span-2 text-sm font-mono text-gray-900 break-all flex items-center gap-2">
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
{hasToken && (
<button
onClick={() => setShowFullUrl(!showFullUrl)}
className="p-1 hover:bg-gray-100 rounded-sm shrink-0"
>
<Icon icon={showFullUrl ? EyeOffIcon : EyeIcon} size="sm" className="text-gray-500" />
</button>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">URL</p>
<div className="col-span-2 flex items-center gap-2 font-mono text-sm break-all">
{renderUrlWithToggle(mcpServer.url, showFullUrl)}
{hasToken && (
<Button
variant="ghost"
size="icon-sm"
aria-label={showFullUrl ? "Hide full URL" : "Show full URL"}
onClick={() => setShowFullUrl(!showFullUrl)}
>
{showFullUrl ? <EyeOff /> : <Eye />}
</Button>
)}
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Transport</Text>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Transport</p>
<div className="col-span-2">
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Authentication</p>
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Extra Headers</p>
<div className="col-span-2 text-sm">
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
mcpServer.extra_headers.join(", ")
) : (
<span className="text-muted-foreground">—</span>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Allow All Keys</p>
<div className="col-span-2">
{mcpServer.allow_all_keys ? (
<Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Enabled
</Badge>
) : (
<Badge variant="outline">Disabled</Badge>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Network Access</p>
<div className="col-span-2">
{mcpServer.available_on_public_internet ? (
<Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Public
</Badge>
) : (
<Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-orange-500" />
Internal only
</Badge>
)}
</div>
</div>
{handleAuth(mcpServer.auth_type) === "oauth2" && (
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Delegate Auth to Upstream</p>
<div className="col-span-2">
{getTransportBadge(handleTransport(mcpServer.transport, mcpServer.spec_path))}
</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Authentication</Text>
<div className="col-span-2">{getAuthBadge(handleAuth(mcpServer.auth_type))}</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Extra Headers</Text>
<div className="col-span-2 text-sm text-gray-900">
{mcpServer.extra_headers && mcpServer.extra_headers.length > 0 ? (
mcpServer.extra_headers.join(", ")
{mcpServer.delegate_auth_to_upstream ? (
<Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Enabled (PKCE passthrough)
</Badge>
) : (
<span className="text-gray-400">—</span>
<Badge variant="outline">Disabled</Badge>
)}
</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Allow All Keys</Text>
<div className="col-span-2">
{mcpServer.allow_all_keys ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Enabled
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
Disabled
</span>
)}
</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Network Access</Text>
<div className="col-span-2">
{mcpServer.available_on_public_internet ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Public
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-orange-50 text-orange-700 rounded-full border border-orange-200 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-orange-500"></span>
Internal only
</span>
)}
</div>
</div>
{handleAuth(mcpServer.auth_type) === "oauth2" && (
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Delegate Auth to Upstream</Text>
)}
{handleAuth(mcpServer.auth_type) !== "oauth2" &&
Array.isArray(mcpServer.extra_headers) &&
mcpServer.extra_headers.some((h) => typeof h === "string" && h.toLowerCase() === "authorization") && (
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">OAuth Pass-through</p>
<div className="col-span-2">
{mcpServer.delegate_auth_to_upstream ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Enabled (PKCE passthrough)
</span>
{mcpServer.oauth_passthrough ? (
<Badge variant="outline">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Enabled
</Badge>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
Disabled
</span>
<Badge variant="outline">Disabled</Badge>
)}
</div>
</div>
)}
{handleAuth(mcpServer.auth_type) !== "oauth2" &&
Array.isArray(mcpServer.extra_headers) &&
mcpServer.extra_headers.some(
(h) => typeof h === "string" && h.toLowerCase() === "authorization",
) && (
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">OAuth Pass-through</Text>
<div className="col-span-2">
{mcpServer.oauth_passthrough ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Enabled
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
Disabled
</span>
)}
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Access Groups</p>
<div className="col-span-2">
{mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{mcpServer.mcp_access_groups.map((group: any, index: number) => (
<Badge key={index} variant="secondary">
{typeof group === "string" ? group : group?.name ?? ""}
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">—</span>
)}
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Access Groups</Text>
<div className="col-span-2">
{mcpServer.mcp_access_groups && mcpServer.mcp_access_groups.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{mcpServer.mcp_access_groups.map((group: any, index: number) => (
<span
key={index}
className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-gray-100 text-gray-700 border border-gray-200"
>
{typeof group === "string" ? group : group?.name ?? ""}
</span>
))}
</div>
) : (
<span className="text-sm text-gray-400">—</span>
)}
</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Allowed Tools</Text>
<div className="col-span-2">
{mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{mcpServer.allowed_tools.map((tool: string, index: number) => (
<span
key={index}
className="inline-flex items-center text-xs font-mono font-medium px-2 py-0.5 rounded-sm bg-blue-50 text-blue-700 border border-blue-200"
>
{tool}
</span>
))}
</div>
) : (
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-sm bg-green-50 text-green-700 border border-green-200">
All tools enabled
</span>
)}
</div>
</div>
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Cost</Text>
<div className="col-span-2">
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
</div>
</div>
</div>
)}
</Card>
</TabPanel>
</TabPanels>
</TabGroup>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Allowed Tools</p>
<div className="col-span-2">
{mcpServer.allowed_tools && mcpServer.allowed_tools.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{mcpServer.allowed_tools.map((tool: string, index: number) => (
<Badge key={index} variant="secondary" className="font-mono">
{tool}
</Badge>
))}
</div>
) : (
<Badge variant="outline">All tools enabled</Badge>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 py-3">
<p className="text-sm font-medium text-muted-foreground">Cost</p>
<div className="col-span-2">
<MCPServerCostDisplay costConfig={mcpServer.mcp_info?.mcp_server_cost_info} />
</div>
</div>
</div>
)}
</Card>
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -1,8 +1,21 @@
import { isAdminRole } from "@/utils/roles";
import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons";
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import { CircleHelp, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import NewBadge from "@/components/common_components/NewBadge";
import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
@ -75,7 +88,6 @@ const compareServers = (a: MCPServer, b: MCPServer, sort: SortKey): number => {
}
};
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
// Server id stashed by the Tools tab before an OBO OAuth redirect, read once at
@ -95,8 +107,6 @@ const readToolsOAuthServerId = (): string | null => {
}
};
const { Option } = Select;
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
@ -240,6 +250,15 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}, [serversWithHealth]);
// Get unique MCP access groups from all servers
const teamSelectItems = React.useMemo(
() => ({
all: isInternalUser ? "All Available Servers" : "All Servers",
personal: "Personal",
...Object.fromEntries(uniqueTeams.map((team) => [team.team_id, team.team_alias || team.team_id])),
}),
[isInternalUser, uniqueTeams],
);
const uniqueMcpAccessGroups = React.useMemo(() => {
if (!serversWithHealth) return [];
return Array.from(
@ -251,6 +270,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
);
}, [serversWithHealth]);
const accessGroupSelectItems = React.useMemo(
() => ({
all: "All Access Groups",
...Object.fromEntries(uniqueMcpAccessGroups.map((group) => [group, group])),
}),
[uniqueMcpAccessGroups],
);
// Filtering logic for both team and access group
const filterServers = useCallback(
(teamId: string, group: string) => {
@ -390,131 +417,135 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}
return (
<div className="w-full h-full p-6">
<Modal
open={isDeleteModalOpen}
title="Delete MCP Server?"
onOk={confirmDelete}
okText={isDeletingServer ? "Deleting..." : "Delete"}
onCancel={cancelDelete}
cancelText="Cancel"
cancelButtonProps={{ disabled: isDeletingServer }}
okButtonProps={{ danger: true }}
confirmLoading={isDeletingServer}
>
<div className="space-y-4">
<AntdText className="text-gray-600">
This action is permanent and cannot be undone. All associated configurations will be removed.
</AntdText>
<TooltipProvider>
<div className="h-full w-full p-6">
<AlertDialog open={isDeleteModalOpen} onOpenChange={(open) => !open && cancelDelete()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete MCP Server?</AlertDialogTitle>
</AlertDialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
This action is permanent and cannot be undone. All associated configurations will be removed.
</p>
{serverToDelete && (
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<Descriptions column={1} size="small" colon={false}>
{serverToDelete.server_name && (
<Descriptions.Item label={<span className="text-gray-500 text-sm">Name</span>}>
<AntdText strong className="text-sm">
{serverToDelete.server_name}
</AntdText>
</Descriptions.Item>
)}
<Descriptions.Item label={<span className="text-gray-500 text-sm">ID</span>}>
<AntdText code className="text-xs">
{serverToDelete.server_id}
</AntdText>
</Descriptions.Item>
{serverToDelete.url && (
<Descriptions.Item label={<span className="text-gray-500 text-sm">URL</span>}>
<AntdText code className="text-xs break-all">
{serverToDelete.url}
</AntdText>
</Descriptions.Item>
)}
</Descriptions>
{serverToDelete && (
<dl className="mt-3 space-y-1 rounded-lg border border-border bg-muted p-4">
{serverToDelete.server_name && (
<div className="flex gap-2">
<dt className="text-sm text-muted-foreground">Name</dt>
<dd className="text-sm font-semibold">{serverToDelete.server_name}</dd>
</div>
)}
<div className="flex gap-2">
<dt className="text-sm text-muted-foreground">ID</dt>
<dd className="font-mono text-xs">{serverToDelete.server_id}</dd>
</div>
{serverToDelete.url && (
<div className="flex gap-2">
<dt className="text-sm text-muted-foreground">URL</dt>
<dd className="font-mono text-xs break-all">{serverToDelete.url}</dd>
</div>
)}
</dl>
)}
</div>
)}
</div>
</Modal>
<CreateMCPServer
userRole={userRole}
userID={userID}
accessToken={accessToken}
onCreateSuccess={handleCreateSuccess}
isModalVisible={isModalVisible}
setModalVisible={setModalVisible}
availableAccessGroups={uniqueMcpAccessGroups}
prefillData={prefillData}
onBackToDiscovery={() => {
setModalVisible(false);
setPrefillData(null);
setDiscoveryVisible(true);
}}
/>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<Title>MCP Servers</Title>
{filteredServers.length > 0 && (
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 border border-gray-200">
{filteredServers.length}
</span>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingServer}>Cancel</AlertDialogCancel>
<Button variant="destructive" disabled={isDeletingServer} onClick={confirmDelete}>
{isDeletingServer ? "Deleting..." : "Delete"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateMCPServer
userRole={userRole}
userID={userID}
accessToken={accessToken}
onCreateSuccess={handleCreateSuccess}
isModalVisible={isModalVisible}
setModalVisible={setModalVisible}
availableAccessGroups={uniqueMcpAccessGroups}
prefillData={prefillData}
onBackToDiscovery={() => {
setModalVisible(false);
setPrefillData(null);
setDiscoveryVisible(true);
}}
/>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-xl font-semibold">MCP Servers</h1>
{filteredServers.length > 0 && <Badge variant="secondary">{filteredServers.length}</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">Configure and manage your MCP servers</p>
</div>
<div className="flex items-center gap-2">
{isAdminRole(userRole) && (
<Button className="shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
)}
{!isAdminRole(userRole) && (
<Button
className="shrink-0"
onClick={() => {
setPrefillData(null);
setModalVisible(true);
}}
variant="secondary"
>
+ Submit MCP Server
</Button>
)}
</div>
<Text className="text-tremor-content mt-1">Configure and manage your MCP servers</Text>
</div>
<div className="flex items-center gap-2">
{isAdminRole(userRole) && (
<Button className="shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
)}
{!isAdminRole(userRole) && (
<Button
className="shrink-0"
onClick={() => {
setPrefillData(null);
setModalVisible(true);
}}
variant="secondary"
>
+ Submit MCP Server
</Button>
)}
</div>
</div>
<MCPDiscovery
isVisible={isDiscoveryVisible}
onClose={() => setDiscoveryVisible(false)}
onSelectServer={(server: DiscoverableMCPServer) => {
setPrefillData(server);
setDiscoveryVisible(false);
setModalVisible(true);
}}
onCustomServer={() => {
setPrefillData(null);
setDiscoveryVisible(false);
setModalVisible(true);
}}
accessToken={accessToken}
/>
<TabGroup className="w-full h-full">
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">
<Tab>All Servers</Tab>
<Tab>Toolsets</Tab>
<Tab>Connect</Tab>
{isAdminRole(userRole) && <Tab>Semantic Filter</Tab>}
{isAdminRole(userRole) && <Tab>Network Settings</Tab>}
<MCPDiscovery
isVisible={isDiscoveryVisible}
onClose={() => setDiscoveryVisible(false)}
onSelectServer={(server: DiscoverableMCPServer) => {
setPrefillData(server);
setDiscoveryVisible(false);
setModalVisible(true);
}}
onCustomServer={() => {
setPrefillData(null);
setDiscoveryVisible(false);
setModalVisible(true);
}}
accessToken={accessToken}
/>
<Tabs defaultValue="servers" className="mt-2 w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="servers" className="flex-none">
All Servers
</TabsTrigger>
<TabsTrigger value="toolsets" className="flex-none">
Toolsets
</TabsTrigger>
<TabsTrigger value="connect" className="flex-none">
Connect
</TabsTrigger>
{isAdminRole(userRole) && (
<Tab>
<TabsTrigger value="semantic-filter" className="flex-none">
Semantic Filter
</TabsTrigger>
)}
{isAdminRole(userRole) && (
<TabsTrigger value="network-settings" className="flex-none">
Network Settings
</TabsTrigger>
)}
{isAdminRole(userRole) && (
<TabsTrigger value="submitted" className="flex-none">
<span className="flex items-center gap-2">
Submitted MCPs <NewBadge />
</span>
</Tab>
</TabsTrigger>
)}
</div>
</TabList>
<TabPanels>
<TabPanel>
</TabsList>
<TabsContent value="servers">
{selectedServerId ? (
<MCPServerView
key={selectedServerId}
@ -532,94 +563,117 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<div className="w-full h-full">
<div className="w-full">
<div className="flex flex-col space-y-4">
<div className="flex items-center gap-6 bg-white rounded-lg px-4 py-3 border border-gray-200">
<div className="flex items-center gap-6 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2">
<Text className="text-sm font-medium text-gray-600 whitespace-nowrap">Team</Text>
<Select value={selectedTeam} onChange={handleTeamChange} style={{ width: 220 }} size="middle">
<Option value="all">
<span className="font-medium">
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Team</p>
<Select
items={teamSelectItems}
value={selectedTeam}
onValueChange={(v: string | null) => handleTeamChange(v ?? "all")}
>
<SelectTrigger className="w-55">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{isInternalUser ? "All Available Servers" : "All Servers"}
</span>
</Option>
<Option value="personal">
<span className="font-medium">Personal</span>
</Option>
{uniqueTeams.map((team) => (
<Option key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias || team.team_id}</span>
</Option>
))}
</SelectItem>
<SelectItem value="personal">Personal</SelectItem>
{uniqueTeams.map((team) => (
<SelectItem key={team.team_id} value={team.team_id}>
{team.team_alias || team.team_id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="h-6 w-px bg-gray-200"></div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2">
<Text className="text-sm font-medium text-gray-600 whitespace-nowrap">
<p className="flex items-center text-sm font-medium whitespace-nowrap text-muted-foreground">
Access Group
<Tooltip title="An MCP Access Group is a set of users or teams that have permission to access specific MCP servers. Use access groups to control and organize who can connect to which servers.">
<QuestionCircleOutlined style={{ marginLeft: 4, color: "#9ca3af" }} />
<Tooltip>
<TooltipTrigger
render={
<CircleHelp
className="ml-1 size-3.5 text-muted-foreground"
aria-label="About access groups"
/>
}
/>
<TooltipContent>
An MCP Access Group is a set of users or teams that have permission to access specific MCP
servers. Use access groups to control and organize who can connect to which servers.
</TooltipContent>
</Tooltip>
</Text>
</p>
<Select
items={accessGroupSelectItems}
value={selectedMcpAccessGroup}
onChange={handleMcpAccessGroupChange}
style={{ width: 220 }}
size="middle"
onValueChange={(v: string | null) => handleMcpAccessGroupChange(v ?? "all")}
>
<Option value="all">
<span className="font-medium">All Access Groups</span>
</Option>
{uniqueMcpAccessGroups.map((group) => (
<Option key={group} value={group}>
<span className="font-medium">{group}</span>
</Option>
))}
<SelectTrigger className="w-55">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Access Groups</SelectItem>
{uniqueMcpAccessGroups.map((group) => (
<SelectItem key={group} value={group}>
{group}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<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 }}
/>
<InputGroup className="max-w-80">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search by name, alias, URL, or ID"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
<div className="flex items-center gap-2">
<Text className="whitespace-nowrap text-sm font-medium text-gray-600">Sort</Text>
<p className="text-sm font-medium whitespace-nowrap text-muted-foreground">Sort</p>
<Select
items={SORT_OPTIONS}
value={sortKey}
onChange={(v: SortKey) => setSortKey(v)}
style={{ width: 220 }}
size="middle"
onValueChange={(v: string | null) => setSortKey((v ?? "created_desc") as SortKey)}
>
{SORT_OPTIONS.map((opt) => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
<SelectTrigger className="w-55">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="ml-auto text-xs text-gray-500">
<div className="ml-auto text-xs text-muted-foreground">
{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 className="flex items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-card p-12">
<UiLoadingSpinner className="size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading MCP servers...</p>
</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 className="rounded-lg border border-dashed border-border bg-card p-12 text-center">
<p className="text-sm text-muted-foreground">
{filteredServers.length === 0
? "No MCP servers configured. Click '+ Add New MCP Server' to get started."
: "No servers match the current filters or search."}
</p>
</div>
) : (
<div
@ -650,59 +704,59 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
</div>
)}
</TabPanel>
<TabPanel>
</TabsContent>
<TabsContent value="toolsets">
<MCPToolsetsTab accessToken={accessToken} userRole={userRole} />
</TabPanel>
<TabPanel>
</TabsContent>
<TabsContent value="connect">
<MCPConnect />
</TabPanel>
</TabsContent>
{isAdminRole(userRole) && (
<TabPanel>
<TabsContent value="semantic-filter">
<MCPSemanticFilterSettings accessToken={accessToken} />
</TabPanel>
</TabsContent>
)}
{isAdminRole(userRole) && (
<TabPanel>
<TabsContent value="network-settings">
<MCPNetworkSettings accessToken={accessToken} />
</TabPanel>
</TabsContent>
)}
{isAdminRole(userRole) && (
<TabPanel>
<TabsContent value="submitted">
<MCPSubmissionsTab accessToken={accessToken} />
</TabPanel>
</TabsContent>
)}
</TabPanels>
</TabGroup>
</Tabs>
{byokModalServer && (
<ByokCredentialModal
server={byokModalServer}
open={!!byokModalServer}
onClose={() => setByokModalServer(null)}
onSuccess={(_serverId) => {
refetch();
setByokModalServer(null);
{byokModalServer && (
<ByokCredentialModal
server={byokModalServer}
open={!!byokModalServer}
onClose={() => setByokModalServer(null)}
onSuccess={(_serverId) => {
refetch();
setByokModalServer(null);
}}
/>
)}
{/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */}
<UserEnvVarsModal
server={activeEnvVarsServer}
open={!!activeEnvVarsServer}
accessToken={accessToken}
onClose={() => {
setEnvVarsModalServer(null);
setDeepLinkServerId(null);
}}
onSaved={() => {
// Refresh the bulk status so the red "N user fields missing" footer
// on each card clears once the user has filled in their values.
refetchEnvVarStatus();
}}
/>
)}
{/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */}
<UserEnvVarsModal
server={activeEnvVarsServer}
open={!!activeEnvVarsServer}
accessToken={accessToken}
onClose={() => {
setEnvVarsModalServer(null);
setDeepLinkServerId(null);
}}
onSaved={() => {
// Refresh the bulk status so the red "N user fields missing" footer
// on each card clears once the user has filled in their values.
refetchEnvVarStatus();
}}
/>
</div>
</div>
</TooltipProvider>
);
};

View file

@ -1,7 +1,14 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Card, Title, Text } from "@tremor/react";
import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons";
import { Badge, Spin, Checkbox, Input, Radio } from "antd";
import { Wrench, CircleCheck, Search, Pencil } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Textarea } from "@/components/ui/textarea";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
import McpCrudPermissionPanel from "@/components/mcp_tools/McpCrudPermissionPanel";
import { TOOL_DISPLAY_NAME_PATTERN } from "./utils";
@ -67,86 +74,74 @@ const ToolRow: React.FC<ToolRowProps> = ({
return (
<div
className={`rounded-lg border transition-colors ${
isEnabled
? "bg-blue-50 border-blue-300 hover:border-blue-400"
: "bg-gray-50 border-gray-200 hover:border-gray-300"
}`}
className={cn(
"rounded-lg border transition-colors",
isEnabled ? "border-primary/40 bg-accent" : "border-border bg-muted",
)}
>
<div className="p-4 cursor-pointer" onClick={() => onToggle(tool.name)}>
<div className="cursor-pointer p-4" onClick={() => onToggle(tool.name)}>
<div className="flex items-start gap-3">
<Checkbox checked={isEnabled} onChange={() => onToggle(tool.name)} />
<Checkbox checked={isEnabled} onCheckedChange={() => onToggle(tool.name)} />
<div className="flex-1">
<div className="flex items-center gap-2">
<Text className="font-medium text-gray-900">{toolNameToDisplayName[tool.name] || tool.name}</Text>
<span
className={`px-2 py-0.5 text-xs rounded-full font-medium ${
isEnabled ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{isEnabled ? "Enabled" : "Disabled"}
</span>
{toolNameToDisplayName[tool.name] && (
<span className="px-2 py-0.5 text-xs rounded-full font-medium bg-purple-100 text-purple-800">
Custom name
</span>
)}
<p className="text-sm font-medium">{toolNameToDisplayName[tool.name] || tool.name}</p>
<Badge variant={isEnabled ? "secondary" : "outline"}>{isEnabled ? "Enabled" : "Disabled"}</Badge>
{toolNameToDisplayName[tool.name] && <Badge variant="secondary">Custom name</Badge>}
</div>
{(toolNameToDescription[tool.name] || tool.description) && (
<Text className="text-gray-500 text-sm block mt-1">
<p className="mt-1 block text-sm text-muted-foreground">
{toolNameToDescription[tool.name] || tool.description}
</Text>
</p>
)}
<Text className="text-gray-400 text-xs block mt-1">
<p className="mt-1 block text-xs text-muted-foreground">
{isEnabled ? "✓ Users can call this tool" : "✗ Users cannot call this tool"}
</Text>
</p>
</div>
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => onToggleExpand(tool.name, e)}
className={`p-1.5 rounded-md transition-colors ${
isEditExpanded ? "bg-blue-100 text-blue-600" : "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
}`}
title="Edit display name and description"
>
<EditOutlined />
</button>
<Pencil />
</Button>
</div>
</div>
{isEditExpanded && (
<div
className="px-4 pb-4 pt-3 border-t border-gray-200 space-y-3 bg-gray-50 rounded-b-lg"
className="space-y-3 rounded-b-lg border-t border-border bg-muted px-4 pt-3 pb-4"
onClick={(e) => e.stopPropagation()}
>
<div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">Display Name</Text>
<p className="mb-1 block text-xs font-medium">Display Name</p>
<Input
placeholder={tool.name}
value={toolNameToDisplayName[tool.name] || ""}
onChange={(e) => onDisplayNameChange(tool.name, e.target.value)}
status={isDisplayNameInvalid ? "error" : undefined}
aria-invalid={isDisplayNameInvalid || undefined}
/>
{isDisplayNameInvalid ? (
<Text className="text-xs text-red-500 mt-1 block">
<p className="mt-1 block text-xs text-destructive">
Only letters, digits, underscores, and hyphens are allowed (no spaces).
</Text>
</p>
) : (
<Text className="text-xs text-gray-400 mt-1 block">
<p className="mt-1 block text-xs text-muted-foreground">
Override how this tool&apos;s name appears to users. Leave blank to use original.
</Text>
</p>
)}
</div>
<div>
<Text className="text-xs font-medium text-gray-600 mb-1 block">Description</Text>
<Input.TextArea
<p className="mb-1 block text-xs font-medium">Description</p>
<Textarea
className="field-sizing-fixed"
placeholder={tool.description || "No description"}
value={toolNameToDescription[tool.name] || ""}
onChange={(e) => onDescriptionChange(tool.name, e.target.value)}
rows={2}
/>
<Text className="text-xs text-gray-400 mt-1 block">
<p className="mt-1 block text-xs text-muted-foreground">
Override the tool description shown to users. Leave blank to use original.
</Text>
</p>
</div>
</div>
)}
@ -398,65 +393,62 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
}
return (
<Card>
<Card className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ToolOutlined className="text-blue-600" />
<Title>Tool Configuration</Title>
{tools.length > 0 && (
<Badge
count={tools.length}
style={{
backgroundColor: "#52c41a",
}}
/>
)}
<Wrench className="size-4 text-muted-foreground" />
<h3 className="text-lg font-medium">Tool Configuration</h3>
{tools.length > 0 && <Badge variant="secondary">{tools.length}</Badge>}
</div>
{tools.length > 0 && (
<Radio.Group
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
options={[
{ label: "Risk Groups", value: "crud" },
{ label: "Flat List", value: "flat" },
]}
/>
<div className="flex items-center gap-1">
<Button
size="sm"
variant={viewMode === "crud" ? "default" : "outline"}
onClick={() => setViewMode("crud")}
>
Risk Groups
</Button>
<Button
size="sm"
variant={viewMode === "flat" ? "default" : "outline"}
onClick={() => setViewMode("flat")}
>
Flat List
</Button>
</div>
)}
</div>
{/* Description */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-blue-800 text-sm">
<div className="rounded-lg border border-border bg-muted p-3">
<p className="text-sm">
<strong>Select which tools users can call:</strong> Only checked tools will be available for users to
invoke. Unchecked tools will be blocked from execution.
</Text>
</p>
</div>
{/* Loading state */}
{isLoadingTools && (
<div className="flex items-center justify-center py-6">
<Spin size="large" />
<Text className="ml-3">Loading tools from spec...</Text>
<div className="flex items-center justify-center gap-3 py-6">
<UiLoadingSpinner className="size-6 text-muted-foreground" />
<p className="text-sm">Loading tools from spec...</p>
</div>
)}
{/* Error state */}
{toolsError && !isLoadingTools && isPreviewForbidden && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<Text className="text-sm text-blue-800">{toolsError}</Text>
<div className="rounded-lg border border-border bg-muted p-4">
<p className="text-sm">{toolsError}</p>
</div>
)}
{toolsError && !isLoadingTools && !isPreviewForbidden && (
<div className="text-center py-6 text-red-500 border rounded-lg border-dashed border-red-300 bg-red-50">
<ToolOutlined className="text-2xl mb-2" />
<Text className="text-red-600 font-medium">Unable to load tools</Text>
<br />
<Text className="text-sm text-red-500">{toolsError}</Text>
<div className="rounded-lg border border-dashed border-destructive/40 bg-destructive/5 py-6 text-center">
<Wrench className="mx-auto mb-2 size-6 text-destructive" />
<p className="text-sm font-medium text-destructive">Unable to load tools</p>
<p className="text-sm text-destructive">{toolsError}</p>
</div>
)}
@ -466,51 +458,50 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
tools.length === 0 &&
canFetchTools &&
(keyTools && keyTools.length > 0 ? (
<div className="text-center py-4 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>No tools loaded from spec</Text>
<Text className="text-sm block mt-1">Expected tools: {keyTools.map((t) => t.name).join(", ")}</Text>
<div className="rounded-lg border border-dashed py-4 text-center text-muted-foreground">
<Wrench className="mx-auto mb-2 size-6" />
<p className="text-sm">No tools loaded from spec</p>
<p className="mt-1 block text-sm">Expected tools: {keyTools.map((t) => t.name).join(", ")}</p>
</div>
) : (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>No tools available for configuration</Text>
<br />
<Text className="text-sm">Connect to an MCP server with tools to configure them</Text>
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<Wrench className="mx-auto mb-2 size-6" />
<p className="text-sm">No tools available for configuration</p>
<p className="text-sm">Connect to an MCP server with tools to configure them</p>
</div>
))}
{/* Incomplete form state */}
{!canFetchTools && (formValues.url || formValues.spec_path) && (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>Complete required fields to configure tools</Text>
<br />
<Text className="text-sm">Fill in URL, Transport, and Authentication to load available tools</Text>
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<Wrench className="mx-auto mb-2 size-6" />
<p className="text-sm">Complete required fields to configure tools</p>
<p className="text-sm">Fill in URL, Transport, and Authentication to load available tools</p>
</div>
)}
{/* Tools loaded successfully */}
{!isLoadingTools && !toolsError && tools.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200">
<CheckCircleOutlined className="text-green-600" />
<Text className="text-green-700 font-medium">
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted p-3">
<CircleCheck className="size-4" />
<p className="text-sm font-medium">
{effectiveAllowedTools.length} of {tools.length} {tools.length === 1 ? "tool" : "tools"} enabled for
user access
</Text>
</p>
</div>
{/* Search box shared by both views */}
<Input
placeholder="Search tools by name or description..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="large"
/>
<InputGroup className="w-full">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search tools by name or description..."
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
/>
</InputGroup>
{/* CRUD grouped view */}
{viewMode === "crud" && (
@ -526,31 +517,25 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
{viewMode === "flat" && (
<>
{filteredTools.length === 0 ? (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<SearchOutlined className="text-2xl mb-2" />
<Text>No tools found matching &quot;{toolSearchTerm}&quot;</Text>
<div className="rounded-lg border border-dashed py-6 text-center text-muted-foreground">
<Search className="mx-auto mb-2 size-6" />
<p className="text-sm">No tools found matching &quot;{toolSearchTerm}&quot;</p>
</div>
) : (
<div className="space-y-2">
{pinnedFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Suggested tools</p>
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Suggested tools
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableSuggested}
className="text-xs text-blue-600 hover:text-blue-700"
>
<Button variant="link" size="sm" onClick={handleEnableSuggested}>
Enable all
</button>
<button
type="button"
onClick={handleDisableSuggested}
className="text-xs text-gray-500 hover:text-gray-700"
>
</Button>
<Button variant="link" size="sm" onClick={handleDisableSuggested}>
Disable all
</button>
</Button>
</div>
</div>
{pinnedFiltered.map((tool) => (
@ -572,24 +557,16 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
{restFiltered.length > 0 && (
<>
<div className="flex items-center justify-between px-1 pt-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{pinnedFiltered.length > 0 ? "All tools" : "Tools"}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleEnableRest}
className="text-xs text-blue-600 hover:text-blue-700"
>
<Button variant="link" size="sm" onClick={handleEnableRest}>
Enable all
</button>
<button
type="button"
onClick={handleDisableRest}
className="text-xs text-gray-500 hover:text-gray-700"
>
</Button>
<Button variant="link" size="sm" onClick={handleDisableRest}>
Disable all
</button>
</Button>
</div>
</div>
{restFiltered.map((tool) => (

View file

@ -19,9 +19,13 @@ import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
import { TOOLS_OAUTH_UI_STATE_KEY } from "@/hooks/mcpOAuthUtils";
import { setSecureItem } from "@/utils/secureStorage";
import { Card, Title, Text } from "@tremor/react";
import { RobotOutlined, ToolOutlined, SearchOutlined, KeyOutlined, LockOutlined } from "@ant-design/icons";
import { Input, Button as AntdButton } from "antd";
import { Bot, Wrench, Search, Key, Lock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { cn } from "@/lib/cva.config";
const MCPToolsViewer = ({
serverId,
@ -285,79 +289,75 @@ const MCPToolsViewer = ({
});
return (
<div className="w-full h-screen p-4 bg-white">
<Card className="w-full rounded-xl shadow-md overflow-hidden">
<div className="flex h-auto w-full gap-4">
<div className="w-full p-4">
<Card className="w-full overflow-hidden rounded-xl shadow-md">
<div className="grid h-auto w-full grid-cols-4 gap-4">
{/* Left Sidebar with Controls */}
<div className="w-1/4 p-4 bg-gray-50 flex flex-col">
<Title className="text-xl font-semibold mb-6 mt-2">MCP Tools</Title>
<div className="col-span-1 flex flex-col bg-muted p-4">
<h2 className="mt-2 mb-6 text-xl font-semibold">MCP Tools</h2>
<div className="flex flex-col flex-1">
{/* Extra Headers Input Section */}
{hasExtraHeaders && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="mb-4 rounded-lg border border-border bg-card p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center">
<KeyOutlined className="text-blue-600 mr-2" />
<Text className="text-sm font-medium text-blue-800">Additional Headers</Text>
<Key className="mr-2 size-4 text-muted-foreground" />
<p className="text-sm font-medium">Additional Headers</p>
</div>
<AntdButton
size="small"
type="link"
onClick={() => setShowHeaderInput(!showHeaderInput)}
className="text-blue-700 p-0 h-auto"
>
<Button variant="link" size="sm" onClick={() => setShowHeaderInput(!showHeaderInput)}>
{showHeaderInput ? "Hide" : "Configure"}
</AntdButton>
</Button>
</div>
{!showHeaderInput && Object.keys(passthroughHeaders).length === 0 && (
<Text className="text-xs text-blue-700">
<p className="text-xs text-muted-foreground">
This server requires additional headers. Click &quot;Configure&quot; to provide values.
</Text>
</p>
)}
{showHeaderInput && (
<div className="mt-3 space-y-2">
{extraHeaders?.map((headerName) => (
<div key={headerName}>
<label className="block text-xs font-medium text-gray-700 mb-1">{headerName}</label>
<Input
size="small"
placeholder={`Enter ${headerName}`}
value={passthroughHeaders[headerName] || ""}
onChange={(e) => {
setPassthroughHeaders({
...passthroughHeaders,
[headerName]: e.target.value,
});
}}
prefix={<KeyOutlined className="text-gray-400" />}
className="rounded-sm"
/>
<label className="mb-1 block text-xs font-medium">{headerName}</label>
<InputGroup className="w-full">
<InputGroupAddon>
<Key className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder={`Enter ${headerName}`}
value={passthroughHeaders[headerName] || ""}
onChange={(e) => {
setPassthroughHeaders({
...passthroughHeaders,
[headerName]: e.target.value,
});
}}
/>
</InputGroup>
</div>
))}
<AntdButton
size="small"
type="primary"
<Button
size="sm"
onClick={() => {
refetchTools();
setShowHeaderInput(false);
}}
disabled={Object.values(passthroughHeaders).every((v) => !v || !v.trim())}
className="w-full mt-2"
className="mt-2 w-full"
>
Load Tools
</AntdButton>
</Button>
</div>
)}
{!showHeaderInput && Object.keys(passthroughHeaders).length > 0 && (
<div className="mt-2">
<Text className="text-xs text-green-700 flex items-center">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-2"></span>
<p className="flex items-center text-xs text-muted-foreground">
<span className="mr-2 inline-block size-2 rounded-full bg-green-500" />
{Object.keys(passthroughHeaders).length} header(s) configured
</Text>
</p>
</div>
)}
</div>
@ -365,31 +365,29 @@ const MCPToolsViewer = ({
{/* Tool Selection - Show tools first */}
<div className="flex flex-col flex-1 min-h-0">
<Text className="font-medium block mb-3 text-gray-700 flex items-center">
<ToolOutlined className="mr-2" /> Available Tools
<p className="mb-3 flex items-center text-sm font-medium">
<Wrench className="mr-2 size-4" /> Available Tools
{toolsData.length > 0 && (
<span className="ml-2 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full">
<Badge variant="secondary" className="ml-2">
{toolsData.length}
</span>
</Badge>
)}
</Text>
</p>
{/* Passthrough auth gate — browser session token absent */}
{usesBrowserHeldToken && !oauthToken && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<LockOutlined className="text-2xl text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p>
<p className="text-xs text-gray-500 mb-3">Authenticate to view available tools</p>
<AntdButton
size="small"
type="primary"
loading={oauthStatus === "authorizing" || oauthStatus === "exchanging"}
<div className="rounded-lg border border-border bg-card p-4 text-center">
<Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="mb-1 text-xs font-medium">Authentication required</p>
<p className="mb-3 text-xs text-muted-foreground">Authenticate to view available tools</p>
<Button
size="sm"
onClick={startOAuthFlow}
disabled={!accessToken}
disabled={!accessToken || oauthStatus === "authorizing" || oauthStatus === "exchanging"}
>
Authorize
</AntdButton>
{oauthError && <p className="text-xs text-red-500 mt-2">{oauthError}</p>}
</Button>
{oauthError && <p className="mt-2 text-xs text-destructive">{oauthError}</p>}
</div>
)}
@ -399,22 +397,20 @@ const MCPToolsViewer = ({
with no usable refresh token). A refreshable token is refreshed
on the list call and never trips this gate. */}
{(authorizationCodeNeedsAuth || authorizationCodeTokenRejected) && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<LockOutlined className="text-2xl text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p>
<p className="text-xs text-gray-500 mb-3">
<div className="rounded-lg border border-border bg-card p-4 text-center">
<Lock className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="mb-1 text-xs font-medium">Authentication required</p>
<p className="mb-3 text-xs text-muted-foreground">
Authenticate with the upstream provider to view available tools
</p>
<AntdButton
size="small"
type="primary"
loading={dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
<Button
size="sm"
onClick={startAuthorizationCodeAuthorize}
disabled={!accessToken}
disabled={!accessToken || dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
>
Authorize
</AntdButton>
{dbOAuthError && <p className="text-xs text-red-500 mt-2">{dbOAuthError}</p>}
</Button>
{dbOAuthError && <p className="mt-2 text-xs text-destructive">{dbOAuthError}</p>}
</div>
)}
@ -423,32 +419,30 @@ const MCPToolsViewer = ({
<>
{toolsData.length > 0 && (
<div className="mb-3">
<Input
placeholder="Search tools..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="middle"
/>
<InputGroup className="w-full">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search tools..."
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
/>
</InputGroup>
</div>
)}
{/* Loading State */}
{toolsAreaLoading && (
<div className="flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg">
<div className="relative mb-3">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-200"></div>
<div className="animate-spin rounded-full h-6 w-6 border-2 border-blue-600 border-t-transparent absolute top-0"></div>
</div>
<p className="text-xs font-medium text-gray-700">Loading tools...</p>
<div className="flex flex-col items-center justify-center rounded-lg border border-border bg-card py-8">
<UiLoadingSpinner className="mb-3 size-6 text-muted-foreground" />
<p className="text-xs font-medium">Loading tools...</p>
</div>
)}
{/* Error State */}
{(mcpToolsResponse?.error || mcpToolsError) && !toolsAreaLoading && !toolsData.length && (
<div className="p-3 text-xs text-red-800 rounded-lg bg-red-50 border border-red-200">
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive">
<p className="font-medium">
Error: {mcpToolsResponse?.message || (mcpToolsError as Error)?.message}
</p>
@ -460,10 +454,10 @@ const MCPToolsViewer = ({
!mcpToolsResponse?.error &&
!mcpToolsError &&
(!toolsData || toolsData.length === 0) && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<div className="mx-auto w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center mb-2">
<div className="rounded-lg border border-border bg-card p-4 text-center">
<div className="mx-auto mb-2 flex size-8 items-center justify-center rounded-full bg-muted">
<svg
className="w-4 h-4 text-gray-400"
className="size-4 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -476,8 +470,8 @@ const MCPToolsViewer = ({
/>
</svg>
</div>
<p className="text-xs font-medium text-gray-700 mb-1">No tools available</p>
<p className="text-xs text-gray-500">No tools found for this server</p>
<p className="mb-1 text-xs font-medium">No tools available</p>
<p className="text-xs text-muted-foreground">No tools found for this server</p>
</div>
)}
@ -485,28 +479,22 @@ const MCPToolsViewer = ({
{!toolsAreaLoading && !mcpToolsResponse?.error && toolsData.length > 0 && (
<>
{filteredTools.length === 0 ? (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<SearchOutlined className="text-2xl text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-700 mb-1">No tools found</p>
<p className="text-xs text-gray-500">No tools match &quot;{toolSearchTerm}&quot;</p>
<div className="rounded-lg border border-border bg-card p-4 text-center">
<Search className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="mb-1 text-xs font-medium">No tools found</p>
<p className="text-xs text-muted-foreground">No tools match &quot;{toolSearchTerm}&quot;</p>
</div>
) : (
<div
className="space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable"
style={{
maxHeight: "400px",
scrollbarWidth: "auto",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
<div className="mcp-tools-scrollable max-h-100 min-h-0 flex-1 space-y-2 overflow-y-auto">
{filteredTools.map((tool: MCPTool) => (
<div
key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-xs ${
className={cn(
"cursor-pointer rounded-lg border p-3 transition-all hover:shadow-xs",
selectedTool?.name === tool.name
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
? "border-primary bg-accent ring-1 ring-ring"
: "border-border bg-card",
)}
onClick={() => {
setSelectedTool(tool);
setToolResult(null);
@ -522,18 +510,18 @@ const MCPToolsViewer = ({
/>
)}
<div className="flex-1 min-w-0">
<h4 className="font-mono text-xs font-medium text-gray-900 truncate">
{tool.name}
</h4>
<p className="text-xs text-gray-500 truncate">{tool.mcp_info.server_name}</p>
<p className="text-xs text-gray-600 mt-1 line-clamp-2 leading-relaxed">
<h4 className="truncate font-mono text-xs font-medium">{tool.name}</h4>
<p className="truncate text-xs text-muted-foreground">
{tool.mcp_info.server_name}
</p>
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{tool.description}
</p>
</div>
</div>
{selectedTool?.name === tool.name && (
<div className="mt-2 pt-2 border-t border-blue-200">
<div className="flex items-center text-xs font-medium text-blue-700">
<div className="mt-2 border-t border-border pt-2">
<div className="flex items-center text-xs font-medium text-primary">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
@ -558,20 +546,20 @@ const MCPToolsViewer = ({
</div>
{/* Main Testing Area */}
<div className="w-3/4 flex flex-col bg-white">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<Title className="text-xl font-semibold mb-0">Tool Testing Playground</Title>
<div className="col-span-3 flex flex-col">
<div className="flex items-center justify-between border-b border-border p-4">
<h2 className="mb-0 text-xl font-semibold">Tool Testing Playground</h2>
</div>
<div className="flex-1 overflow-auto p-4">
{!selectedTool ? (
/* Empty State */
<div className="h-full flex flex-col items-center justify-center text-gray-400">
<RobotOutlined style={{ fontSize: "48px", marginBottom: "16px" }} />
<Text className="text-lg font-medium text-gray-600 mb-2">Select a Tool to Test</Text>
<Text className="text-center text-gray-500 max-w-md">
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="mb-4 size-12" />
<p className="mb-2 text-lg font-medium">Select a Tool to Test</p>
<p className="max-w-md text-center text-sm">
Choose a tool from the left sidebar to start testing its functionality with custom inputs.
</Text>
</p>
</div>
) : (
/* Tool Test Panel */

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { Icon, Button, Col, Text, Grid } from "@tremor/react";
import { RefreshIcon } from "@heroicons/react/outline";
import { RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import TagInfoView from "./tag_info";
import { modelInfoCall } from "@/components/networking";
import { tagCreateCall, tagListCall, tagDeleteCall } from "@/components/networking";
@ -139,22 +139,18 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
editTag={editTag}
/>
) : (
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
<div className="flex justify-between mt-2 w-full items-center mb-4">
<div className="mt-2 h-[75vh] w-full gap-2 p-8">
<div className="mt-2 mb-4 flex w-full items-center justify-between">
<h1>Tag Management</h1>
<div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
<Icon
icon={RefreshIcon}
variant="shadow"
size="xs"
className="self-center cursor-pointer"
onClick={handleRefreshClick}
/>
{lastRefreshed && <p className="text-sm">Last Refreshed: {lastRefreshed}</p>}
<Button variant="outline" size="icon-sm" aria-label="Refresh tags" onClick={handleRefreshClick}>
<RefreshCw />
</Button>
</div>
</div>
<Text className="mb-4">
<div className="mb-4 text-sm">
Click on a tag name to view and edit its details.
<p>
You can use tags to restrict the usage of certain LLMs based on tags passed in the request. Read more
@ -164,14 +160,14 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
</a>
.
</p>
</Text>
</div>
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
+ Create New Tag
</Button>
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
<Col numColSpan={1}>
<div className="mt-2 grid h-[75vh] w-full grid-cols-1 gap-2 pt-2 pb-2">
<div>
<TagTable
data={tags}
isLoading={isLoadingTags}
@ -182,8 +178,8 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
onDelete={handleDelete}
onSelectTag={setSelectedTagId}
/>
</Col>
</Grid>
</div>
</div>
{/* Create Tag Modal */}
<CreateTagModal

View file

@ -1,5 +1,9 @@
import { CodeOutlined, PlayCircleOutlined } from "@ant-design/icons";
import { Alert, Button, Card, Input, Space, Tabs, Typography } from "antd";
import { Code, CircleAlert, CirclePlay, Info } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import ModelSelector from "@/components/common_components/ModelSelector";
import { TestResult } from "./semanticFilterTestUtils";
@ -30,148 +34,132 @@ export default function MCPSemanticFilterTestPanel({
testError,
curlCommand,
}: MCPSemanticFilterTestPanelProps) {
const canRunTest = testQuery && testModel && filterEnabled;
const testDisabled = isTesting || !canRunTest;
return (
<Card title="Test Configuration" style={{ marginBottom: 16 }}>
<Tabs
defaultActiveKey="test"
items={[
{
key: "test",
label: "Test",
children: (
<Space direction="vertical" style={{ width: "100%" }} size="large">
<div>
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}>
<PlayCircleOutlined /> Test Query
</Typography.Text>
<Input.TextArea
placeholder="Enter a test query to see which tools would be selected..."
value={testQuery}
onChange={(e) => setTestQuery(e.target.value)}
rows={4}
disabled={isTesting}
/>
</div>
<Card className="mb-4">
<CardHeader>
<CardTitle>Test Configuration</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="test">
<TabsList>
<TabsTrigger value="test" className="flex-none">
Test
</TabsTrigger>
<TabsTrigger value="api" className="flex-none">
API Usage
</TabsTrigger>
</TabsList>
<div>
<ModelSelector
accessToken={accessToken || ""}
value={testModel}
onChange={setTestModel}
disabled={isTesting}
showLabel={true}
labelText="Select Model"
/>
</div>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={onTest}
loading={isTesting}
disabled={!testQuery || !testModel || !filterEnabled}
block
>
Test Filter
</Button>
{!filterEnabled && (
<Alert
type="warning"
message="Semantic filtering is disabled"
description="Enable semantic filtering and save settings to test the filter."
showIcon
/>
)}
{testError && (
<Alert
type="error"
message="Semantic filtering did not run"
description={testError}
showIcon
style={{ marginBottom: 16 }}
/>
)}
{testResult && (
<div>
<Typography.Title level={5}>Results</Typography.Title>
<Alert
type={testResult.totalTools - testResult.selectedTools > 0 ? "success" : "warning"}
message={`${testResult.selectedTools} of ${testResult.totalTools} tools selected`}
description={`${testResult.totalTools - testResult.selectedTools} tools filtered out`}
showIcon
style={{ marginBottom: 16 }}
/>
<div>
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}>
Selected Tools:
</Typography.Text>
<ul style={{ paddingLeft: 20, margin: 0 }}>
{testResult.tools.map((tool, index) => (
<li key={index} style={{ marginBottom: 4 }}>
<Typography.Text>{tool}</Typography.Text>
</li>
))}
</ul>
{testResult.selectedTools > testResult.tools.length && (
<Typography.Text type="secondary" style={{ display: "block", marginTop: 8 }}>
+{testResult.selectedTools - testResult.tools.length} more selected tools not shown
</Typography.Text>
)}
</div>
</div>
)}
</Space>
),
},
{
key: "api",
label: "API Usage",
children: (
<TabsContent value="test">
<div className="flex w-full flex-col gap-6">
<div>
<Space style={{ marginBottom: 8 }}>
<CodeOutlined />
<Typography.Text strong>API Usage</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
Use this curl command to test the semantic filter with your current configuration.
</Typography.Text>
<Typography.Text strong style={{ display: "block", marginBottom: 8 }}>
Response headers to check:
</Typography.Text>
<ul style={{ paddingLeft: 20, margin: "0 0 12px 0" }}>
<li>
<Typography.Text>x-litellm-semantic-filter: shows total tools → selected tools</Typography.Text>
<Typography.Text type="secondary" style={{ display: "block" }}>
Example: 10→3
</Typography.Text>
</li>
<li>
<Typography.Text>x-litellm-semantic-filter-tools: CSV of selected tool names</Typography.Text>
<Typography.Text type="secondary" style={{ display: "block" }}>
Example: wikipedia-fetch,github-search,slack-post
</Typography.Text>
</li>
</ul>
<pre
style={{
background: "#f5f5f5",
padding: 12,
borderRadius: 4,
overflow: "auto",
fontSize: 12,
margin: 0,
}}
>
{curlCommand}
</pre>
<p className="mb-2 flex items-center gap-1.5 font-medium">
<CirclePlay className="size-4" /> Test Query
</p>
<Textarea
className="field-sizing-fixed"
placeholder="Enter a test query to see which tools would be selected..."
value={testQuery}
onChange={(e) => setTestQuery(e.target.value)}
rows={4}
disabled={isTesting}
/>
</div>
),
},
]}
/>
<div>
<ModelSelector
accessToken={accessToken || ""}
value={testModel}
onChange={setTestModel}
disabled={isTesting}
showLabel={true}
labelText="Select Model"
/>
</div>
<Button className="w-full" onClick={onTest} disabled={testDisabled}>
<CirclePlay />
Test Filter
</Button>
{!filterEnabled && (
<Alert>
<Info />
<AlertTitle>Semantic filtering is disabled</AlertTitle>
<AlertDescription>Enable semantic filtering and save settings to test the filter.</AlertDescription>
</Alert>
)}
{testError && (
<Alert variant="destructive" className="mb-4">
<CircleAlert />
<AlertTitle>Semantic filtering did not run</AlertTitle>
<AlertDescription>{testError}</AlertDescription>
</Alert>
)}
{testResult && (
<div>
<h5 className="mb-2 text-base font-medium">Results</h5>
<Alert className="mb-4">
<Info />
<AlertTitle>
{testResult.selectedTools} of {testResult.totalTools} tools selected
</AlertTitle>
<AlertDescription>
{testResult.totalTools - testResult.selectedTools} tools filtered out
</AlertDescription>
</Alert>
<div>
<p className="mb-2 block font-medium">Selected Tools:</p>
<ul className="m-0 list-disc pl-5">
{testResult.tools.map((tool, index) => (
<li key={index} className="mb-1">
<span>{tool}</span>
</li>
))}
</ul>
{testResult.selectedTools > testResult.tools.length && (
<p className="mt-2 block text-sm text-muted-foreground">
+{testResult.selectedTools - testResult.tools.length} more selected tools not shown
</p>
)}
</div>
</div>
)}
</div>
</TabsContent>
<TabsContent value="api">
<div>
<div className="mb-2 flex items-center gap-2">
<Code className="size-4" />
<p className="font-medium">API Usage</p>
</div>
<p className="mb-2 block text-sm text-muted-foreground">
Use this curl command to test the semantic filter with your current configuration.
</p>
<p className="mb-2 block font-medium">Response headers to check:</p>
<ul className="mt-0 mr-0 mb-3 ml-0 list-disc pl-5">
<li>
<span>x-litellm-semantic-filter: shows total tools → selected tools</span>
<span className="block text-sm text-muted-foreground">Example: 10→3</span>
</li>
<li>
<span>x-litellm-semantic-filter-tools: CSV of selected tool names</span>
<span className="block text-sm text-muted-foreground">
Example: wikipedia-fetch,github-search,slack-post
</span>
</li>
</ul>
<pre className="m-0 overflow-auto rounded-sm bg-muted p-3 text-xs">{curlCommand}</pre>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}

View file

@ -1,9 +1,19 @@
"use client";
import { ArrowLeftOutlined, HistoryOutlined, ToolOutlined } from "@ant-design/icons";
import { ArrowLeft, History, Wrench } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Select, Spin } from "antd";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import TeamDropdown from "@/components/common_components/team_dropdown";
import { LogViewer } from "@/components/GuardrailsMonitor/LogViewer";
import type { LogEntry } from "@/components/GuardrailsMonitor/mockData";
@ -32,6 +42,11 @@ interface KeyOption {
key_alias?: string;
}
interface KeyItem {
value: string;
label: string;
}
const TOOL_DETAIL_QUERY_KEY = "tool-detail";
const LOGS_PAGE_SIZE = 50;
@ -133,6 +148,11 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
}));
}, [keysData]);
const keyItems: KeyItem[] = useMemo(
() => keys.map((k) => ({ value: k.token, label: k.key_alias || k.token?.substring?.(0, 12) || k.token })),
[keys],
);
const invalidateDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: [TOOL_DETAIL_QUERY_KEY, toolName] });
}, [queryClient, toolName]);
@ -218,7 +238,7 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
if (detailLoading && !detail) {
return (
<div className="flex items-center justify-center py-12">
<Spin size="large" />
<UiLoadingSpinner className="size-8 text-muted-foreground" />
</div>
);
}
@ -226,10 +246,11 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
if (detailError && !detail) {
return (
<div>
<Button type="link" icon={<ArrowLeftOutlined />} onClick={onBack} className="pl-0 mb-4">
<Button variant="link" onClick={onBack} className="mb-4 pl-0">
<ArrowLeft />
Back to Tool Policies
</Button>
<p className="text-red-600">Failed to load tool details.</p>
<p className="text-destructive">Failed to load tool details.</p>
</div>
);
}
@ -246,40 +267,37 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
return (
<div>
<div className="mb-6">
<Button type="link" icon={<ArrowLeftOutlined />} onClick={onBack} className="pl-0 mb-4">
<Button variant="link" onClick={onBack} className="mb-4 pl-0">
<ArrowLeft />
Back to Tool Policies
</Button>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-1 flex-wrap">
<ToolOutlined className="text-xl text-gray-400" />
<h1 className="text-xl font-semibold text-gray-900 font-mono">{tool.tool_name}</h1>
<span className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md bg-gray-100 text-gray-700 border border-gray-200">
{tool.origin ?? "—"}
</span>
<span className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md bg-indigo-50 text-indigo-700 border border-indigo-200">
{(tool.call_count ?? 0).toLocaleString()} calls
</span>
<div className="mb-1 flex flex-wrap items-center gap-3">
<Wrench className="size-5 text-muted-foreground" />
<h1 className="font-mono text-xl font-semibold">{tool.tool_name}</h1>
<Badge variant="outline">{tool.origin ?? "—"}</Badge>
<Badge variant="secondary">{(tool.call_count ?? 0).toLocaleString()} calls</Badge>
</div>
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600">
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-sm text-muted-foreground">
{tool.user_agent && (
<div className="flex items-center gap-1.5">
<dt className="font-medium text-gray-500 whitespace-nowrap">User Agent:</dt>
<dd className="font-mono truncate max-w-[40ch]" title={tool.user_agent}>
<dt className="font-medium whitespace-nowrap">User Agent:</dt>
<dd className="max-w-[40ch] truncate font-mono" title={tool.user_agent}>
{tool.user_agent}
</dd>
</div>
)}
{tool.created_at && (
<div className="flex items-center gap-1.5">
<dt className="font-medium text-gray-500 whitespace-nowrap">First Discovered:</dt>
<dt className="font-medium whitespace-nowrap">First Discovered:</dt>
<dd>{new Date(tool.created_at).toLocaleString()}</dd>
</div>
)}
{tool.last_used_at && (
<div className="flex items-center gap-1.5">
<dt className="font-medium text-gray-500 whitespace-nowrap">Last Used:</dt>
<dt className="font-medium whitespace-nowrap">Last Used:</dt>
<dd>{new Date(tool.last_used_at).toLocaleString()}</dd>
</div>
)}
@ -291,9 +309,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
<div className="space-y-6">
{/* Two-panel policy layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-1">Input Policy</h2>
<p className="text-xs text-gray-500 mb-3">
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="mb-1 text-sm font-semibold">Input Policy</h2>
<p className="mb-3 text-xs text-muted-foreground">
{inputDesc ?? "Controls what data this tool is allowed to accept."}
</p>
<PolicySelect
@ -308,9 +326,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
/>
</section>
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-1">Output Policy</h2>
<p className="text-xs text-gray-500 mb-3">
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="mb-1 text-sm font-semibold">Output Policy</h2>
<p className="mb-3 text-xs text-muted-foreground">
{outputDesc ?? "Controls how this tool's output is trusted by downstream tools."}
</p>
<PolicySelect
@ -327,24 +345,18 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</div>
{overrides.length > 0 && (
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Blocked for team or key</h2>
<ul className="border rounded-md divide-y divide-gray-100 bg-red-50/30">
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="mb-3 text-sm font-semibold">Blocked for team or key</h2>
<ul className="divide-y divide-border rounded-md border border-border">
{overrides.map((ov) => (
<li key={ov.override_id} className="flex items-center justify-between px-3 py-2.5 text-sm">
<span className="text-gray-700">
<span>
{ov.team_id ? `Team: ${ov.team_id}` : ""}
{ov.team_id && ov.key_hash ? " · " : ""}
{ov.key_hash ? `Key: ${ov.key_alias || ov.key_hash.substring(0, 8)}` : ""}
{!ov.team_id && !ov.key_hash ? "—" : ""}
</span>
<Button
type="link"
danger
size="small"
disabled={overrideSaving}
onClick={() => handleRemoveOverride(ov)}
>
<Button variant="link" size="sm" disabled={overrideSaving} onClick={() => handleRemoveOverride(ov)}>
Remove
</Button>
</li>
@ -353,13 +365,13 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</section>
)}
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Block for team or key</h2>
<div className="flex flex-col gap-4 max-w-md">
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="mb-3 text-sm font-semibold">Block for team or key</h2>
<div className="flex max-w-md flex-col gap-4">
<div>
<span className="text-sm font-medium text-gray-700 block mb-2">Scope</span>
<span className="mb-2 block text-sm font-medium">Scope</span>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="radio"
checked={blockScope === "team"}
@ -368,7 +380,7 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
/>
Team
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-700">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="radio"
checked={blockScope === "key"}
@ -380,36 +392,34 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</div>
</div>
<div>
<span className="text-sm font-medium text-gray-700 block mb-2">
{blockScope === "team" ? "Team" : "Key"}
</span>
<span className="mb-2 block text-sm font-medium">{blockScope === "team" ? "Team" : "Key"}</span>
{blockScope === "team" ? (
<TeamDropdown value={blockTeamId ?? undefined} onChange={(id) => setBlockTeamId(id || null)} />
) : (
<Select
placeholder="Select key"
allowClear
showSearch
optionFilterProp="label"
value={blockKey ? blockKey.token : undefined}
onChange={(token) => {
const k = keys.find((x) => x.token === token);
setBlockKey(k ?? null);
}}
options={keys.map((k) => ({
value: k.token,
label: k.key_alias || k.token?.substring?.(0, 12) || k.token,
}))}
className="w-full"
style={{ minWidth: 200 }}
/>
<Combobox
items={keyItems}
value={keyItems.find((k) => k.value === blockKey?.token) ?? null}
onValueChange={(item: KeyItem | null) =>
setBlockKey(keys.find((k) => k.token === item?.value) ?? null)
}
>
<ComboboxInput placeholder="Select key" showClear className="w-full min-w-50" />
<ComboboxContent>
<ComboboxEmpty>No keys found</ComboboxEmpty>
<ComboboxList>
{(item: KeyItem) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
)}
</div>
<Button
type="primary"
danger
variant="destructive"
disabled={overrideSaving || (blockScope === "team" ? !blockTeamId : !blockKey?.token)}
loading={overrideSaving}
onClick={handleAddOverride}
>
Block for {blockScope}
@ -417,9 +427,9 @@ export function ToolDetail({ toolName, onBack, accessToken }: ToolDetailProps) {
</div>
</section>
<section className="bg-white rounded-lg border border-gray-200 p-5 shadow-xs">
<h2 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<HistoryOutlined />
<section className="rounded-lg border border-border bg-card p-5 shadow-xs">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<History className="size-4" />
Recent logs
</h2>
<LogViewer

View file

@ -1,17 +1,18 @@
"use client";
import React from "react";
import { Select } from "antd";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/cva.config";
export const INPUT_POLICY_OPTIONS = [
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" },
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
{ value: "blocked", label: "blocked", color: "#991b1b", bg: "#fee2e2", border: "#fca5a5" },
{ value: "untrusted", label: "untrusted", dot: "bg-amber-500" },
{ value: "trusted", label: "trusted", dot: "bg-green-500" },
{ value: "blocked", label: "blocked", dot: "bg-red-500" },
] as const;
export const OUTPUT_POLICY_OPTIONS = [
{ value: "untrusted", label: "untrusted", color: "#92400e", bg: "#fef3c7", border: "#fcd34d" },
{ value: "trusted", label: "trusted", color: "#065f46", bg: "#d1fae5", border: "#6ee7b7" },
{ value: "untrusted", label: "untrusted", dot: "bg-amber-500" },
{ value: "trusted", label: "trusted", dot: "bg-green-500" },
] as const;
export const POLICY_OPTIONS = INPUT_POLICY_OPTIONS;
@ -36,56 +37,30 @@ export const PolicySelect: React.FC<PolicySelectProps> = ({
onChange,
policyType = "input",
size = "small",
minWidth = 110,
stopPropagation = true,
}) => {
const options = policyType === "output" ? OUTPUT_POLICY_OPTIONS : INPUT_POLICY_OPTIONS;
const style = policyStyle(value);
const selected = policyStyle(value);
return (
<Select
size={size}
value={value}
disabled={saving}
loading={saving}
onChange={(v) => onChange(toolName, v)}
onClick={(e) => stopPropagation && e.stopPropagation()}
style={{
minWidth,
fontWeight: 500,
backgroundColor: style.bg,
borderColor: style.border,
color: style.color,
borderRadius: 999,
fontSize: size === "small" ? 11 : 12,
}}
popupMatchSelectWidth={false}
options={options.map((o) => ({
value: o.value,
label: (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 500,
color: o.color,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: o.color,
display: "inline-block",
flexShrink: 0,
}}
/>
{o.label}
</span>
),
}))}
/>
<Select value={value} disabled={saving} onValueChange={(v: string | null) => v !== null && onChange(toolName, v)}>
<SelectTrigger
size={size === "small" ? "sm" : "default"}
className="w-auto min-w-28"
onClick={(e) => stopPropagation && e.stopPropagation()}
>
<span className={cn("size-2 shrink-0 rounded-full", selected.dot)} />
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
<span className="inline-flex items-center gap-1.5">
<span className={cn("size-2 shrink-0 rounded-full", o.dot)} />
{o.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View file

@ -1,11 +1,11 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Tooltip } from "antd";
import { ToolRow } from "@/components/networking";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { PolicySelect } from "./PolicySelect";
@ -20,9 +20,12 @@ interface ToolPoliciesTableColumnsDeps {
function TruncatedText({ value, className }: { value: string | undefined; className?: string }) {
const text = value ?? "-";
return (
<Tooltip title={text}>
<span className={className}>{text}</span>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className={className}>{text}</span>} />
<TooltipContent>{text}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}