Merge pull request #34469 from BerriAI/litellm_/blissful-torvalds-5a5be3

refactor(ui): migrate mcp-servers, tag-management, tool-policies to shadcn
This commit is contained in:
yuneng-jiang 2026-07-24 10:32:29 -07:00 • committed by GitHub
commit 5e98e8f196
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2760 additions and 1903 deletions

View file

@ -812,15 +812,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
}
@ -835,11 +827,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
@ -878,9 +865,6 @@
}
},
"src/app/(dashboard)/mcp-servers/_components/OpenAPIQuickPicker.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -921,11 +905,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
@ -973,9 +952,6 @@
},
"no-nested-ternary": {
"count": 3
},
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/mcp-servers/_components/mcp_discovery.tsx": {
@ -985,9 +961,6 @@
"local/no-complex-jsx-arrow": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -995,17 +968,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": {
@ -1031,9 +998,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": {
@ -1046,9 +1010,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1056,9 +1017,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": {
@ -1071,9 +1029,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
@ -1801,9 +1756,6 @@
"local/filename-pascal-case": {
"count": 1
},
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2341,11 +2293,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
@ -2511,20 +2458,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

@ -0,0 +1,106 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MCPNetworkSettings from "./MCPNetworkSettings";
import {
getGeneralSettingsCall,
updateConfigFieldSetting,
deleteConfigFieldSetting,
fetchMCPClientIp,
} from "@/components/networking";
vi.mock("@/components/networking", () => ({
getGeneralSettingsCall: vi.fn(),
updateConfigFieldSetting: vi.fn(),
deleteConfigFieldSetting: vi.fn(),
fetchMCPClientIp: vi.fn(),
}));
const renderSettings = () => render(<MCPNetworkSettings accessToken="tok" />);
describe("MCPNetworkSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getGeneralSettingsCall).mockResolvedValue([]);
vi.mocked(fetchMCPClientIp).mockResolvedValue(null);
vi.mocked(updateConfigFieldSetting).mockResolvedValue(undefined);
vi.mocked(deleteConfigFieldSetting).mockResolvedValue(undefined);
});
it("renders the stored private ranges once settings load", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8", "192.168.0.0/16"] },
]);
renderSettings();
expect(await screen.findByText("10.0.0.0/8")).toBeInTheDocument();
expect(screen.getByText("192.168.0.0/16")).toBeInTheDocument();
});
it("ignores unrelated config fields", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "some_other_setting", field_value: ["should-not-show"] },
]);
renderSettings();
await screen.findByText("Private IP Ranges");
expect(screen.queryByText("should-not-show")).not.toBeInTheDocument();
});
it("suggests the caller's /24 range from the detected client IP", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
renderSettings();
expect(await screen.findByText("203.0.113.45")).toBeInTheDocument();
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
});
it("exposes the suggested range as a control a keyboard user can reach and activate", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
renderSettings();
const suggested = await screen.findByRole("button", { name: /203\.0\.113\.0\/24/ });
suggested.focus();
expect(suggested).toHaveFocus();
await userEvent.keyboard("{Enter}");
await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument());
});
it("adds the suggested range to the list when clicked, and stops suggesting it", async () => {
vi.mocked(fetchMCPClientIp).mockResolvedValue("203.0.113.45");
renderSettings();
await userEvent.click(await screen.findByText("203.0.113.0/24"));
await waitFor(() => expect(screen.queryByText("Suggested range:")).not.toBeInTheDocument());
expect(screen.getByText("203.0.113.0/24")).toBeInTheDocument();
});
it("saves the configured ranges", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{ field_name: "mcp_internal_ip_ranges", field_value: ["10.0.0.0/8"] },
]);
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
await waitFor(() =>
expect(updateConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges", ["10.0.0.0/8"]),
);
expect(deleteConfigFieldSetting).not.toHaveBeenCalled();
});
it("clears the setting instead of saving an empty list", async () => {
renderSettings();
await userEvent.click(await screen.findByRole("button", { name: /Save/ }));
await waitFor(() => expect(deleteConfigFieldSetting).toHaveBeenCalledWith("tok", "mcp_internal_ip_ranges"));
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
});
});

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,76 @@ 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
className="cursor-pointer font-mono"
color="blue"
icon={<PlusOutlined />}
<div className="mt-1 flex items-center gap-2">
<p className="text-sm">Suggested range: </p>
<Button
variant="outline"
size="sm"
className="font-mono"
onClick={() => addSuggestedRange(suggestedRange)}
>
<Plus />
{suggestedRange}
</Tag>
</Button>
</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

@ -0,0 +1,84 @@
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import OpenAPIQuickPicker, { type OpenAPIRegistryEntry } from "./OpenAPIQuickPicker";
import { fetchOpenAPIRegistry } from "@/components/networking";
vi.mock("@/components/networking", () => ({
fetchOpenAPIRegistry: vi.fn(),
}));
const stripe: OpenAPIRegistryEntry = {
name: "stripe",
title: "Stripe",
description: "Payments API",
icon_url: "https://cdn.example.com/stripe.svg",
spec_url: "https://example.com/stripe.json",
};
const github: OpenAPIRegistryEntry = {
name: "github",
title: "GitHub",
description: "Code hosting API",
icon_url: "https://cdn.example.com/github.svg",
spec_url: "https://example.com/github.json",
};
describe("OpenAPIQuickPicker", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders one selectable entry per registry API", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
expect(await screen.findByRole("button", { name: /Stripe/ })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /GitHub/ })).toBeInTheDocument();
expect(screen.getByText("Popular APIs")).toBeInTheDocument();
});
it("passes the whole registry entry to onSelect when one is clicked", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe, github] });
const onSelect = vi.fn();
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={onSelect} />);
await userEvent.click(await screen.findByRole("button", { name: /Stripe/ }));
expect(onSelect).toHaveBeenCalledWith(stripe);
});
it("renders nothing when the registry is empty", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [] });
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
});
it("renders nothing when the registry fetch fails", async () => {
vi.mocked(fetchOpenAPIRegistry).mockRejectedValue(new Error("boom"));
const { container } = render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
await waitFor(() => expect(container).toBeEmptyDOMElement());
});
it("does not fetch without an access token", () => {
render(<OpenAPIQuickPicker accessToken={null} selectedName={null} onSelect={vi.fn()} />);
expect(fetchOpenAPIRegistry).not.toHaveBeenCalled();
});
it("falls back to a letter avatar when the icon fails to load", async () => {
vi.mocked(fetchOpenAPIRegistry).mockResolvedValue({ apis: [stripe] });
render(<OpenAPIQuickPicker accessToken="tok" selectedName={null} onSelect={vi.fn()} />);
fireEvent.error(await screen.findByAltText("Stripe"));
await waitFor(() => expect(screen.queryByAltText("Stripe")).not.toBeInTheDocument());
expect(screen.getByText("S")).toBeInTheDocument();
});
});

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

@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import TruePassthroughWarning from "./TruePassthroughWarning";
import { AUTH_TYPE } from "@/components/mcp_tools/types";
describe("TruePassthroughWarning", () => {
it("warns when auth type is true_passthrough", () => {
render(<TruePassthroughWarning authType={AUTH_TYPE.TRUE_PASSTHROUGH} />);
expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument();
expect(screen.getByText(/Anyone who can reach the gateway can call this server/)).toBeInTheDocument();
});
it("renders nothing for any other auth type", () => {
const { container } = render(<TruePassthroughWarning authType={AUTH_TYPE.OAUTH2} />);
expect(container).toBeEmptyDOMElement();
});
it("renders nothing when no auth type is set", () => {
const { container } = render(<TruePassthroughWarning authType={null} />);
expect(container).toBeEmptyDOMElement();
});
});

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

@ -0,0 +1,118 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MCPDiscovery from "./mcp_discovery";
import { fetchDiscoverableMCPServers } from "@/components/networking";
import type { DiscoverableMCPServer } from "@/components/mcp_tools/types";
vi.mock("@/components/networking", () => ({
fetchDiscoverableMCPServers: vi.fn(),
}));
const githubServer = {
name: "github",
title: "GitHub",
description: "Code hosting",
category: "Developer Tools",
icon_url: "",
} as DiscoverableMCPServer;
const slackServer = {
name: "slack",
title: "Slack",
description: "Team chat",
category: "Communication",
icon_url: "",
} as DiscoverableMCPServer;
const defaultProps = {
isVisible: true,
onClose: vi.fn(),
onSelectServer: vi.fn(),
onCustomServer: vi.fn(),
accessToken: "tok",
};
describe("MCPDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({
servers: [githubServer, slackServer],
categories: ["Developer Tools", "Communication"],
});
});
// Each category name renders twice: once as a filter pill (a button) and once
// as the heading of its group. Only the heading is not a button.
const groupHeading = (category: string) => screen.getAllByText(category).filter((el) => el.tagName !== "BUTTON");
it("lists every discoverable server grouped under its category", async () => {
render(<MCPDiscovery {...defaultProps} />);
expect(await screen.findByText("GitHub")).toBeInTheDocument();
expect(screen.getByText("Slack")).toBeInTheDocument();
expect(groupHeading("Developer Tools")).toHaveLength(1);
expect(groupHeading("Communication")).toHaveLength(1);
expect(screen.getByText("Add MCP Server")).toBeInTheDocument();
});
it("filters the list down to the chosen category", async () => {
render(<MCPDiscovery {...defaultProps} />);
await screen.findByText("GitHub");
await userEvent.click(screen.getByRole("button", { name: "Communication" }));
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
expect(screen.getByText("Slack")).toBeInTheDocument();
});
it("filters the list by the search term", async () => {
render(<MCPDiscovery {...defaultProps} />);
await screen.findByText("GitHub");
await userEvent.type(screen.getByPlaceholderText("Search servers..."), "chat");
await waitFor(() => expect(screen.queryByText("GitHub")).not.toBeInTheDocument());
expect(screen.getByText("Slack")).toBeInTheDocument();
});
it("hands the picked server back to the caller", async () => {
const onSelectServer = vi.fn();
render(<MCPDiscovery {...defaultProps} onSelectServer={onSelectServer} />);
await userEvent.click(await screen.findByText("GitHub"));
expect(onSelectServer).toHaveBeenCalledWith(githubServer);
});
it("offers a custom-server escape hatch", async () => {
const onCustomServer = vi.fn();
render(<MCPDiscovery {...defaultProps} onCustomServer={onCustomServer} />);
await userEvent.click(await screen.findByRole("button", { name: "+ Custom Server" }));
expect(onCustomServer).toHaveBeenCalled();
});
it("surfaces a fetch failure", async () => {
vi.mocked(fetchDiscoverableMCPServers).mockRejectedValue(new Error("registry down"));
render(<MCPDiscovery {...defaultProps} />);
expect(await screen.findByText(/Failed to load servers: registry down/)).toBeInTheDocument();
});
it("offers the custom-server link when nothing matches", async () => {
vi.mocked(fetchDiscoverableMCPServers).mockResolvedValue({ servers: [], categories: [] });
render(<MCPDiscovery {...defaultProps} />);
expect(await screen.findByText(/No servers found/)).toBeInTheDocument();
});
it("does not fetch while hidden", () => {
render(<MCPDiscovery {...defaultProps} isVisible={false} />);
expect(fetchDiscoverableMCPServers).not.toHaveBeenCalled();
});
});

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

@ -0,0 +1,87 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import MCPServerCostConfig from "./mcp_server_cost_config";
const tools = [
{ name: "search", description: "Search the index" },
{ name: "fetch", description: "Fetch a document" },
];
describe("MCPServerCostConfig", () => {
it("renders the default cost field with the current value", () => {
render(<MCPServerCostConfig value={{ default_cost_per_query: 0.02 }} tools={[]} />);
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
expect(screen.getByPlaceholderText("0.0000")).toHaveValue("0.0200");
});
it("reports the edited default cost as a number", async () => {
const onChange = vi.fn();
render(<MCPServerCostConfig value={{}} tools={[]} onChange={onChange} />);
await userEvent.type(screen.getByPlaceholderText("0.0000"), "0.5");
expect(onChange).toHaveBeenLastCalledWith({ default_cost_per_query: 0.5 });
});
it("disables the default cost field when disabled", () => {
render(<MCPServerCostConfig value={{}} tools={[]} disabled />);
expect(screen.getByPlaceholderText("0.0000")).toBeDisabled();
});
it("hides the per-tool section when the server exposes no tools", () => {
render(<MCPServerCostConfig value={{}} tools={[]} />);
expect(screen.queryByText("Available Tools")).not.toBeInTheDocument();
});
it("offers a per-tool override for every tool once tools are loaded", async () => {
render(<MCPServerCostConfig value={{}} tools={tools} />);
await userEvent.click(screen.getByText("Available Tools"));
expect(screen.getByText("search")).toBeInTheDocument();
expect(screen.getByText("Search the index")).toBeInTheDocument();
expect(screen.getByText("fetch")).toBeInTheDocument();
expect(screen.getAllByPlaceholderText("Use default")).toHaveLength(2);
});
it("merges a per-tool override into the existing cost map", async () => {
const onChange = vi.fn();
render(
<MCPServerCostConfig
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { fetch: 0.2 } }}
tools={tools}
onChange={onChange}
/>,
);
await userEvent.click(screen.getByText("Available Tools"));
await userEvent.type(screen.getAllByPlaceholderText("Use default")[0], "3");
expect(onChange).toHaveBeenLastCalledWith({
default_cost_per_query: 0.01,
tool_name_to_cost_per_query: { fetch: 0.2, search: 3 },
});
});
it("summarises the configured costs", () => {
render(
<MCPServerCostConfig
value={{ default_cost_per_query: 0.01, tool_name_to_cost_per_query: { search: 0.25 } }}
tools={tools}
/>,
);
expect(screen.getByText("• Default cost: $0.0100 per query")).toBeInTheDocument();
expect(screen.getByText("• search: $0.2500 per query")).toBeInTheDocument();
});
it("shows no summary when nothing is configured", () => {
render(<MCPServerCostConfig value={{}} tools={tools} />);
expect(screen.queryByText("Cost Summary:")).not.toBeInTheDocument();
});
});

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

@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import MCPServerCostDisplay from "./mcp_server_cost_display";
describe("MCPServerCostDisplay", () => {
it("explains that calls are free when no cost config exists", () => {
render(<MCPServerCostDisplay costConfig={null} />);
expect(
screen.getByText("No cost configuration set for this server. Tool calls will be charged at $0.00 per tool call."),
).toBeInTheDocument();
});
it("treats a config with only a null default cost as unconfigured", () => {
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: null }} />);
expect(screen.getByText(/No cost configuration set for this server/)).toBeInTheDocument();
});
it("shows a zero default cost rather than falling back to the empty state", () => {
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0 }} />);
expect(screen.getByText("Default Cost per Query")).toBeInTheDocument();
expect(screen.getByText("$0.0000")).toBeInTheDocument();
});
it("renders the default cost to four decimal places and summarises it", () => {
render(<MCPServerCostDisplay costConfig={{ default_cost_per_query: 0.0125 }} />);
expect(screen.getByText("$0.0125")).toBeInTheDocument();
expect(screen.getByText("• Default cost: $0.0125 per query")).toBeInTheDocument();
});
it("lists each tool-specific cost and counts them in the summary", () => {
render(
<MCPServerCostDisplay
costConfig={{ tool_name_to_cost_per_query: { search: 0.5, fetch: 0.25, skipped: null } }}
/>,
);
expect(screen.getByText("search")).toBeInTheDocument();
expect(screen.getByText("$0.5000 per query")).toBeInTheDocument();
expect(screen.getByText("fetch")).toBeInTheDocument();
expect(screen.getByText("$0.2500 per query")).toBeInTheDocument();
expect(screen.queryByText("skipped")).not.toBeInTheDocument();
expect(screen.getByText("• 3 tool(s) with custom pricing")).toBeInTheDocument();
});
});

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

@ -0,0 +1,152 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MCPServerView } from "./mcp_server_view";
import type { MCPServer } from "@/components/mcp_tools/types";
vi.mock(".", () => ({
MCPToolsViewer: () => <div>tools viewer</div>,
}));
vi.mock("./mcp_server_edit", () => ({
default: () => <div>edit form</div>,
EDIT_OAUTH_UI_STATE_KEY: "litellm-mcp-oauth-edit-state",
}));
const baseServer = {
server_id: "srv-1",
server_name: "demo server",
alias: "demo_alias",
description: "A demo MCP server",
transport: "http",
url: "https://example.com/mcp",
auth_type: "api_key",
} as MCPServer;
const renderView = (overrides: Partial<MCPServer> = {}, props: Record<string, unknown> = {}) =>
render(
<MCPServerView
mcpServer={{ ...baseServer, ...overrides } as MCPServer}
onBack={vi.fn()}
isProxyAdmin
isEditing={false}
accessToken="tok"
userRole="Admin"
userID="u1"
availableAccessGroups={[]}
{...props}
/>,
);
describe("MCPServerView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Name, alias and description each label the header and a Settings row, so
// only the server id is unique to the header.
it("shows the server identity in the header", () => {
renderView();
expect(screen.getByText("srv-1")).toBeInTheDocument();
expect(screen.getAllByText("demo server").length).toBeGreaterThan(0);
expect(screen.getAllByText("A demo MCP server").length).toBeGreaterThan(0);
expect(screen.getAllByText("demo_alias").length).toBeGreaterThan(0);
});
it("falls back to a placeholder name when the server has neither name nor alias", () => {
renderView({ server_name: undefined, alias: undefined });
expect(screen.getByText("Unnamed Server")).toBeInTheDocument();
});
// "Transport" and "Authentication" label both an Overview card and a Settings
// row, so only Overview-exclusive labels identify the Overview panel.
it("summarises the connection on the Overview tab", () => {
renderView();
expect(screen.getByText("Host URL")).toBeInTheDocument();
expect(screen.getByText("Cost Configuration")).toBeInTheDocument();
expect(screen.getAllByText("HTTP").length).toBeGreaterThan(0);
expect(screen.getAllByText("https://example.com/mcp").length).toBeGreaterThan(0);
});
it("offers a Settings tab to proxy admins only", () => {
renderView();
expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument();
});
it("hides the Settings tab from non-admins", () => {
renderView({}, { isProxyAdmin: false });
expect(screen.queryByRole("tab", { name: "Settings" })).not.toBeInTheDocument();
});
it("opens the tools viewer on the MCP Tools tab", async () => {
renderView();
await userEvent.click(screen.getByRole("tab", { name: "MCP Tools" }));
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
});
it("shows the read-only settings summary before editing", async () => {
renderView({ allow_all_keys: true, available_on_public_internet: false });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("MCP Server Settings")).toBeInTheDocument();
expect(screen.getByText("Allow All Keys")).toBeInTheDocument();
expect(screen.getByText("Enabled")).toBeInTheDocument();
expect(screen.getByText("Internal only")).toBeInTheDocument();
expect(screen.queryByText("edit form")).not.toBeInTheDocument();
});
it("swaps in the edit form when Edit Settings is pressed", async () => {
renderView();
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
await userEvent.click(await screen.findByRole("button", { name: "Edit Settings" }));
expect(await screen.findByText("edit form")).toBeInTheDocument();
});
it("opens straight into the edit form when isEditing is set", async () => {
renderView({}, { isEditing: true });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("edit form")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument();
});
it("opens on the tab named by initialTabIndex", async () => {
renderView({}, { initialTabIndex: 1 });
expect(await screen.findByText("tools viewer")).toBeInTheDocument();
});
it("returns to the server list when Back is pressed", async () => {
const onBack = vi.fn();
renderView({}, { onBack });
await userEvent.click(screen.getByRole("button", { name: /Back to All Servers/ }));
expect(onBack).toHaveBeenCalled();
});
it("lists the allowed tools, or says all tools are enabled", async () => {
renderView({ allowed_tools: ["search", "fetch"] });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("search")).toBeInTheDocument();
expect(screen.getByText("fetch")).toBeInTheDocument();
expect(screen.queryByText("All tools enabled")).not.toBeInTheDocument();
});
it("says all tools are enabled when no allowlist is stored", async () => {
renderView({ allowed_tools: [] });
await userEvent.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByText("All tools enabled")).toBeInTheDocument();
});
});

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,5 +1,6 @@
import React from "react";
import { render, waitFor, screen, fireEvent, act } from "@testing-library/react";
import { render, waitFor, screen, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import MCPServers from "./mcp_servers";
@ -307,36 +308,15 @@ describe("MCPServers", () => {
expect(screen.getByText("Team B Server")).toBeInTheDocument();
expect(screen.getByText("Team A Server 2")).toBeInTheDocument();
// Find the team select dropdown by looking for the "Team" label
// Find the team select by its "Team" label, then the combobox it labels
const teamLabel = screen.getByText("Team");
const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select");
expect(teamSelectContainer).toBeTruthy();
const teamSelect = within(teamLabel.parentElement!).getByRole("combobox");
// Open the dropdown by clicking on the selector
const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector");
expect(selectSelector).toBeTruthy();
await userEvent.click(teamSelect);
act(() => {
fireEvent.mouseDown(selectSelector!);
});
// Wait for dropdown to open
await waitFor(
() => {
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
expect(dropdownOptions.length).toBeGreaterThan(0);
},
{ timeout: 5000 },
);
// Find and click on "Team A" option
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
const teamAOption = Array.from(dropdownOptions).find((option) => option.textContent?.includes("Team A"));
expect(teamAOption).toBeTruthy();
act(() => {
fireEvent.click(teamAOption!);
});
// Pick the "Team A" option once the listbox opens
const teamAOption = await screen.findByText("Team A");
await userEvent.click(teamAOption);
// Wait for filtering to complete
await waitFor(() => {

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

@ -0,0 +1,197 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ToolDetail } from "./ToolDetail";
import {
deleteToolPolicyOverride,
fetchToolDetail,
fetchToolPolicyOptions,
getToolUsageLogs,
keyListCall,
teamListCall,
updateToolPolicy,
type ToolDetailResponse,
type ToolPolicyOption,
type ToolPolicyOverrideRow,
type ToolRow,
type ToolUsageLogsResponse,
} from "@/components/networking";
vi.mock("@/components/networking", () => ({
deleteToolPolicyOverride: vi.fn(),
fetchToolDetail: vi.fn(),
fetchToolPolicyOptions: vi.fn(),
getToolUsageLogs: vi.fn(),
keyListCall: vi.fn(),
teamListCall: vi.fn(),
updateToolPolicy: vi.fn(),
}));
vi.mock("@/components/common_components/team_dropdown", () => ({
default: ({ onChange }: { onChange: (id: string) => void }) => (
<button type="button" onClick={() => onChange("team-1")}>
pick team
</button>
),
}));
vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({
LogViewer: ({ totalLogs }: { totalLogs: number }) => <div>log viewer ({totalLogs})</div>,
}));
const detail = {
tool: {
tool_name: "search_docs",
input_policy: "untrusted",
output_policy: "trusted",
origin: "mcp",
call_count: 42,
user_agent: "litellm-python/1.0",
created_at: "2026-03-04T10:00:00Z",
},
overrides: [],
} as unknown as ToolDetailResponse;
const renderDetail = (onBack = vi.fn()) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
<QueryClientProvider client={queryClient}>
<ToolDetail toolName="search_docs" onBack={onBack} accessToken="tok" />
</QueryClientProvider>,
);
};
describe("ToolDetail", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fetchToolDetail).mockResolvedValue(detail);
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({ input_policies: [], output_policies: [] });
vi.mocked(teamListCall).mockResolvedValue({ data: [] });
vi.mocked(keyListCall).mockResolvedValue({ keys: [] });
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 0 } as unknown as ToolUsageLogsResponse);
vi.mocked(updateToolPolicy).mockResolvedValue(undefined as unknown as ToolRow);
vi.mocked(deleteToolPolicyOverride).mockResolvedValue(
undefined as unknown as { deleted: boolean; tool_name: string },
);
});
it("shows the tool identity once loaded", async () => {
renderDetail();
expect(await screen.findByText("search_docs")).toBeInTheDocument();
expect(screen.getByText("mcp")).toBeInTheDocument();
expect(screen.getByText("42 calls")).toBeInTheDocument();
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
});
it("renders both policy panels with the tool's current policies", async () => {
renderDetail();
expect(await screen.findByText("Input Policy")).toBeInTheDocument();
expect(screen.getByText("Output Policy")).toBeInTheDocument();
expect(screen.getByText("untrusted")).toBeInTheDocument();
expect(screen.getByText("trusted")).toBeInTheDocument();
});
it("uses the policy option descriptions when the backend supplies them", async () => {
vi.mocked(fetchToolPolicyOptions).mockResolvedValue({
input_policies: [{ value: "untrusted", description: "Treat inputs as hostile" } as ToolPolicyOption],
output_policies: [{ value: "trusted", description: "Outputs may be chained" } as ToolPolicyOption],
});
renderDetail();
expect(await screen.findByText("Treat inputs as hostile")).toBeInTheDocument();
expect(screen.getByText("Outputs may be chained")).toBeInTheDocument();
});
it("returns to the list when Back is pressed", async () => {
const onBack = vi.fn();
renderDetail(onBack);
await userEvent.click(await screen.findByRole("button", { name: /Back to Tool Policies/ }));
expect(onBack).toHaveBeenCalled();
});
it("reports a failed detail load and still offers a way back", async () => {
vi.mocked(fetchToolDetail).mockRejectedValue(new Error("nope"));
renderDetail();
expect(await screen.findByText("Failed to load tool details.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Back to Tool Policies/ })).toBeInTheDocument();
});
it("hides the overrides panel when the tool has none", async () => {
renderDetail();
await screen.findByText("Input Policy");
expect(screen.queryByText("Blocked for team or key")).not.toBeInTheDocument();
});
it("lists existing overrides and removes the chosen one", async () => {
vi.mocked(fetchToolDetail).mockResolvedValue({
...detail,
overrides: [
{
override_id: "o1",
team_id: "team-alpha",
key_hash: null,
key_alias: null,
} as unknown as ToolPolicyOverrideRow,
],
});
renderDetail();
expect(await screen.findByText("Team: team-alpha")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Remove" }));
await waitFor(() =>
expect(deleteToolPolicyOverride).toHaveBeenCalledWith("tok", "search_docs", {
team_id: "team-alpha",
key_hash: undefined,
}),
);
});
it("keeps the block button disabled until a team is chosen, then blocks that team", async () => {
renderDetail();
const blockButton = await screen.findByRole("button", { name: /Block for team/ });
expect(blockButton).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "pick team" }));
await waitFor(() => expect(screen.getByRole("button", { name: /Block for team/ })).toBeEnabled());
await userEvent.click(screen.getByRole("button", { name: /Block for team/ }));
await waitFor(() =>
expect(updateToolPolicy).toHaveBeenCalledWith(
"tok",
"search_docs",
{ input_policy: "blocked" },
{ team_id: "team-1", key_hash: undefined, key_alias: undefined },
),
);
});
it("switches the block scope to a key", async () => {
renderDetail();
await screen.findByText("Block for team or key");
await userEvent.click(screen.getByRole("radio", { name: "Key" }));
expect(await screen.findByRole("button", { name: /Block for key/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "pick team" })).not.toBeInTheDocument();
});
it("passes the usage-log total through to the log viewer", async () => {
vi.mocked(getToolUsageLogs).mockResolvedValue({ logs: [], total: 7 } as unknown as ToolUsageLogsResponse);
renderDetail();
expect(await screen.findByText("log viewer (7)")).toBeInTheDocument();
});
});

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

@ -30,12 +30,12 @@ describe("PolicySelect", () => {
it("should be disabled when saving is true", () => {
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={true} onChange={vi.fn()} />);
expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false");
expect(screen.getByRole("combobox").closest(".ant-select")).toHaveClass("ant-select-disabled");
expect(screen.getByRole("combobox")).toBeDisabled();
});
it("should not be disabled when saving is false", () => {
renderWithProviders(<PolicySelect value="untrusted" toolName="test-tool" saving={false} onChange={vi.fn()} />);
expect(screen.getByRole("combobox").closest(".ant-select")).not.toHaveClass("ant-select-disabled");
expect(screen.getByRole("combobox")).toBeEnabled();
});
});

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

@ -67,21 +67,27 @@ const row = (toolId: string): HTMLElement => {
const policySelect = (toolId: string, kind: "input" | "output"): HTMLElement =>
within(row(toolId)).getAllByRole("combobox")[kind === "input" ? 0 : 1];
/** Exact selected-value text. Never assert with toHaveTextContent here: it substring-matches, so "untrusted" satisfies "trusted". */
const policyValue = (toolId: string, kind: "input" | "output"): string =>
policySelect(toolId, kind).closest(".ant-select")?.querySelector(".ant-select-selection-item")?.textContent ?? "";
/**
* Exact selected-value text, read off the policy cell and stripped of anything
* that is not a letter (the control draws a status dot and a chevron around the
* label). Never assert with toHaveTextContent here: it substring-matches, so
* "untrusted" satisfies "trusted".
*/
const policyValue = (toolId: string, kind: "input" | "output"): string => {
const cell = policySelect(toolId, kind).closest("td");
return (cell?.textContent ?? "").replace(/[^a-z]/gi, "");
};
const isSaving = (toolId: string, kind: "input" | "output"): boolean =>
policySelect(toolId, kind).closest(".ant-select")?.classList.contains("ant-select-disabled") ?? false;
policySelect(toolId, kind).hasAttribute("disabled");
const chooseOption = async (user: ReturnType<typeof userEvent.setup>, trigger: HTMLElement, label: string) => {
await user.click(trigger);
// The label also renders in the trigger once selected, so take the last match:
// the popup is portalled after the table in document order.
const option = await waitFor(() => {
const match = Array.from(document.querySelectorAll(".ant-select-item-option")).find(
(element) => element.textContent === label,
);
if (match === undefined) throw new Error(`option ${label} not open`);
return match as HTMLElement;
const matches = screen.getAllByText(label);
return matches[matches.length - 1];
});
await user.click(option);
};

View file

@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { getToolPoliciesTableColumns } from "./ToolPoliciesTableColumns";
import type { ToolRow } from "@/components/networking";
const row: ToolRow = {
tool_name: "search_docs",
input_policy: "untrusted",
output_policy: "trusted",
call_count: 1234,
team_id: "team-alpha",
key_hash: "abc123def456",
key_alias: "prod-key",
user_agent: "litellm-python/1.0",
created_at: "2026-03-04T10:00:00Z",
} as ToolRow;
const defaultDeps = {
onSelectTool: vi.fn(),
savingInput: new Set<string>(),
savingOutput: new Set<string>(),
onInputPolicyChange: vi.fn(),
onOutputPolicyChange: vi.fn(),
};
// Renders the column definitions through a real TanStack table so each `cell`
// renderer runs exactly as the DataTable runs it.
function TableHarness({ columns, data }: { columns: ColumnDef<ToolRow>[]; data: ToolRow[] }) {
const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() });
return (
<table>
<tbody>
{table.getRowModel().rows.map((r) => (
<tr key={r.id}>
{r.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
const renderTable = (deps = {}, data: ToolRow[] = [row]) =>
render(<TableHarness columns={getToolPoliciesTableColumns({ ...defaultDeps, ...deps })} data={data} />);
describe("getToolPoliciesTableColumns", () => {
it("defines the expected columns in order", () => {
const columns = getToolPoliciesTableColumns(defaultDeps);
expect(columns.map((c) => c.id)).toEqual([
"created_at",
"tool_name",
"input_policy",
"output_policy",
"call_count",
"team_id",
"key_hash",
"key_alias",
"user_agent",
]);
});
it("renders the row's identifying fields", () => {
renderTable();
expect(screen.getByText("search_docs")).toBeInTheDocument();
expect(screen.getByText("team-alpha")).toBeInTheDocument();
expect(screen.getByText("prod-key")).toBeInTheDocument();
expect(screen.getByText("litellm-python/1.0")).toBeInTheDocument();
});
it("formats the call count with thousands separators", () => {
renderTable();
expect(screen.getByText("1,234")).toBeInTheDocument();
});
it("renders a zero call count rather than a blank cell", () => {
renderTable({}, [{ ...row, call_count: undefined } as ToolRow]);
expect(screen.getByText("0")).toBeInTheDocument();
});
it("falls back to a dash for a missing key alias and user agent", () => {
renderTable({}, [{ ...row, key_alias: undefined, user_agent: undefined } as ToolRow]);
expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(2);
});
it("notifies the caller when the tool name is clicked", async () => {
const onSelectTool = vi.fn();
renderTable({ onSelectTool });
await userEvent.click(screen.getByText("search_docs"));
expect(onSelectTool).toHaveBeenCalledWith("search_docs");
});
it("renders a policy control for each direction, showing the row's current policies", () => {
renderTable();
expect(screen.getByText("untrusted")).toBeInTheDocument();
expect(screen.getByText("trusted")).toBeInTheDocument();
expect(screen.getAllByRole("combobox")).toHaveLength(2);
});
it("disables only the input policy control while that direction is saving", () => {
renderTable({ savingInput: new Set(["search_docs"]) });
const [input, output] = screen.getAllByRole("combobox");
expect(input).toBeDisabled();
expect(output).toBeEnabled();
});
it("disables only the output policy control while that direction is saving", () => {
renderTable({ savingOutput: new Set(["search_docs"]) });
const [input, output] = screen.getAllByRole("combobox");
expect(input).toBeEnabled();
expect(output).toBeDisabled();
});
});

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>
);
}