feat(ui): migrate mcp_tools lists/panels to shadcn

- Migrate src/components/mcp_tools/mcp_servers.tsx from Tremor TabGroup/
  Tab/TabList/TabPanels + antd Modal/Descriptions/Select/Tooltip/Typography
  to shadcn Tabs/AlertDialog/Select/Tooltip/Separator. Delete confirm
  uses AlertDialog with a dl/dt/dd Descriptions replacement. The Team /
  Access Group filter dropdowns become shadcn Radix Selects with
  aria-label. Update mcp_servers.test.tsx to drive the new Radix Select
  via getByRole('combobox', { name: 'Team' }) and getAllByRole('option')
  instead of .ant-select class selectors.
- Migrate src/components/mcp_tools/mcp_tools.tsx from Tremor Card/Title/
  Text + antd Input/Button to shadcn Card/Input/Button. Retains the
  loader/selection visual treatment with lucide icons.
- Migrate src/components/mcp_tools/mcp_connect.tsx from antd Card/Alert/
  Button/Switch/Form/Collapse + Tremor TabGroup/Tab/Title/Text to
  shadcn Card/Alert/Button/Switch/Tabs (Form.Item replaced with a plain
  Switch + Label pair — the connect screens have no form submit).
- Migrate src/components/mcp_tools/mcp_discovery.tsx from antd Modal/
  Input.Search/Typography to shadcn Dialog + Input with an inline search
  icon. Inline styles replaced with Tailwind classes, keeping the custom
  2-column server grid and category pills.
- Migrate src/components/mcp_tools/MCPToolsetsTab.tsx from antd Modal/
  Form/Input/Typography/message/Spin + Tremor Button/Text/Title to
  shadcn Dialog/Input/Label/Button/AlertDialog and MessageManager for
  toasts. Form state moves to react-hook-form with register().
- Migrate src/components/mcp_tools/ToolTestPanel.tsx from antd Form.useForm
  + Form.Item + Tremor Button/TextInput to react-hook-form + Controller
  with shadcn Input/Textarea/Select/Label/Button. The ref-exposed
  boolean-select keeps its title attribute on the trigger so the
  existing ToolTestPanel.test.tsx getByTitle('True') assertion still
  matches. Categorical green palette retained per BLUEPRINT rule and
  added to the eslint overrides list for categorical-palette files
  (mcp_connect.tsx, mcp_tools.tsx, ToolTestPanel.tsx).
- Migrate src/components/mcp_tools/mcp_tools.tsx. Categorical palette
  overrides recorded in .eslintrc.json.
- MCPPermissionManagement.tsx is already in BLOCKERS.md from commit A as
  part of the antd Form cluster; no change required here.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
cursor 2026-04-24 09:48:59 +00:00
parent a6e177e123
commit 26c6978808
No known key found for this signature in database
8 changed files with 1372 additions and 1288 deletions

View file

@ -143,7 +143,10 @@
"src/components/DeletedTeamsPage/DeletedTeamsPage.tsx",
"src/components/molecules/models/columns.tsx",
"src/components/common_components/MemberTable.tsx",
"src/components/price_data_reload.tsx"
"src/components/price_data_reload.tsx",
"src/components/mcp_tools/mcp_connect.tsx",
"src/components/mcp_tools/mcp_tools.tsx",
"src/components/mcp_tools/ToolTestPanel.tsx"
],
"rules": {
"litellm-ui/no-banned-ui-imports": "off",

View file

@ -1,8 +1,32 @@
import React, { useState, useCallback } from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Button, Text, Title } from "@tremor/react";
import { Modal, Form, Input, message, Spin, Card, Typography, Space } from "antd";
import { Plus as PlusIcon, Pencil as PencilIcon, Trash2 as TrashIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Plus as PlusIcon,
Pencil as PencilIcon,
Trash2 as TrashIcon,
LoaderCircle,
X as XIcon,
} from "lucide-react";
import { ColumnDef } from "@tanstack/react-table";
import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
@ -15,10 +39,9 @@ import {
listMCPTools,
getProxyBaseUrl,
} from "../networking";
import MessageManager from "../molecules/message_manager";
import { MCPToolset, MCPToolsetTool } from "./types";
const { Text: AntdText } = Typography;
interface MCPToolsetsTabProps {
accessToken: string | null;
userRole: string | null;
@ -47,15 +70,19 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const selectedSet = new Set(selectedTools.filter((t) => t.server_id === serverId).map((t) => t.tool_name));
const selectedSet = new Set(
selectedTools.filter((t) => t.server_id === serverId).map((t) => t.tool_name),
);
const fetchTools = useCallback(async () => {
if (!accessToken || tools.length > 0) return;
setLoading(true);
try {
const result = await listMCPTools(accessToken, serverId);
const toolList = Array.isArray(result) ? result : result?.tools ?? [];
setTools(toolList.map((t: any) => ({ name: t.name ?? t.tool_name ?? t, description: t.description ?? "" })));
const toolList = Array.isArray(result) ? result : (result?.tools ?? []);
setTools(
toolList.map((t: any) => ({ name: t.name ?? t.tool_name ?? t, description: t.description ?? "" })),
);
} catch {
setTools([]);
} finally {
@ -69,27 +96,29 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
};
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors"
className="w-full flex items-center justify-between px-4 py-3 bg-muted hover:bg-muted/70 transition-colors"
onClick={handleToggle}
>
<span className="text-sm font-medium text-gray-700 flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />
<span className="text-sm font-medium text-foreground flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-primary flex-shrink-0" />
{serverName}
{selectedSet.size > 0 && (
<span className="ml-1 text-xs text-purple-600 font-semibold">{selectedSet.size} selected</span>
<span className="ml-1 text-xs text-primary font-semibold">{selectedSet.size} selected</span>
)}
</span>
<span className="text-gray-400 text-xs">{expanded ? "▲" : "▼"}</span>
<span className="text-muted-foreground text-xs">{expanded ? "▲" : "▼"}</span>
</button>
{expanded && (
<div className="p-2">
{loading ? (
<div className="flex justify-center py-3"><Spin size="small" /></div>
<div className="flex justify-center py-3">
<LoaderCircle className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : tools.length === 0 ? (
<p className="text-xs text-gray-400 px-2 py-2">No tools found for this server.</p>
<p className="text-xs text-muted-foreground px-2 py-2">No tools found for this server.</p>
) : (
<div className="flex flex-col gap-1">
{tools.map((tool) => {
@ -101,19 +130,25 @@ function MCPToolList({ serverId, serverName, accessToken, selectedTools, onToggl
onClick={() => onToggle({ server_id: serverId, tool_name: tool.name })}
className={`flex items-start justify-between px-3 py-2 rounded-lg text-left transition-colors ${
selected
? "bg-purple-50 border border-purple-300"
: "bg-white border border-gray-100 hover:bg-gray-50"
? "bg-primary/10 border border-primary/40"
: "bg-background border border-border hover:bg-muted"
}`}
>
<div className="min-w-0 flex-1">
<p className={`text-sm font-medium leading-tight ${selected ? "text-purple-800" : "text-gray-800"}`}>
<p
className={`text-sm font-medium leading-tight ${selected ? "text-primary" : "text-foreground"}`}
>
{tool.name}
</p>
{tool.description && (
<p className="text-xs text-gray-400 mt-0.5 leading-tight line-clamp-2">{tool.description}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-tight line-clamp-2">
{tool.description}
</p>
)}
</div>
{selected && <span className="text-purple-500 text-xs font-semibold ml-2 flex-shrink-0 mt-0.5">✓</span>}
{selected && (
<span className="text-primary text-xs font-semibold ml-2 flex-shrink-0 mt-0.5">✓</span>
)}
</button>
);
})}
@ -133,8 +168,20 @@ interface CreateToolsetModalProps {
initialToolset?: MCPToolset;
}
function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset }: CreateToolsetModalProps) {
const [form] = Form.useForm<ToolsetFormValues>();
function CreateToolsetModal({
open,
onClose,
onSave,
accessToken,
initialToolset,
}: CreateToolsetModalProps) {
const form = useForm<ToolsetFormValues>({
defaultValues: {
toolset_name: initialToolset?.toolset_name || "",
description: initialToolset?.description || "",
},
});
const { register, reset, handleSubmit, formState } = form;
const [selectedTools, setSelectedTools] = useState<MCPToolsetTool[]>(initialToolset?.tools || []);
const [saving, setSaving] = useState(false);
const [serverSearch, setServerSearch] = useState("");
@ -142,14 +189,14 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
React.useEffect(() => {
if (open) {
form.setFieldsValue({
reset({
toolset_name: initialToolset?.toolset_name || "",
description: initialToolset?.description || "",
});
setSelectedTools(initialToolset?.tools || []);
setServerSearch("");
}
}, [open, initialToolset]);
}, [open, initialToolset, reset]);
const handleToggleTool = (tool: MCPToolsetTool) => {
setSelectedTools((prev) => {
@ -160,8 +207,7 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
});
};
const handleSubmit = async () => {
const values = await form.validateFields();
const onValidSubmit = async (values: ToolsetFormValues) => {
setSaving(true);
try {
await onSave(values.toolset_name, values.description, selectedTools);
@ -181,100 +227,118 @@ function CreateToolsetModal({ open, onClose, onSave, accessToken, initialToolset
});
return (
<Modal
open={open}
onCancel={onClose}
title={initialToolset ? "Edit Toolset" : "New Toolset"}
width={960}
footer={null}
forceRender
>
<Form form={form} layout="vertical" className="mt-2">
<div className="flex gap-4 mb-4">
<Form.Item
label="Toolset Name"
name="toolset_name"
rules={[{ required: true, message: "Please enter a toolset name" }]}
className="flex-1 mb-0"
>
<Input placeholder="e.g. github-linear-tools" />
</Form.Item>
<Form.Item label="Description" name="description" className="flex-1 mb-0">
<Input placeholder="Optional description" />
</Form.Item>
</div>
</Form>
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{initialToolset ? "Edit Toolset" : "New Toolset"}</DialogTitle>
</DialogHeader>
<div className="flex gap-4 mt-2" style={{ minHeight: 360 }}>
{/* Left panel: Available Tools */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<Text className="text-sm font-semibold text-gray-700">Available Tools</Text>
<form onSubmit={handleSubmit(onValidSubmit)} className="space-y-4">
<div className="flex gap-4">
<div className="flex-1 space-y-2">
<Label htmlFor="toolset-name">
Toolset Name <span className="text-destructive">*</span>
</Label>
<Input
id="toolset-name"
placeholder="e.g. github-linear-tools"
{...register("toolset_name", { required: "Please enter a toolset name" })}
/>
{formState.errors.toolset_name && (
<p className="text-sm text-destructive">
{formState.errors.toolset_name.message as string}
</p>
)}
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="toolset-description">Description</Label>
<Input
id="toolset-description"
placeholder="Optional description"
{...register("description")}
/>
</div>
</div>
<Input
placeholder="Search MCP servers..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
className="mb-2"
allowClear
/>
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
{filteredServers.length === 0 ? (
<Text className="text-gray-400 text-sm">{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}</Text>
) : (
filteredServers.map((server) => (
<MCPToolList
key={server.server_id}
serverId={server.server_id}
serverName={server.alias || server.server_name || server.server_id}
accessToken={accessToken}
selectedTools={selectedTools}
onToggle={handleToggleTool}
/>
))
)}
<div className="flex gap-4 mt-2" style={{ minHeight: 360 }}>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-foreground">Available Tools</span>
</div>
<Input
placeholder="Search MCP servers..."
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
className="mb-2"
/>
<div className="space-y-2 overflow-y-auto" style={{ maxHeight: 300 }}>
{filteredServers.length === 0 ? (
<span className="text-muted-foreground text-sm">
{mcpServers.length === 0 ? "No MCP servers configured" : "No servers match your search"}
</span>
) : (
filteredServers.map((server) => (
<MCPToolList
key={server.server_id}
serverId={server.server_id}
serverName={server.alias || server.server_name || server.server_id}
accessToken={accessToken}
selectedTools={selectedTools}
onToggle={handleToggleTool}
/>
))
)}
</div>
</div>
<div className="w-px bg-border flex-shrink-0" />
<div className="w-72 flex-shrink-0">
<span className="text-sm font-semibold text-foreground mb-2 block">
Your Toolset{" "}
<span className="text-xs font-normal text-muted-foreground">
({selectedTools.length} tools)
</span>
</span>
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
{selectedTools.length === 0 ? (
<span className="text-muted-foreground text-sm">No tools added yet</span>
) : (
selectedTools.map((tool, idx) => (
<button
key={idx}
type="button"
onClick={() => handleToggleTool(tool)}
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-primary/30 bg-primary/10 hover:bg-destructive/5 hover:border-destructive/30 group transition-colors"
>
<div className="min-w-0 text-left">
<span className="text-xs font-medium text-primary group-hover:text-destructive truncate block">
{tool.tool_name}
</span>
<span className="text-[10px] text-primary/60 truncate block">
{tool.server_id.slice(0, 8)}…
</span>
</div>
<XIcon className="ml-2 text-primary/60 group-hover:text-destructive text-xs flex-shrink-0 h-3 w-3" />
</button>
))
)}
</div>
</div>
</div>
</div>
{/* Divider */}
<div className="w-px bg-gray-200 flex-shrink-0" />
{/* Right panel: Your Toolset */}
<div className="w-72 flex-shrink-0">
<Text className="text-sm font-semibold text-gray-700 mb-2 block">
Your Toolset{" "}
<span className="text-xs font-normal text-gray-400">({selectedTools.length} tools)</span>
</Text>
<div className="space-y-1 overflow-y-auto" style={{ maxHeight: 340 }}>
{selectedTools.length === 0 ? (
<Text className="text-gray-400 text-sm">No tools added yet</Text>
) : (
selectedTools.map((tool, idx) => (
<button
key={idx}
type="button"
onClick={() => handleToggleTool(tool)}
className="w-full flex items-center justify-between px-3 py-1.5 rounded-lg border border-purple-200 bg-purple-50 hover:bg-red-50 hover:border-red-200 group transition-colors"
>
<div className="min-w-0 text-left">
<span className="text-xs font-medium text-purple-800 group-hover:text-red-600 truncate block">{tool.tool_name}</span>
<span className="text-[10px] text-purple-400 truncate block">{tool.server_id.slice(0, 8)}…</span>
</div>
<span className="ml-2 text-purple-300 group-hover:text-red-400 text-xs flex-shrink-0">✕</span>
</button>
))
)}
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-gray-200">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} loading={saving}>
{initialToolset ? "Save Changes" : "Create Toolset"}
</Button>
</div>
</Modal>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={saving}>
{saving && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{initialToolset ? "Save Changes" : "Create Toolset"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@ -289,7 +353,7 @@ function toolsetColumns(
header: "Toolset ID",
accessorKey: "toolset_id",
cell: ({ row }) => (
<span className="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded text-gray-600">
<span className="font-mono text-xs bg-muted px-2 py-0.5 rounded text-muted-foreground">
{row.original.toolset_id.slice(0, 8)}…
</span>
),
@ -302,12 +366,12 @@ function toolsetColumns(
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full bg-purple-500 flex-shrink-0" />
<span className="font-medium text-gray-900">{row.original.toolset_name}</span>
<span className="inline-block w-2 h-2 rounded-full bg-primary flex-shrink-0" />
<span className="font-medium text-foreground">{row.original.toolset_name}</span>
</div>
<button
type="button"
className="text-xs text-gray-400 hover:text-purple-600 font-mono truncate max-w-xs text-left transition-colors"
className="text-xs text-muted-foreground hover:text-primary font-mono truncate max-w-xs text-left transition-colors"
onClick={() => navigator.clipboard.writeText(url)}
title="Click to copy endpoint URL"
>
@ -321,7 +385,7 @@ function toolsetColumns(
header: "Description",
accessorKey: "description",
cell: ({ row }) => (
<span className="text-sm text-gray-500">{row.original.description || "—"}</span>
<span className="text-sm text-muted-foreground">{row.original.description || "—"}</span>
),
},
{
@ -332,12 +396,15 @@ function toolsetColumns(
return (
<div className="flex flex-wrap gap-1 max-w-xs">
{tools.slice(0, 4).map((t, i) => (
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded bg-purple-50 border border-purple-200 text-purple-700 text-xs">
<span
key={i}
className="inline-flex items-center px-1.5 py-0.5 rounded bg-primary/10 border border-primary/30 text-primary text-xs"
>
{t.tool_name}
</span>
))}
{tools.length > 4 && (
<span className="text-xs text-gray-400 self-center">+{tools.length - 4} more</span>
<span className="text-xs text-muted-foreground self-center">+{tools.length - 4} more</span>
)}
</div>
);
@ -347,33 +414,37 @@ function toolsetColumns(
header: "Created",
accessorKey: "created_at",
cell: ({ row }) => (
<span className="text-xs text-gray-500">
<span className="text-xs text-muted-foreground">
{row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"}
</span>
),
},
...(isAdmin ? [{
header: "",
id: "actions",
cell: ({ row }: { row: { original: MCPToolset } }) => (
<div className="flex items-center gap-1 justify-end">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-700 transition-colors"
onClick={() => onEdit(row.original)}
>
<PencilIcon className="h-4 w-4" />
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors"
onClick={() => onDelete(row.original.toolset_id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
),
} as ColumnDef<MCPToolset>] : []),
...(isAdmin
? [
{
header: "",
id: "actions",
cell: ({ row }: { row: { original: MCPToolset } }) => (
<div className="flex items-center gap-1 justify-end">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onEdit(row.original)}
>
<PencilIcon className="h-4 w-4" />
</button>
<button
type="button"
className="p-1.5 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
onClick={() => onDelete(row.original.toolset_id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
),
} as ColumnDef<MCPToolset>,
]
: []),
];
}
@ -401,20 +472,22 @@ function ToolsetUsageGuide() {
};
return (
<div className="mb-6 rounded-lg border border-gray-200 bg-gray-50 px-5 py-4">
<p className="text-sm font-medium text-gray-700 mb-1">How toolsets work</p>
<p className="text-sm text-gray-500 mb-3">
Create a toolset, assign it to a key via <span className="font-medium text-gray-700">API Keys → Edit Key → MCP Servers</span>, then point your MCP client at the toolset URL. The client only sees the tools you picked.
<div className="mb-6 rounded-lg border border-border bg-muted px-5 py-4">
<p className="text-sm font-medium text-foreground mb-1">How toolsets work</p>
<p className="text-sm text-muted-foreground mb-3">
Create a toolset, assign it to a key via{" "}
<span className="font-medium text-foreground">API Keys → Edit Key → MCP Servers</span>, then point
your MCP client at the toolset URL. The client only sees the tools you picked.
</p>
<div className="text-xs text-gray-400 mb-1">Claude Code / Cursor config</div>
<div className="text-xs text-muted-foreground mb-1">Claude Code / Cursor config</div>
<div className="relative">
<pre className="bg-white border border-gray-200 rounded px-4 py-3 text-xs font-mono text-gray-700 overflow-x-auto leading-relaxed pr-14">
<pre className="bg-background border border-border rounded px-4 py-3 text-xs font-mono text-foreground overflow-x-auto leading-relaxed pr-14">
{snippet}
</pre>
<button
type="button"
onClick={copy}
className="absolute top-2 right-2 px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 text-gray-400 hover:text-gray-600 border-gray-200 transition-colors"
className="absolute top-2 right-2 px-2 py-1 text-xs rounded border bg-background hover:bg-muted text-muted-foreground hover:text-foreground border-border transition-colors"
>
{copied ? "✓" : "copy"}
</button>
@ -436,14 +509,19 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
const handleCreate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken) return;
await createMCPToolset(accessToken, { toolset_name: name, description, tools });
message.success("Toolset created");
MessageManager.success("Toolset created");
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
};
const handleUpdate = async (name: string, description: string | undefined, tools: MCPToolsetTool[]) => {
if (!accessToken || !editToolset) return;
await updateMCPToolset(accessToken, { toolset_id: editToolset.toolset_id, toolset_name: name, description, tools });
message.success("Toolset updated");
await updateMCPToolset(accessToken, {
toolset_id: editToolset.toolset_id,
toolset_name: name,
description,
tools,
});
MessageManager.success("Toolset updated");
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
setEditToolset(null);
};
@ -453,7 +531,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
setDeleting(true);
try {
await deleteMCPToolset(accessToken, deleteId);
message.success("Toolset deleted");
MessageManager.success("Toolset deleted");
queryClient.invalidateQueries({ queryKey: ["mcpToolsets"] });
setDeleteId(null);
} finally {
@ -468,13 +546,15 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
<div className="mt-4">
<div className="flex items-center justify-between mb-4">
<div>
<Title>MCP Toolsets</Title>
<Text className="text-gray-500 text-sm">
Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via the MCP permissions dropdown.
</Text>
<h2 className="text-2xl font-semibold m-0">MCP Toolsets</h2>
<p className="text-muted-foreground text-sm m-0">
Curated collections of tools from one or more MCP servers. Assign toolsets to keys and teams via
the MCP permissions dropdown.
</p>
</div>
{isAdmin && (
<Button icon={PlusIcon} onClick={() => setCreateOpen(true)}>
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
New Toolset
</Button>
)}
@ -510,16 +590,30 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) {
/>
)}
<Modal
open={!!deleteId}
onCancel={() => setDeleteId(null)}
onOk={handleDelete}
okText="Delete"
okButtonProps={{ danger: true, loading: deleting }}
title="Delete Toolset"
>
<p>Are you sure you want to delete this toolset? Keys and teams using it will lose access to the scoped tools.</p>
</Modal>
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Toolset</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this toolset? Keys and teams using it will lose access to the
scoped tools.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleting}
>
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -1,9 +1,24 @@
import React from "react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Button, TextInput } from "@tremor/react";
import { useForm, Controller } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Info as InfoCircleOutlined, X, Copy, Check, AlertCircle, Zap } from "lucide-react";
import { MCPTool, InputSchema, InputSchemaProperty } from "./types";
import { Form, Select, Tooltip } from "antd";
import { Info as InfoCircleOutlined } from "lucide-react";
import NotificationsManager from "../molecules/notifications_manager";
const isPlainObject = (value: unknown): value is Record<string, any> =>
@ -101,6 +116,38 @@ const getInitialValueForField = (prop: InputSchemaProperty): any => {
return defaultValue;
};
function validateField(
key: string,
prop: InputSchemaProperty,
required: boolean,
value: any,
): string | true {
if (required && (value === undefined || value === null || value === "")) {
return `Please enter ${key}`;
}
if (prop.type === "object" || prop.type === "array") {
if ((value === undefined || value === null || value === "") && !required) {
return true;
}
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const isValidObject =
prop.type === "object" &&
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed);
const isValidArray = prop.type === "array" && Array.isArray(parsed);
if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) {
return true;
}
return prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array";
} catch {
return "Invalid JSON";
}
}
return true;
}
export function ToolTestPanel({
tool,
onSubmit,
@ -116,15 +163,12 @@ export function ToolTestPanel({
error: Error | null;
onClose: () => void;
}) {
const [form] = Form.useForm();
const [viewMode, setViewMode] = React.useState<"formatted" | "json">("formatted");
const [startTime, setStartTime] = React.useState<number | null>(null);
const [duration, setDuration] = React.useState<number | null>(null);
// Create a placeholder schema if we only have the "tool_input_schema" string
const schema: InputSchema = React.useMemo(() => {
if (typeof tool.inputSchema === "string") {
// Default schema with a single text field
return {
type: "object",
properties: {
@ -139,7 +183,6 @@ export function ToolTestPanel({
return tool.inputSchema as InputSchema;
}, [tool.inputSchema]);
// Check if this is a nested params structure and extract the actual parameters
const actualSchema: InputSchema = React.useMemo(() => {
if (
schema.properties &&
@ -147,7 +190,6 @@ export function ToolTestPanel({
schema.properties.params.type === "object" &&
schema.properties.params.properties
) {
// This is a nested params structure, extract the actual parameters
return {
type: "object",
properties: schema.properties.params.properties,
@ -157,27 +199,31 @@ export function ToolTestPanel({
return schema;
}, [schema]);
React.useEffect(() => {
form.resetFields();
if (!actualSchema.properties) {
return;
const defaultValues = React.useMemo(() => {
const values: Record<string, any> = {};
if (actualSchema.properties) {
Object.entries(actualSchema.properties).forEach(([key, prop]) => {
values[key] = getInitialValueForField(prop);
});
}
return values;
}, [actualSchema]);
const initialValues: Record<string, any> = {};
Object.entries(actualSchema.properties).forEach(([key, prop]) => {
initialValues[key] = getInitialValueForField(prop);
});
const form = useForm<Record<string, any>>({
defaultValues,
mode: "onSubmit",
});
const { control, handleSubmit, reset } = form;
form.setFieldsValue(initialValues);
}, [form, actualSchema, tool]);
React.useEffect(() => {
reset(defaultValues);
}, [defaultValues, reset, tool]);
const handleSubmit = (values: Record<string, any>) => {
const handleFormSubmit = (values: Record<string, any>) => {
const start = Date.now();
setStartTime(start);
setDuration(null);
// Convert form values to proper types based on schema
const convertedValues: Record<string, any> = {};
const schemaToUse = actualSchema;
@ -226,7 +272,6 @@ export function ToolTestPanel({
}
});
// If this was a nested params structure, wrap the values back in params
const submitValues =
schema.properties &&
schema.properties.params &&
@ -238,7 +283,6 @@ export function ToolTestPanel({
onSubmit(submitValues);
};
// Track when result changes to calculate duration
React.useEffect(() => {
if (startTime && (result || error)) {
const endTime = Date.now();
@ -248,12 +292,10 @@ export function ToolTestPanel({
const copyToClipboard = async (text: string) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
} else {
// Fallback for non-secure contexts (like 0.0.0.0)
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
@ -297,7 +339,7 @@ export function ToolTestPanel({
return (
<div className="space-y-4 h-full">
{/* Compact Header */}
<div className="flex items-center justify-between pb-3 border-b border-gray-200">
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center space-x-3">
{tool.mcp_info.logo_url && (
// eslint-disable-next-line @next/next/no-img-element
@ -309,300 +351,311 @@ export function ToolTestPanel({
)}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h2 className="text-lg font-semibold text-gray-900">Test Tool:</h2>
<h2 className="text-lg font-semibold text-foreground">Test Tool:</h2>
<div
className="group inline-flex items-center space-x-1 bg-slate-50 hover:bg-slate-100 px-3 py-1 rounded-md cursor-pointer transition-colors border border-slate-200"
className="group inline-flex items-center space-x-1 bg-muted hover:bg-muted/70 px-3 py-1 rounded-md cursor-pointer transition-colors border border-border"
onClick={handleCopyToolName}
title="Click to copy tool name"
>
<span className="font-mono text-slate-700 font-medium text-sm">{tool.name}</span>
<svg
className="w-3 h-3 text-slate-400 group-hover:text-slate-600 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span className="font-mono text-foreground font-medium text-sm">{tool.name}</span>
<Copy className="w-3 h-3 text-muted-foreground group-hover:text-foreground transition-colors" />
</div>
</div>
<p className="text-xs text-gray-600">{tool.description}</p>
<p className="text-xs text-gray-500">Provider: {tool.mcp_info.server_name}</p>
<p className="text-xs text-muted-foreground">{tool.description}</p>
<p className="text-xs text-muted-foreground">Provider: {tool.mcp_info.server_name}</p>
</div>
</div>
<Button onClick={onClose} variant="light" size="sm" className="text-gray-500 hover:text-gray-700">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Two Column Layout - Always Side by Side */}
{/* Two Column Layout */}
<div className="grid grid-cols-2 gap-4 h-full">
{/* Left Column - Input Parameters */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="border-b border-gray-100 px-4 py-2">
<div className="bg-background border border-border rounded-lg">
<div className="border-b border-border px-4 py-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">Input Parameters</h3>
<Tooltip title="Configure the input parameters for this tool call">
<InfoCircleOutlined className="text-gray-400 hover:text-gray-600" />
</Tooltip>
<h3 className="text-sm font-semibold text-foreground">Input Parameters</h3>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircleOutlined className="text-muted-foreground hover:text-foreground h-4 w-4" />
</TooltipTrigger>
<TooltipContent>Configure the input parameters for this tool call</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="p-4">
<Form form={form} onFinish={handleSubmit} layout="vertical" className="space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-3">
{typeof tool.inputSchema === "string" ? (
<div className="space-y-3">
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Input <span className="text-red-500">*</span>
</span>
}
<div className="space-y-2">
<Label htmlFor={`${tool.name}-input`}>
<span className="text-sm font-medium text-foreground">
Input <span className="text-destructive">*</span>
</span>
</Label>
<Controller
control={control}
name="input"
rules={[{ required: true, message: "Please enter input for this tool" }]}
className="mb-3"
>
<TextInput
placeholder="Enter input for this tool"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
rules={{ required: "Please enter input for this tool" }}
render={({ field, fieldState }) => (
<>
<Input
id={`${tool.name}-input`}
placeholder="Enter input for this tool"
{...field}
value={field.value ?? ""}
/>
{fieldState.error && (
<p className="text-sm text-destructive">{fieldState.error.message}</p>
)}
</>
)}
/>
</div>
) : actualSchema.properties === undefined ? (
<div className="text-center py-6 bg-gray-50 rounded-lg border border-gray-200">
<div className="text-center py-6 bg-muted rounded-lg border border-border">
<div className="max-w-sm mx-auto">
<h4 className="text-sm font-medium text-gray-900 mb-1">No Parameters Required</h4>
<p className="text-xs text-gray-500">This tool can be called without any input parameters.</p>
<h4 className="text-sm font-medium text-foreground mb-1">No Parameters Required</h4>
<p className="text-xs text-muted-foreground">
This tool can be called without any input parameters.
</p>
</div>
</div>
) : (
<div className="space-y-3">
{Object.entries(actualSchema.properties).map(([key, prop]) => {
const initialValue = getInitialValueForField(prop);
const required = !!actualSchema.required?.includes(key);
const fieldKey = `${tool.name}-${key}`;
return (
<Form.Item
key={fieldKey}
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
{key} {actualSchema.required?.includes(key) && <span className="text-red-500">*</span>}
<div key={fieldKey} className="space-y-2">
<Label htmlFor={fieldKey}>
<span className="text-sm font-medium text-foreground flex items-center">
{key} {required && <span className="text-destructive">*</span>}
{prop.description && (
<Tooltip title={prop.description}>
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoCircleOutlined className="ml-2 text-muted-foreground hover:text-foreground h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">{prop.description}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
}
name={key}
initialValue={initialValue}
rules={[
{
required: actualSchema.required?.includes(key),
message: `Please enter ${key}`,
},
...(prop.type === "object" || prop.type === "array"
? [
{
validator: (_rule: any, value: any) => {
if (
(value === undefined || value === null || value === "") &&
!actualSchema.required?.includes(key)
) {
return Promise.resolve();
}
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const isValidObject =
prop.type === "object" &&
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed);
const isValidArray = prop.type === "array" && Array.isArray(parsed);
if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) {
return Promise.resolve();
</Label>
<Controller
control={control}
name={key}
rules={{ validate: (value) => validateField(key, prop, required, value) }}
render={({ field, fieldState }) => {
const errorMessage = fieldState.error?.message;
if (prop.type === "string" && prop.enum) {
return (
<>
<Select
value={field.value ? String(field.value) : ""}
onValueChange={(v) => field.onChange(v)}
>
<SelectTrigger id={fieldKey} aria-label={key}>
<SelectValue placeholder={`Select ${key}`} />
</SelectTrigger>
<SelectContent>
{prop.enum!.map((v) => (
<SelectItem key={String(v)} value={String(v)}>
{String(v)}
</SelectItem>
))}
</SelectContent>
</Select>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
if (prop.type === "string") {
return (
<>
<Input
id={fieldKey}
placeholder={prop.description || `Enter ${key}`}
{...field}
value={field.value ?? ""}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
if (prop.type === "number" || prop.type === "integer") {
return (
<>
<Input
id={fieldKey}
type="number"
step={prop.type === "integer" ? 1 : "any"}
placeholder={prop.description || `Enter ${key}`}
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value;
field.onChange(v === "" ? "" : Number(v));
}}
onBlur={field.onBlur}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
if (prop.type === "boolean") {
const valueStr =
field.value === true ? "true" : field.value === false ? "false" : "";
const valueTitle =
field.value === true ? "True" : field.value === false ? "False" : undefined;
return (
<>
<Select
value={valueStr}
onValueChange={(v) => field.onChange(v === "true")}
>
<SelectTrigger id={fieldKey} aria-label={key} title={valueTitle}>
<SelectValue placeholder={`Select ${key}`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}
if (prop.type === "object" || prop.type === "array") {
return (
<div className="space-y-2">
<Textarea
id={fieldKey}
rows={prop.type === "object" ? 6 : 4}
placeholder={
prop.description ||
(prop.type === "object"
? `Enter JSON object for ${key}`
: `Enter JSON array for ${key}`)
}
return Promise.reject(
new Error(
prop.type === "object"
? "Please enter a JSON object"
: "Please enter a JSON array",
),
);
} catch (error) {
return Promise.reject(new Error("Invalid JSON"));
}
},
},
]
: []),
]}
className="mb-3"
>
{prop.type === "string" && prop.enum && (
<select
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"
defaultValue={(initialValue as string) ?? ""}
>
{!actualSchema.required?.includes(key) && <option value="">Select {key}</option>}
{prop.enum.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
)}
{prop.type === "string" && !prop.enum && (
<TextInput
placeholder={prop.description || `Enter ${key}`}
defaultValue={(initialValue as string) ?? ""}
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
)}
{(prop.type === "number" || prop.type === "integer") && (
<input
type="number"
step={prop.type === "integer" ? 1 : "any"}
placeholder={prop.description || `Enter ${key}`}
defaultValue={initialValue ?? 0}
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm transition-colors"
/>
)}
{prop.type === "boolean" && (
<Select
placeholder={`Select ${key}`}
allowClear={!actualSchema.required?.includes(key)}
className="w-full"
>
<Select.Option value={true}>True</Select.Option>
<Select.Option value={false}>False</Select.Option>
</Select>
)}
{(prop.type === "object" || prop.type === "array") && (
<div className="space-y-2">
<textarea
rows={prop.type === "object" ? 6 : 4}
placeholder={
prop.description ||
(prop.type === "object" ? `Enter JSON object for ${key}` : `Enter JSON array for ${key}`)
}
defaultValue={(initialValue as string) ?? (prop.type === "object" ? "{}" : "[]")}
spellCheck={false}
data-testid={`textarea-${key}`}
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
/>
<p className="text-xs text-gray-500">
{prop.type === "object"
? "Provide a valid JSON object."
: "Provide a valid JSON array."}
</p>
</div>
)}
</Form.Item>
spellCheck={false}
data-testid={`textarea-${key}`}
className="font-mono"
{...field}
value={field.value ?? ""}
/>
<p className="text-xs text-muted-foreground">
{prop.type === "object"
? "Provide a valid JSON object."
: "Provide a valid JSON array."}
</p>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</div>
);
}
return (
<>
<Input
id={fieldKey}
placeholder={prop.description || `Enter ${key}`}
{...field}
value={field.value ?? ""}
/>
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</>
);
}}
/>
</div>
);
})}
</div>
)}
<div className="pt-3 border-t border-gray-100">
<Button
onClick={() => form.submit()}
disabled={isLoading}
variant="primary"
className="w-full"
loading={isLoading}
>
<div className="pt-3 border-t border-border">
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? "Calling Tool..." : result || error ? "Call Again" : "Call Tool"}
</Button>
</div>
</Form>
</form>
</div>
</div>
{/* Right Column - Tool Result */}
<div className="bg-white border border-gray-200 rounded-lg">
<div className="border-b border-gray-100 px-4 py-2">
<h3 className="text-sm font-semibold text-gray-900">Tool Result</h3>
<div className="bg-background border border-border rounded-lg">
<div className="border-b border-border px-4 py-2">
<h3 className="text-sm font-semibold text-foreground">Tool Result</h3>
</div>
<div className="p-4">
{!result && !error && !isLoading ? (
/* Empty State */
<div className="flex flex-col justify-center items-center h-48 text-gray-500">
<div className="flex flex-col justify-center items-center h-48 text-muted-foreground">
<div className="text-center max-w-sm">
<div className="mb-3">
<svg
className="mx-auto h-12 w-12 text-gray-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<h4 className="text-sm font-medium text-gray-900 mb-1">Ready to Call Tool</h4>
<p className="text-xs text-gray-500 leading-relaxed">
<Zap className="mx-auto h-12 w-12 text-muted-foreground/50 mb-3" />
<h4 className="text-sm font-medium text-foreground mb-1">Ready to Call Tool</h4>
<p className="text-xs text-muted-foreground leading-relaxed">
Configure the input parameters and click &quot;Call Tool&quot; to see the results here.
</p>
</div>
</div>
) : (
<div className="space-y-3">
{/* Result Control Bar */}
{result && !isLoading && !error && (
<div className="p-2 bg-green-50 border border-green-200 rounded-lg">
<div className="p-2 bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="h-4 w-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h4 className="text-xs font-medium text-green-900">Tool executed successfully</h4>
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
<h4 className="text-xs font-medium text-green-900 dark:text-green-100">
Tool executed successfully
</h4>
{duration !== null && (
<span className="text-xs text-green-600 ml-1">• {(duration / 1000).toFixed(2)}s</span>
<span className="text-xs text-green-600 dark:text-green-400 ml-1">
• {(duration / 1000).toFixed(2)}s
</span>
)}
</div>
<div className="flex items-center space-x-1">
<div className="flex bg-white rounded border border-green-300 p-0.5">
<div className="flex bg-background rounded border border-green-300 dark:border-green-800 p-0.5">
<button
type="button"
onClick={() => setViewMode("formatted")}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
viewMode === "formatted"
? "bg-green-100 text-green-800"
: "text-green-600 hover:text-green-800"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"
: "text-green-600 dark:text-green-400 hover:text-green-800"
}`}
>
Formatted
</button>
<button
type="button"
onClick={() => setViewMode("json")}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
viewMode === "json"
? "bg-green-100 text-green-800"
: "text-green-600 hover:text-green-800"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"
: "text-green-600 dark:text-green-400 hover:text-green-800"
}`}
>
JSON
@ -610,24 +663,12 @@ export function ToolTestPanel({
</div>
<button
type="button"
onClick={handleCopyResult}
className="p-1 hover:bg-green-100 rounded text-green-700"
className="p-1 hover:bg-green-100 dark:hover:bg-green-900 rounded text-green-700 dark:text-green-300"
title="Copy response"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
</div>
@ -636,41 +677,34 @@ export function ToolTestPanel({
<div className="max-h-96 overflow-y-auto">
{isLoading && (
<div className="flex flex-col justify-center items-center h-48 text-gray-500">
<div className="flex flex-col justify-center items-center h-48 text-muted-foreground">
<div className="relative">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gray-200"></div>
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-600 border-t-transparent absolute top-0"></div>
<div className="animate-spin rounded-full h-8 w-8 border-2 border-muted"></div>
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent absolute top-0"></div>
</div>
<p className="text-sm font-medium mt-3">Calling tool...</p>
<p className="text-xs text-gray-400 mt-1">Please wait while we process your request</p>
<p className="text-sm font-medium mt-3 text-foreground">Calling tool...</p>
<p className="text-xs text-muted-foreground mt-1">
Please wait while we process your request
</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="bg-destructive/5 border border-destructive/30 rounded-lg p-3">
<div className="flex items-start space-x-2">
<div className="flex-shrink-0">
<svg className="h-4 w-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<h4 className="text-xs font-medium text-red-900">Tool Call Failed</h4>
<h4 className="text-xs font-medium text-destructive">Tool Call Failed</h4>
{duration !== null && (
<span className="text-xs text-red-600">• {(duration / 1000).toFixed(2)}s</span>
<span className="text-xs text-destructive">
• {(duration / 1000).toFixed(2)}s
</span>
)}
</div>
<div className="bg-white border border-red-200 rounded p-2 max-h-48 overflow-y-auto">
<pre className="text-xs whitespace-pre-wrap text-red-700 font-mono">
{(() => {
return error.message;
})()}
<div className="bg-background border border-destructive/30 rounded p-2 max-h-48 overflow-y-auto">
<pre className="text-xs whitespace-pre-wrap text-destructive font-mono">
{error.message}
</pre>
</div>
</div>
@ -681,44 +715,46 @@ export function ToolTestPanel({
{result && !isLoading && !error && (
<div className="space-y-3">
{viewMode === "formatted" ? (
// Formatted View
result.map((content: any, idx: number) => (
<div key={idx} className="border border-gray-200 rounded-lg overflow-hidden">
<div key={idx} className="border border-border rounded-lg overflow-hidden">
{content.type === "text" && (
<div>
<div className="bg-gray-50 px-3 py-1 border-b border-gray-200">
<span className="text-xs font-medium text-gray-700 uppercase tracking-wide">
<div className="bg-muted px-3 py-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Text Response
</span>
</div>
<div className="p-3">
<div className="bg-white rounded border border-gray-200 max-h-64 overflow-y-auto">
<div className="bg-background rounded border border-border max-h-64 overflow-y-auto">
<div className="p-3 space-y-2">
{content.text
.split("\n\n")
.map((section: string, sectionIndex: number) => {
if (section.trim() === "") return null;
// Handle headers (## or ###)
if (section.startsWith("##")) {
const headerText = section.replace(/^#+\s/, "");
return (
<div key={sectionIndex} className="border-b border-gray-200 pb-1 mb-2">
<h3 className="text-sm font-semibold text-gray-900">{headerText}</h3>
<div
key={sectionIndex}
className="border-b border-border pb-1 mb-2"
>
<h3 className="text-sm font-semibold text-foreground">
{headerText}
</h3>
</div>
);
}
// Handle URL-containing sections
const urlRegex = /(https?:\/\/[^\s\)]+)/g;
if (urlRegex.test(section)) {
const parts = section.split(urlRegex);
return (
<div
key={sectionIndex}
className="bg-blue-50 border border-blue-200 rounded p-2"
className="bg-primary/5 border border-primary/20 rounded p-2"
>
<div className="text-xs text-gray-700 leading-relaxed whitespace-pre-wrap">
<div className="text-xs text-foreground leading-relaxed whitespace-pre-wrap">
{parts.map((part, partIndex) => {
if (urlRegex.test(part)) {
return (
@ -727,7 +763,7 @@ export function ToolTestPanel({
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline break-all"
className="text-primary hover:text-primary/80 underline break-all"
>
{part}
</a>
@ -740,27 +776,25 @@ export function ToolTestPanel({
);
}
// Handle score information
if (section.includes("Score:")) {
return (
<div
key={sectionIndex}
className="bg-green-50 border-l-4 border-green-400 p-2 rounded-r"
className="bg-green-50 dark:bg-green-950/30 border-l-4 border-green-400 p-2 rounded-r"
>
<p className="text-xs text-green-800 font-medium whitespace-pre-wrap">
<p className="text-xs text-green-800 dark:text-green-200 font-medium whitespace-pre-wrap">
{section}
</p>
</div>
);
}
// Regular content sections
return (
<div
key={sectionIndex}
className="bg-gray-50 rounded p-2 border border-gray-200"
className="bg-muted rounded p-2 border border-border"
>
<div className="text-xs text-gray-700 leading-relaxed whitespace-pre-wrap font-mono">
<div className="text-xs text-foreground leading-relaxed whitespace-pre-wrap font-mono">
{section}
</div>
</div>
@ -775,13 +809,13 @@ export function ToolTestPanel({
{content.type === "image" && content.url && (
<div>
<div className="bg-gray-50 px-3 py-1 border-b border-gray-200">
<span className="text-xs font-medium text-gray-700 uppercase tracking-wide">
<div className="bg-muted px-3 py-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Image Response
</span>
</div>
<div className="p-3">
<div className="bg-gray-50 rounded p-3 border border-gray-200">
<div className="bg-muted rounded p-3 border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={content.url}
@ -795,30 +829,16 @@ export function ToolTestPanel({
{content.type === "embedded_resource" && (
<div>
<div className="bg-gray-50 px-3 py-1 border-b border-gray-200">
<span className="text-xs font-medium text-gray-700 uppercase tracking-wide">
<div className="bg-muted px-3 py-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Embedded Resource
</span>
</div>
<div className="p-3">
<div className="flex items-center space-x-2 p-3 bg-blue-50 border border-blue-200 rounded">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div className="flex items-center space-x-2 p-3 bg-primary/5 border border-primary/20 rounded">
<InfoCircleOutlined className="h-5 w-5 text-primary flex-shrink-0" />
<div className="flex-1">
<p className="text-xs font-medium text-blue-900">
<p className="text-xs font-medium text-foreground">
Resource Type: {content.resource_type}
</p>
{content.url && (
@ -826,13 +846,9 @@ export function ToolTestPanel({
href={content.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 hover:underline mt-1 transition-colors"
className="inline-flex items-center text-xs text-primary hover:text-primary/80 hover:underline mt-1 transition-colors"
>
View Resource
<svg className="ml-1 h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</a>
)}
</div>
@ -843,10 +859,9 @@ export function ToolTestPanel({
</div>
))
) : (
// JSON View
<div className="bg-white rounded border border-gray-200">
<div className="p-3 overflow-auto max-h-80 bg-gray-50">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-gray-800">
<div className="bg-background rounded border border-border">
<div className="p-3 overflow-auto max-h-80 bg-muted">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
{JSON.stringify(result, null, 2)}
</pre>
</div>

View file

@ -1,16 +1,27 @@
/* eslint-disable react/no-unescaped-entities */
import React, { useState } from "react";
import { Card, Typography, Space, Alert, Button, Switch, Form, Collapse } from "antd";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { TabPanel, TabPanels, TabGroup, TabList, Tab, Title as TremorTitle, Text as TremorText } from "@tremor/react";
import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
CopyIcon,
Code,
Terminal,
Globe,
CheckIcon,
ExternalLinkIcon,
KeyIcon,
ServerIcon,
Zap,
Info,
} from "lucide-react";
import { getProxyBaseUrl } from "../networking";
import { copyToClipboard as utilCopyToClipboard } from "../../utils/dataUtils";
const { Title, Text } = Typography;
const { Panel } = Collapse;
interface CodeBlockProps {
code: string;
title?: string;
@ -43,54 +54,55 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
};
if (useServerHeader && serverName) {
const formattedServerName = serverName.replace(/\s+/g, "_");
// Include both server name and access groups in the same header (comma-separated string)
const serverAndGroups = [formattedServerName, ...accessGroups].join(",");
headers["x-mcp-servers"] = serverAndGroups;
}
return headers;
};
const featureCardSwitchId = React.useId();
return (
<Card className="border border-gray-200">
<Card className="border border-border p-6">
<div className="flex items-center gap-3 mb-3">
<span className="p-2 rounded-lg bg-gray-50">{icon}</span>
<span className="p-2 rounded-lg bg-muted">{icon}</span>
<div>
<Title level={5} className="mb-0">
{title}
</Title>
<Text className="text-gray-600">{description}</Text>
<h5 className="text-base font-semibold mb-0">{title}</h5>
<p className="text-muted-foreground text-sm m-0">{description}</p>
</div>
</div>
{serverName && (title === "Implementation Example" || title === "Configuration") && (
<Form.Item className="mb-4">
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<Switch size="small" checked={useServerHeader} onChange={setUseServerHeader} />
<Text className="text-sm">
Limit tools to specific MCP servers or MCP groups by passing the <code>x-mcp-servers</code> header
</Text>
<Switch
id={featureCardSwitchId}
checked={useServerHeader}
onCheckedChange={setUseServerHeader}
/>
<Label htmlFor={featureCardSwitchId} className="text-sm font-normal">
Limit tools to specific MCP servers or MCP groups by passing the{" "}
<code>x-mcp-servers</code> header
</Label>
</div>
{useServerHeader && (
<Alert
className="mt-2"
type="info"
showIcon
message="Two Options"
description={
<div>
<p>
<strong>Option 1:</strong> Get a specific server: <code>"{serverName.replace(/\s+/g, "_")}"</code>
</p>
<p>
<strong>Option 2:</strong> Get a group of MCPs: <code>"dev-group"</code>
</p>
<p className="mt-2 text-sm text-gray-600">
You can also mix both: <code>"Server1,dev-group"</code>
</p>
</div>
}
/>
<Alert className="mt-2">
<Info className="h-4 w-4" />
<AlertTitle>Two Options</AlertTitle>
<AlertDescription>
<p>
<strong>Option 1:</strong> Get a specific server:{" "}
<code>"{serverName.replace(/\s+/g, "_")}"</code>
</p>
<p>
<strong>Option 2:</strong> Get a group of MCPs: <code>"dev-group"</code>
</p>
<p className="mt-2 text-sm text-muted-foreground">
You can also mix both: <code>"Server1,dev-group"</code>
</p>
</AlertDescription>
</Alert>
)}
</Form.Item>
</div>
)}
{React.Children.map(children, (child) => {
if (
@ -101,7 +113,10 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
const code = child.props.code;
if (code && code.includes('"headers":')) {
return React.cloneElement(child, {
code: code.replace(/"headers":\s*{[^}]*}/, `"headers": ${JSON.stringify(getHeadersConfig(), null, 8)}`),
code: code.replace(
/"headers":\s*{[^}]*}/,
`"headers": ${JSON.stringify(getHeadersConfig(), null, 8)}`,
),
});
}
}
@ -118,13 +133,7 @@ interface MCPConnectProps {
const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = [] }) => {
const proxyBaseUrl = getProxyBaseUrl();
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [serverHeaders, setServerHeaders] = useState<Record<string, string[]>>({
openai: [],
litellm: [],
cursor: [],
http: [],
});
const [currentServer] = useState("Zapier_MCP"); // This should match the current server being viewed
const [currentServer] = useState("Zapier_MCP");
const copyToClipboard = async (text: string, key: string) => {
const success = await utilCopyToClipboard(text);
@ -136,22 +145,6 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
}
};
const getHeadersConfig = (type: string) => {
const headers: Record<string, any> = {
"x-litellm-api-key": "Bearer YOUR_LITELLM_API_KEY",
};
if (serverHeaders[type]?.length > 0) {
// Format server names (replace spaces with underscores)
const formattedServers = serverHeaders[type].map((s) => s.replace(/\s+/g, "_"));
// Use comma-separated string (can include both servers and access groups)
headers["x-mcp-servers"] = formattedServers.join(",");
}
return headers;
};
const CodeBlock: React.FC<{
code: string;
copyKey: string;
@ -161,25 +154,24 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
<div className="relative group">
{title && (
<div className="flex items-center gap-2 mb-2">
<Code size={16} className="text-blue-600" />
<Text strong className="text-gray-700">
{title}
</Text>
<Code size={16} className="text-primary" />
<span className="font-semibold text-foreground">{title}</span>
</div>
)}
<Card className={`bg-gray-50 border border-gray-200 relative ${className}`}>
<Card className={`bg-muted border border-border relative p-4 ${className}`}>
<Button
type="text"
size="small"
icon={copiedStates[copyKey] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
variant="ghost"
size="sm"
onClick={() => copyToClipboard(code, copyKey)}
className={`absolute top-2 right-2 z-10 transition-all duration-200 ${
copiedStates[copyKey]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
copiedStates[copyKey] ? "text-primary" : "text-muted-foreground hover:text-foreground"
}`}
/>
<pre className="text-sm overflow-x-auto pr-10 text-gray-800 font-mono leading-relaxed">{code}</pre>
>
{copiedStates[copyKey] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
</Button>
<pre className="text-sm overflow-x-auto pr-10 text-foreground font-mono leading-relaxed">
{code}
</pre>
</Card>
</div>
);
@ -191,49 +183,53 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
}> = ({ step, title, children }) => (
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-semibold">
<div className="w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-semibold">
{step}
</div>
</div>
<div className="flex-1">
<Text strong className="text-gray-800 block mb-2">
{title}
</Text>
<span className="font-semibold text-foreground block mb-2">{title}</span>
{children}
</div>
</div>
);
const LiteLLMProxyTab = () => (
<Space direction="vertical" size="large" className="w-full">
<div className="bg-gradient-to-r from-emerald-50 to-green-50 p-6 rounded-lg border border-emerald-100">
<div className="flex flex-col gap-6 w-full">
<div className="bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-950/30 dark:to-green-950/30 p-6 rounded-lg border border-emerald-100 dark:border-emerald-900">
<div className="flex items-center gap-3 mb-3">
<Zap className="text-emerald-600" size={24} />
<Title level={4} className="mb-0 text-emerald-900">
<Zap className="text-emerald-600 dark:text-emerald-400" size={24} />
<h4 className="mb-0 text-lg font-semibold text-emerald-900 dark:text-emerald-100">
LiteLLM Proxy API Integration
</Title>
</h4>
</div>
<Text className="text-emerald-700">
<p className="text-emerald-700 dark:text-emerald-200">
Connect to LiteLLM Proxy Responses API for seamless tool integration with multiple model providers
</Text>
</p>
</div>
<Space direction="vertical" size="large" className="w-full">
<div className="flex flex-col gap-6 w-full">
<FeatureCard
icon={<KeyIcon className="text-emerald-600" size={16} />}
icon={<KeyIcon className="text-emerald-600 dark:text-emerald-400" size={16} />}
title="Virtual Key Setup"
description="Configure your LiteLLM Proxy Virtual Key for authentication"
>
<Space direction="vertical" size="middle" className="w-full">
<div className="flex flex-col gap-4 w-full">
<div>
<Text>Get your Virtual Key from your LiteLLM Proxy dashboard or contact your administrator</Text>
<span className="text-foreground">
Get your Virtual Key from your LiteLLM Proxy dashboard or contact your administrator
</span>
</div>
<CodeBlock title="Environment Variable" code='export LITELLM_API_KEY="sk-..."' copyKey="litellm-env" />
</Space>
<CodeBlock
title="Environment Variable"
code='export LITELLM_API_KEY="sk-..."'
copyKey="litellm-env"
/>
</div>
</FeatureCard>
<FeatureCard
icon={<ServerIcon className="text-emerald-600" size={16} />}
icon={<ServerIcon className="text-emerald-600 dark:text-emerald-400" size={16} />}
title="MCP Server Information"
description="Connection details for your LiteLLM MCP server"
>
@ -241,7 +237,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
</FeatureCard>
<FeatureCard
icon={<Code className="text-emerald-600" size={16} />}
icon={<Code className="text-emerald-600 dark:text-emerald-400" size={16} />}
title="Implementation Example"
description="Complete cURL example for using the LiteLLM Proxy Responses API"
serverName={currentServer}
@ -272,51 +268,54 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
className="text-xs"
/>
</FeatureCard>
</Space>
</Space>
</div>
</div>
);
const OpenAITab = () => (
<Space direction="vertical" size="large" className="w-full">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-lg border border-blue-100">
<div className="flex flex-col gap-6 w-full">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 p-6 rounded-lg border border-blue-100 dark:border-blue-900">
<div className="flex items-center gap-3 mb-3">
<Code className="text-blue-600" size={24} />
<Title level={4} className="mb-0 text-blue-900">
<Code className="text-primary" size={24} />
<h4 className="mb-0 text-lg font-semibold text-blue-900 dark:text-blue-100">
OpenAI Responses API Integration
</Title>
</h4>
</div>
<Text className="text-blue-700">
<p className="text-blue-700 dark:text-blue-200">
Connect OpenAI Responses API to your LiteLLM MCP server for seamless tool integration
</Text>
</p>
</div>
<Space direction="vertical" size="large" className="w-full">
<div className="flex flex-col gap-6 w-full">
<FeatureCard
icon={<KeyIcon className="text-blue-600" size={16} />}
icon={<KeyIcon className="text-primary" size={16} />}
title="API Key Setup"
description="Configure your OpenAI API key for authentication"
>
<Space direction="vertical" size="middle" className="w-full">
<div className="flex flex-col gap-4 w-full">
<div>
{/* eslint-disable-next-line react/no-unescaped-entities */}
<Text>
<span className="text-foreground">
Get your API key from the{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 inline-flex items-center gap-1"
className="text-primary hover:text-primary/80 inline-flex items-center gap-1"
>
OpenAI platform <ExternalLinkIcon size={12} />
</a>
</Text>
</span>
</div>
<CodeBlock title="Environment Variable" code='export OPENAI_API_KEY="sk-..."' copyKey="openai-env" />
</Space>
<CodeBlock
title="Environment Variable"
code='export OPENAI_API_KEY="sk-..."'
copyKey="openai-env"
/>
</div>
</FeatureCard>
<FeatureCard
icon={<ServerIcon className="text-blue-600" size={16} />}
icon={<ServerIcon className="text-primary" size={16} />}
title="MCP Server Information"
description="Connection details for your LiteLLM MCP server"
>
@ -324,7 +323,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
</FeatureCard>
<FeatureCard
icon={<Code className="text-blue-600" size={16} />}
icon={<Code className="text-primary" size={16} />}
title="Implementation Example"
description="Complete cURL example for using the Responses API"
serverName="Zapier Gmail"
@ -355,49 +354,50 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
className="text-xs"
/>
</FeatureCard>
</Space>
</Space>
</div>
</div>
);
const CursorTab = () => (
<Space direction="vertical" size="large" className="w-full">
<div className="bg-gradient-to-r from-purple-50 to-blue-50 p-6 rounded-lg border border-purple-100">
<div className="flex flex-col gap-6 w-full">
<div className="bg-gradient-to-r from-purple-50 to-blue-50 dark:from-purple-950/30 dark:to-blue-950/30 p-6 rounded-lg border border-purple-100 dark:border-purple-900">
<div className="flex items-center gap-3 mb-3">
<Terminal className="text-purple-600" size={24} />
<Title level={4} className="mb-0 text-purple-900">
<Terminal className="text-purple-600 dark:text-purple-400" size={24} />
<h4 className="mb-0 text-lg font-semibold text-purple-900 dark:text-purple-100">
Cursor IDE Integration
</Title>
</h4>
</div>
<Text className="text-purple-700">
Use tools directly from Cursor IDE with LiteLLM MCP. Enable your AI assistant to perform real-world tasks
without leaving your coding environment.
</Text>
<p className="text-purple-700 dark:text-purple-200">
Use tools directly from Cursor IDE with LiteLLM MCP. Enable your AI assistant to perform
real-world tasks without leaving your coding environment.
</p>
</div>
<Card className="border border-gray-200">
<Title level={5} className="mb-4 text-gray-800">
Setup Instructions
</Title>
<Space direction="vertical" size="large" className="w-full">
<Card className="border border-border p-6">
<h5 className="mb-4 text-foreground text-base font-semibold">Setup Instructions</h5>
<div className="flex flex-col gap-6 w-full">
<StepCard step={1} title="Open Cursor Settings">
<Text className="text-gray-600">
Use the keyboard shortcut <code className="bg-gray-100 px-2 py-1 rounded">⇧+⌘+J</code> (Mac) or{" "}
<code className="bg-gray-100 px-2 py-1 rounded">Ctrl+Shift+J</code> (Windows/Linux)
</Text>
<span className="text-muted-foreground">
Use the keyboard shortcut <code className="bg-muted px-2 py-1 rounded">⇧+⌘+J</code>{" "}
(Mac) or <code className="bg-muted px-2 py-1 rounded">Ctrl+Shift+J</code>{" "}
(Windows/Linux)
</span>
</StepCard>
<StepCard step={2} title="Navigate to MCP Tools">
<Text className="text-gray-600">Go to the "MCP Tools" tab and click "New MCP Server"</Text>
<span className="text-muted-foreground">
Go to the "MCP Tools" tab and click "New MCP Server"
</span>
</StepCard>
<StepCard step={3} title="Add Configuration">
<Text className="text-gray-600 mb-3">
<span className="text-muted-foreground mb-3 block">
Copy the JSON configuration below and paste it into Cursor, then save with{" "}
<code className="bg-gray-100 px-2 py-1 rounded">Cmd+S</code> or{" "}
<code className="bg-gray-100 px-2 py-1 rounded">Ctrl+S</code>
</Text>
<code className="bg-muted px-2 py-1 rounded">Cmd+S</code> or{" "}
<code className="bg-muted px-2 py-1 rounded">Ctrl+S</code>
</span>
<FeatureCard
icon={<Code className="text-purple-600" size={16} />}
icon={<Code className="text-purple-600 dark:text-purple-400" size={16} />}
title="Configuration"
description="Cursor MCP configuration"
serverName="Zapier Gmail"
@ -420,36 +420,37 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
/>
</FeatureCard>
</StepCard>
</Space>
</div>
</Card>
</Space>
</div>
);
const StreamableHTTPTab = () => (
<Space direction="vertical" size="large" className="w-full">
<div className="bg-gradient-to-r from-green-50 to-teal-50 p-6 rounded-lg border border-green-100">
<div className="flex flex-col gap-6 w-full">
<div className="bg-gradient-to-r from-green-50 to-teal-50 dark:from-green-950/30 dark:to-teal-950/30 p-6 rounded-lg border border-green-100 dark:border-green-900">
<div className="flex items-center gap-3 mb-3">
<Globe className="text-green-600" size={24} />
<Title level={4} className="mb-0 text-green-900">
<Globe className="text-green-600 dark:text-green-400" size={24} />
<h4 className="mb-0 text-lg font-semibold text-green-900 dark:text-green-100">
Streamable HTTP Transport
</Title>
</h4>
</div>
<Text className="text-green-700">
Connect to LiteLLM MCP using HTTP transport. Compatible with any MCP client that supports HTTP streaming.
</Text>
<p className="text-green-700 dark:text-green-200">
Connect to LiteLLM MCP using HTTP transport. Compatible with any MCP client that supports HTTP
streaming.
</p>
</div>
<FeatureCard
icon={<Globe className="text-green-600" size={16} />}
icon={<Globe className="text-green-600 dark:text-green-400" size={16} />}
title="Universal MCP Connection"
description="Use this URL with any MCP client that supports HTTP transport"
>
<Space direction="vertical" size="middle" className="w-full">
<div className="flex flex-col gap-4 w-full">
<div>
<Text>
Each MCP client supports different transports. Refer to your client documentation to determine the
appropriate transport method.
</Text>
<span className="text-foreground">
Each MCP client supports different transports. Refer to your client documentation to
determine the appropriate transport method.
</span>
</div>
<CodeBlock title="Server URL" code={`${proxyBaseUrl}/mcp`} copyKey="http-server-url" />
<CodeBlock
@ -464,76 +465,63 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
copyKey="http-headers"
/>
<div className="mt-4">
<Button
type="link"
className="p-0 h-auto text-blue-600 hover:text-blue-700"
<a
href="https://modelcontextprotocol.io/docs/concepts/transports"
icon={<ExternalLinkIcon size={14} />}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-primary hover:text-primary/80"
>
Learn more about MCP transports
</Button>
<ExternalLinkIcon size={14} />
</a>
</div>
</Space>
</div>
</FeatureCard>
</Space>
</div>
);
return (
<div>
<Space direction="vertical" size="large" className="w-full">
<div>
<TremorTitle className="text-3xl font-bold text-gray-900 mb-3">Connect to your MCP client</TremorTitle>
<TremorText className="text-lg text-gray-600">
Use tools directly from any MCP client with LiteLLM MCP. Enable your AI assistant to perform real-world
tasks through a simple, secure connection.
</TremorText>
</div>
<div className="flex flex-col gap-6 w-full">
<div>
<h1 className="text-3xl font-bold text-foreground mb-3">Connect to your MCP client</h1>
<p className="text-lg text-muted-foreground">
Use tools directly from any MCP client with LiteLLM MCP. Enable your AI assistant to perform
real-world tasks through a simple, secure connection.
</p>
</div>
<TabGroup className="w-full">
<TabList className="flex justify-start mt-8 mb-6">
<div className="flex bg-gray-100 p-1 rounded-lg">
<Tab className="px-6 py-3 rounded-md transition-all duration-200">
<span className="flex items-center gap-2 font-medium">
<Code size={18} />
OpenAI API
</span>
</Tab>
<Tab className="px-6 py-3 rounded-md transition-all duration-200">
<span className="flex items-center gap-2 font-medium">
<Zap size={18} />
LiteLLM Proxy
</span>
</Tab>
<Tab className="px-6 py-3 rounded-md transition-all duration-200">
<span className="flex items-center gap-2 font-medium">
<Terminal size={18} />
Cursor
</span>
</Tab>
<Tab className="px-6 py-3 rounded-md transition-all duration-200">
<span className="flex items-center gap-2 font-medium">
<Globe size={18} />
Streamable HTTP
</span>
</Tab>
</div>
</TabList>
<TabPanels>
<TabPanel className="mt-6">
<OpenAITab />
</TabPanel>
<TabPanel className="mt-6">
<LiteLLMProxyTab />
</TabPanel>
<TabPanel className="mt-6">
<CursorTab />
</TabPanel>
<TabPanel className="mt-6">
<StreamableHTTPTab />
</TabPanel>
</TabPanels>
</TabGroup>
</Space>
<Tabs defaultValue="openai" className="w-full">
<TabsList className="mt-8 mb-6">
<TabsTrigger value="openai" className="gap-2">
<Code size={18} />
OpenAI API
</TabsTrigger>
<TabsTrigger value="litellm" className="gap-2">
<Zap size={18} />
LiteLLM Proxy
</TabsTrigger>
<TabsTrigger value="cursor" className="gap-2">
<Terminal size={18} />
Cursor
</TabsTrigger>
<TabsTrigger value="http" className="gap-2">
<Globe size={18} />
Streamable HTTP
</TabsTrigger>
</TabsList>
<TabsContent value="openai" className="mt-6">
<OpenAITab />
</TabsContent>
<TabsContent value="litellm" className="mt-6">
<LiteLLMProxyTab />
</TabsContent>
<TabsContent value="cursor" className="mt-6">
<CursorTab />
</TabsContent>
<TabsContent value="http" className="mt-6">
<StreamableHTTPTab />
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -1,12 +1,17 @@
import React, { useState, useMemo, useEffect } from "react";
import { Modal, Input, Typography } from "antd";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
import { fetchDiscoverableMCPServers } from "../networking";
import { DiscoverableMCPServer, DiscoverMCPServersResponse } from "./types";
import { mcpLogoImg } from "./create_mcp_server";
const { Search } = Input;
const { Text } = Typography;
interface MCPDiscoveryProps {
isVisible: boolean;
onClose: () => void;
@ -29,8 +34,7 @@ const INITIAL_COLORS = [
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;
name.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0) % INITIAL_COLORS.length;
return { initial, backgroundColor: INITIAL_COLORS[colorIndex] };
}
@ -101,219 +105,151 @@ 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={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
<Dialog open={isVisible} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<div className="flex items-center justify-between pb-4 border-b border-border">
<div className="flex items-center space-x-3">
<img
src={mcpLogoImg}
alt="MCP Logo"
className="w-8 h-8 object-contain"
style={{
height: "20px",
width: "20px",
marginRight: "8px",
objectFit: "contain",
}}
/>
<DialogTitle className="text-xl font-semibold">Add MCP Server</DialogTitle>
</div>
<Button
type="button"
variant="link"
onClick={onCustomServer}
style={{ color: "#2563eb", cursor: "pointer" }}
className="text-sm font-medium"
>
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={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>
);
})}
</div>
+ Custom Server
</Button>
</div>
))}
</Modal>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-1">
{/* Filter pills */}
<div className="flex gap-1.5 flex-wrap mb-3">
{["All", ...categories].map((cat) => {
const isSelected = selectedCategory === cat;
return (
<button
key={cat}
type="button"
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1 rounded text-xs leading-5 transition-colors ${
isSelected
? "bg-foreground text-background border border-foreground font-medium"
: "bg-background text-muted-foreground border border-border font-normal hover:bg-muted"
}`}
>
{cat}
</button>
);
})}
</div>
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search servers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Loading skeleton */}
{loading && (
<div className="flex flex-col gap-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-9 rounded-md bg-muted" />
))}
</div>
)}
{error && (
<div className="text-center py-8 text-muted-foreground">
<span>Failed to load servers: {error}</span>
</div>
)}
{!loading && !error && filteredServers.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<span>
No servers found.{" "}
<button
type="button"
onClick={onCustomServer}
className="text-primary cursor-pointer underline"
>
Add a custom server
</button>
</span>
</div>
)}
{/* Server list grouped by category */}
{!loading &&
!error &&
Object.entries(groupedServers).map(([category, categoryServers]) => (
<div key={category} className="mb-4">
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-[0.05em] py-1.5 border-b border-border mb-1">
{category}
</div>
<div className="grid grid-cols-2 gap-x-4">
{categoryServers.map((server) => {
const avatar = getInitialAvatar(server.title || server.name);
return (
<button
key={server.name}
type="button"
onClick={() => onSelectServer(server)}
className="flex items-center px-2.5 py-2 rounded-md text-left hover:bg-muted transition-colors"
>
{server.icon_url ? (
<img
src={server.icon_url}
alt={server.title}
className="w-5 h-5 object-contain flex-shrink-0 mr-3"
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="w-5 h-5 rounded text-white items-center justify-center font-semibold text-[11px] flex-shrink-0 mr-3"
style={{
backgroundColor: avatar.backgroundColor,
display: server.icon_url ? "none" : "flex",
}}
>
{avatar.initial}
</div>
<span className="text-sm font-normal text-foreground flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
{server.title || server.name}
</span>
<span className="text-muted-foreground text-sm flex-shrink-0 ml-2">&#8250;</span>
</button>
);
})}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -306,33 +306,22 @@ 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
const teamLabel = screen.getByText("Team");
const teamSelectContainer = teamLabel.closest("div")?.querySelector(".ant-select");
expect(teamSelectContainer).toBeTruthy();
// Open the dropdown by clicking on the selector
const selectSelector = teamSelectContainer?.querySelector(".ant-select-selector");
expect(selectSelector).toBeTruthy();
// Open the Team dropdown (shadcn Radix Select)
const teamTrigger = screen.getByRole("combobox", { name: "Team" });
act(() => {
fireEvent.mouseDown(selectSelector!);
fireEvent.click(teamTrigger);
});
// Wait for dropdown to open
await waitFor(
() => {
const dropdownOptions = document.querySelectorAll(".ant-select-item-option");
expect(dropdownOptions.length).toBeGreaterThan(0);
expect(screen.getAllByRole("option").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"),
);
const teamAOption = screen
.getAllByRole("option")
.find((option) => option.textContent?.includes("Team A"));
expect(teamAOption).toBeTruthy();
act(() => {

View file

@ -1,9 +1,32 @@
import { isAdminRole } from "@/utils/roles";
import { HelpCircle as QuestionCircleOutlined } from "lucide-react";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import NewBadge from "../common_components/NewBadge";
import { Descriptions, Modal, Select, Tooltip, Typography } from "antd";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
@ -23,18 +46,18 @@ import MCPDiscovery from "./mcp_discovery";
import { ByokCredentialModal } from "./ByokCredentialModal";
import { getSecureItem } from "@/utils/secureStorage";
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
const { Option } = Select;
const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID }) => {
const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers();
// Fetch health status for all servers
const { data: healthStatuses, isLoading: isLoadingHealth, recheckServerHealth, recheckingServerIds } = useMCPServerHealth();
const {
data: healthStatuses,
isLoading: isLoadingHealth,
recheckServerHealth,
recheckingServerIds,
} = useMCPServerHealth();
// Merge health status data into servers
const serversWithHealth = useMemo(() => {
if (!mcpServers) return [];
if (!healthStatuses) return mcpServers;
@ -52,7 +75,6 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
});
}, [mcpServers, healthStatuses]);
// state
const [serverIdToDelete, setServerToDelete] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
@ -86,7 +108,6 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}
}, []);
// Get unique teams from all servers
const uniqueTeams = React.useMemo(() => {
if (!serversWithHealth) return [];
const teamsSet = new Set<string>();
@ -105,54 +126,56 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
return uniqueTeamsArray;
}, [serversWithHealth]);
// Get unique MCP access groups from all servers
const uniqueMcpAccessGroups = React.useMemo(() => {
if (!serversWithHealth) return [];
return Array.from(
new Set(
serversWithHealth.flatMap((server) => server.mcp_access_groups).filter((group): group is string => group != null),
serversWithHealth
.flatMap((server) => server.mcp_access_groups)
.filter((group): group is string => group != null),
),
);
}, [serversWithHealth]);
// Filtering logic for both team and access group
const filterServers = useCallback((teamId: string, group: string) => {
if (!serversWithHealth) return setFilteredServers([]);
let filtered = serversWithHealth;
if (teamId === "personal") {
setFilteredServers([]);
return;
}
if (teamId !== "all") {
filtered = filtered.filter((server) => server.teams?.some((team) => team.team_id === teamId));
}
if (group !== "all") {
filtered = filtered.filter((server) =>
server.mcp_access_groups?.some((g: any) => (typeof g === "string" ? g === group : g && g.name === group)),
);
}
const sorted = [...filtered].sort((a, b) => {
if (!a.created_at && !b.created_at) return 0;
if (!a.created_at) return 1;
if (!b.created_at) return -1;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
setFilteredServers(sorted);
}, [serversWithHealth]);
const filterServers = useCallback(
(teamId: string, group: string) => {
if (!serversWithHealth) return setFilteredServers([]);
let filtered = serversWithHealth;
if (teamId === "personal") {
setFilteredServers([]);
return;
}
if (teamId !== "all") {
filtered = filtered.filter((server) => server.teams?.some((team) => team.team_id === teamId));
}
if (group !== "all") {
filtered = filtered.filter((server) =>
server.mcp_access_groups?.some((g: any) =>
typeof g === "string" ? g === group : g && g.name === group,
),
);
}
const sorted = [...filtered].sort((a, b) => {
if (!a.created_at && !b.created_at) return 0;
if (!a.created_at) return 1;
if (!b.created_at) return -1;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
setFilteredServers(sorted);
},
[serversWithHealth],
);
// Handle team filter change
const handleTeamChange = (teamId: string) => {
setSelectedTeam(teamId);
filterServers(teamId, selectedMcpAccessGroup);
};
// Handle MCP access group filter change
const handleMcpAccessGroupChange = (group: string) => {
setSelectedMcpAccessGroup(group);
filterServers(selectedTeam, group);
};
// Initial and effect-based filtering (trigger on query data updates and health data updates)
useEffect(() => {
filterServers(selectedTeam, selectedMcpAccessGroup);
}, [serversWithHealth, selectedTeam, selectedMcpAccessGroup, filterServers]);
@ -206,7 +229,6 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
setServerToDelete(null);
};
// Find the server to delete from the servers list
const serverToDelete = serverIdToDelete
? (mcpServers || []).find((server) => server.server_id === serverIdToDelete)
: null;
@ -217,23 +239,23 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
refetch();
};
// Memoize the selected server to prevent unnecessary re-renders
const selectedServer = React.useMemo(() => {
return filteredServers.find((server: MCPServer) => server.server_id === selectedServerId) || {
server_id: "",
server_name: "",
alias: "",
url: "",
transport: "",
auth_type: "",
created_at: "",
created_by: "",
updated_at: "",
updated_by: "",
};
return (
filteredServers.find((server: MCPServer) => server.server_id === selectedServerId) || {
server_id: "",
server_name: "",
alias: "",
url: "",
transport: "",
auth_type: "",
created_at: "",
created_by: "",
updated_at: "",
updated_by: "",
}
);
}, [filteredServers, selectedServerId]);
// Memoize the onBack callback to prevent unnecessary re-renders
const handleBack = React.useCallback(() => {
setEditServer(false);
setSelectedServerId(null);
@ -242,50 +264,74 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
if (!accessToken || !userRole || !userID) {
console.log("Missing required authentication parameters", { accessToken, userRole, userID });
return <div className="p-6 text-center text-gray-500">Missing required authentication parameters.</div>;
return (
<div className="p-6 text-center text-muted-foreground">
Missing required authentication parameters.
</div>
);
}
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>
<AlertDialog open={isDeleteModalOpen} onOpenChange={(o) => !o && cancelDelete()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete MCP Server?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p className="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>
{serverToDelete && (
<div className="mt-3 p-4 bg-muted rounded-lg border border-border">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
{serverToDelete.server_name && (
<>
<dt className="text-muted-foreground">Name</dt>
<dd className="font-semibold text-foreground">
{serverToDelete.server_name}
</dd>
</>
)}
<dt className="text-muted-foreground">ID</dt>
<dd>
<code className="text-xs bg-background px-1.5 py-0.5 rounded">
{serverToDelete.server_id}
</code>
</dd>
{serverToDelete.url && (
<>
<dt className="text-muted-foreground">URL</dt>
<dd>
<code className="text-xs bg-background px-1.5 py-0.5 rounded break-all">
{serverToDelete.url}
</code>
</dd>
</>
)}
</dl>
</div>
)}
<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>
</div>
)}
</div>
</Modal>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingServer}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDeletingServer}
>
{isDeletingServer ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<CreateMCPServer
userRole={userRole}
accessToken={accessToken}
@ -303,14 +349,16 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<Title>MCP Servers</Title>
<h2 className="text-2xl font-semibold m-0">MCP Servers</h2>
{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">
<span className="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-muted text-muted-foreground border border-border">
{filteredServers.length}
</span>
)}
</div>
<Text className="text-tremor-content mt-1">Configure and manage your MCP servers</Text>
<p className="text-muted-foreground mt-1 text-sm m-0">
Configure and manage your MCP servers
</p>
</div>
<div className="flex items-center gap-2">
{isAdminRole(userRole) && (
@ -347,108 +395,136 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}}
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>
<Tab>Semantic Filter</Tab>
<Tab>Network Settings</Tab>
{isAdminRole(userRole) && <Tab><span className="flex items-center gap-2">Submitted MCPs <NewBadge /></span></Tab>}
</div>
</TabList>
<TabPanels>
<TabPanel>
{selectedServerId ? (
<MCPServerView
key={selectedServerId}
mcpServer={selectedServer}
onBack={handleBack}
isProxyAdmin={isAdminRole(userRole)}
isEditing={editServer}
accessToken={accessToken}
userID={userID}
userRole={userRole}
availableAccessGroups={uniqueMcpAccessGroups}
/>
) : (
<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-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">{isInternalUser ? "All Available Servers" : "All Servers"}</span>
</Option>
<Option value="personal">
<Tabs defaultValue="servers" className="w-full h-full">
<TabsList className="mt-2 flex justify-start">
<TabsTrigger value="servers">All Servers</TabsTrigger>
<TabsTrigger value="toolsets">Toolsets</TabsTrigger>
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="semantic">Semantic Filter</TabsTrigger>
<TabsTrigger value="network">Network Settings</TabsTrigger>
{isAdminRole(userRole) && (
<TabsTrigger value="submissions">
<span className="flex items-center gap-2">
Submitted MCPs <NewBadge />
</span>
</TabsTrigger>
)}
</TabsList>
<TabsContent value="servers">
{selectedServerId ? (
<MCPServerView
key={selectedServerId}
mcpServer={selectedServer}
onBack={handleBack}
isProxyAdmin={isAdminRole(userRole)}
isEditing={editServer}
accessToken={accessToken}
userID={userID}
userRole={userRole}
availableAccessGroups={uniqueMcpAccessGroups}
/>
) : (
<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-background rounded-lg px-4 py-3 border border-border">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
Team
</span>
<Select value={selectedTeam} onValueChange={handleTeamChange}>
<SelectTrigger className="w-[220px]" aria-label="Team">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="font-medium">
{isInternalUser ? "All Available Servers" : "All Servers"}
</span>
</SelectItem>
<SelectItem value="personal">
<span className="font-medium">Personal</span>
</Option>
</SelectItem>
{uniqueTeams.map((team) => (
<Option key={team.team_id} value={team.team_id}>
<SelectItem key={team.team_id} value={team.team_id}>
<span className="font-medium">{team.team_alias || team.team_id}</span>
</Option>
</SelectItem>
))}
</Select>
</div>
<div className="h-6 w-px bg-gray-200"></div>
<div className="flex items-center gap-2">
<Text className="text-sm font-medium text-gray-600 whitespace-nowrap">
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" }} />
</SelectContent>
</Select>
</div>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap flex items-center">
Access Group
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<QuestionCircleOutlined className="ml-1 h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
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>
<Select value={selectedMcpAccessGroup} onChange={handleMcpAccessGroupChange} style={{ width: 220 }} size="middle">
<Option value="all">
</TooltipProvider>
</span>
<Select
value={selectedMcpAccessGroup}
onValueChange={handleMcpAccessGroupChange}
>
<SelectTrigger className="w-[220px]" aria-label="Access Group">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<span className="font-medium">All Access Groups</span>
</Option>
</SelectItem>
{uniqueMcpAccessGroups.map((group) => (
<Option key={group} value={group}>
<SelectItem key={group} value={group}>
<span className="font-medium">{group}</span>
</Option>
</SelectItem>
))}
</Select>
</div>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="w-full mt-6">
<DataTable
data={filteredServers}
columns={columns}
renderSubComponent={() => <div></div>}
getRowCanExpand={() => false}
isLoading={isLoadingServers}
noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started."
loadingMessage="Loading MCP servers..."
enableSorting={true}
/>
</div>
</div>
)}
</TabPanel>
<TabPanel>
<MCPToolsetsTab accessToken={accessToken} userRole={userRole} />
</TabPanel>
<TabPanel>
<MCPConnect />
</TabPanel>
<TabPanel>
<MCPSemanticFilterSettings accessToken={accessToken} />
</TabPanel>
<TabPanel>
<MCPNetworkSettings accessToken={accessToken} />
</TabPanel>
{isAdminRole(userRole) && (
<TabPanel>
<MCPSubmissionsTab accessToken={accessToken} />
</TabPanel>
<div className="w-full mt-6">
<DataTable
data={filteredServers}
columns={columns}
renderSubComponent={() => <div></div>}
getRowCanExpand={() => false}
isLoading={isLoadingServers}
noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started."
loadingMessage="Loading MCP servers..."
enableSorting={true}
/>
</div>
</div>
)}
</TabPanels>
</TabGroup>
</TabsContent>
<TabsContent value="toolsets">
<MCPToolsetsTab accessToken={accessToken} userRole={userRole} />
</TabsContent>
<TabsContent value="connect">
<MCPConnect />
</TabsContent>
<TabsContent value="semantic">
<MCPSemanticFilterSettings accessToken={accessToken} />
</TabsContent>
<TabsContent value="network">
<MCPNetworkSettings accessToken={accessToken} />
</TabsContent>
{isAdminRole(userRole) && (
<TabsContent value="submissions">
<MCPSubmissionsTab accessToken={accessToken} />
</TabsContent>
)}
</Tabs>
{byokModalServer && (
<ByokCredentialModal

View file

@ -4,10 +4,16 @@ import { ToolTestPanel } from "./ToolTestPanel";
import { MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse } from "./types";
import { listMCPTools, callMCPTool } from "../networking";
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
import { Card, Title, Text } from "@tremor/react";
import { Bot as RobotOutlined, Wrench as ToolOutlined, Search as SearchOutlined, Key as KeyOutlined } from "lucide-react";
import { Input, Button as AntdButton } from "antd";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Bot as RobotOutlined,
Wrench as ToolOutlined,
Search as SearchOutlined,
Key as KeyOutlined,
Check as CheckIcon,
} from "lucide-react";
const MCPToolsViewer = ({
serverId,
@ -22,33 +28,27 @@ const MCPToolsViewer = ({
const [toolResult, setToolResult] = useState<MCPContent[] | null>(null);
const [toolError, setToolError] = useState<Error | null>(null);
const [toolSearchTerm, setToolSearchTerm] = useState("");
// State for passthrough headers
const [passthroughHeaders, setPassthroughHeaders] = useState<Record<string, string>>({});
const [showHeaderInput, setShowHeaderInput] = useState(false);
// Check if this server has extra headers configured
const hasExtraHeaders = extraHeaders && extraHeaders.length > 0;
// Build custom headers for MCP server requests
const buildCustomHeaders = () => {
if (!serverAlias || !hasExtraHeaders) return undefined;
const customHeaders: Record<string, string> = {};
// Add passthrough headers with server-specific prefix
Object.entries(passthroughHeaders).forEach(([headerName, headerValue]) => {
if (headerValue && headerValue.trim()) {
// Format: x-mcp-{alias}-{header_name}
const mcpHeaderName = `x-mcp-${serverAlias}-${headerName.toLowerCase()}`;
customHeaders[mcpHeaderName] = headerValue;
}
});
return Object.keys(customHeaders).length > 0 ? customHeaders : undefined;
};
// Query to fetch MCP tools
const {
data: mcpToolsResponse,
isLoading: isLoadingTools,
@ -61,21 +61,20 @@ const MCPToolsViewer = ({
return listMCPTools(accessToken, serverId, buildCustomHeaders());
},
enabled: !!accessToken,
staleTime: 30000, // Consider data fresh for 30 seconds
staleTime: 30000,
});
// Mutation for calling a tool
const { mutate: executeTool, isPending: isCallingTool } = useMutation({
mutationFn: async (args: { tool: MCPTool; arguments: Record<string, any> }) => {
if (!accessToken) throw new Error("Access Token required");
try {
const result: CallMCPToolResponse = await callMCPTool(
accessToken,
serverId,
args.tool.name,
accessToken,
serverId,
args.tool.name,
args.arguments,
{ customHeaders: buildCustomHeaders() }
{ customHeaders: buildCustomHeaders() },
);
return result;
} catch (error) {
@ -94,7 +93,6 @@ const MCPToolsViewer = ({
const toolsData = mcpToolsResponse?.tools || [];
// Filter tools based on search term
const filteredTools = toolsData.filter((tool: MCPTool) => {
const searchLower = toolSearchTerm.toLowerCase();
return (
@ -105,83 +103,83 @@ const MCPToolsViewer = ({
});
return (
<div className="w-full h-screen p-4 bg-white">
<div className="w-full h-screen p-4 bg-background">
<Card className="w-full rounded-xl shadow-md overflow-hidden">
<div className="flex h-auto w-full 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="w-1/4 p-4 bg-muted flex flex-col">
<h2 className="text-xl font-semibold mb-6 mt-2 text-foreground">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="mb-4 p-3 bg-primary/5 border border-primary/20 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center">
<KeyOutlined className="text-blue-600 mr-2" />
<Text className="text-sm font-medium text-blue-800">
<KeyOutlined className="text-primary mr-2 h-4 w-4" />
<span className="text-sm font-medium text-foreground">
Additional Headers
</Text>
</span>
</div>
<AntdButton
size="small"
type="link"
<Button
size="sm"
variant="link"
onClick={() => setShowHeaderInput(!showHeaderInput)}
className="text-blue-700 p-0 h-auto"
className="text-primary p-0 h-auto"
>
{showHeaderInput ? "Hide" : "Configure"}
</AntdButton>
</Button>
</div>
{!showHeaderInput && Object.keys(passthroughHeaders).length === 0 && (
<Text className="text-xs text-blue-700">
<span className="text-xs text-muted-foreground">
This server requires additional headers. Click &quot;Configure&quot; to provide values.
</Text>
</span>
)}
{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">
<label className="block text-xs font-medium text-foreground 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"
/>
<div className="relative">
<KeyOutlined className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={`Enter ${headerName}`}
value={passthroughHeaders[headerName] || ""}
onChange={(e) => {
setPassthroughHeaders({
...passthroughHeaders,
[headerName]: e.target.value,
});
}}
className="pl-9 h-8"
/>
</div>
</div>
))}
<AntdButton
size="small"
type="primary"
<Button
size="sm"
onClick={() => {
refetchTools();
setShowHeaderInput(false);
}}
disabled={Object.values(passthroughHeaders).every(v => !v || !v.trim())}
disabled={Object.values(passthroughHeaders).every((v) => !v || !v.trim())}
className="w-full mt-2"
>
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="text-xs text-green-700 dark:text-green-400 flex items-center">
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-2"></span>
{Object.keys(passthroughHeaders).length} header(s) configured
</Text>
</span>
</div>
)}
</div>
@ -189,74 +187,60 @@ 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
<span className="font-medium block mb-3 text-foreground flex items-center">
<ToolOutlined className="mr-2 h-4 w-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">
<span className="ml-2 bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 rounded-full">
{toolsData.length}
</span>
)}
</Text>
</span>
{/* Search Bar */}
{toolsData.length > 0 && (
<div className="mb-3">
<div className="mb-3 relative">
<SearchOutlined className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search tools..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="middle"
className="pl-9"
/>
</div>
)}
{/* Loading State */}
{isLoadingTools && (
<div className="flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg">
<div className="flex flex-col items-center justify-center py-8 bg-background border border-border 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 className="animate-spin rounded-full h-6 w-6 border-2 border-muted"></div>
<div className="animate-spin rounded-full h-6 w-6 border-2 border-primary border-t-transparent absolute top-0"></div>
</div>
<p className="text-xs font-medium text-gray-700">Loading tools...</p>
<p className="text-xs font-medium text-foreground">Loading tools...</p>
</div>
)}
{/* Error State */}
{mcpToolsResponse?.error && !isLoadingTools && !toolsData.length && (
<div className="p-3 text-xs text-red-800 rounded-lg bg-red-50 border border-red-200">
<div className="p-3 text-xs text-destructive rounded-lg bg-destructive/10 border border-destructive/30">
<p className="font-medium">Error: {mcpToolsResponse.message}</p>
</div>
)}
{/* No Tools State */}
{!isLoadingTools && !mcpToolsResponse?.error && (!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">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 8.172V5L8 4z"
/>
</svg>
<div className="p-4 text-center bg-background border border-border rounded-lg">
<div className="mx-auto w-8 h-8 bg-muted rounded-full flex items-center justify-center mb-2">
<ToolOutlined className="w-4 h-4 text-muted-foreground" />
</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="text-xs font-medium text-foreground mb-1">No tools available</p>
<p className="text-xs text-muted-foreground">No tools found for this server</p>
</div>
)}
{/* Tools List */}
{!isLoadingTools && !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="p-4 text-center bg-background border border-border rounded-lg">
<SearchOutlined className="text-2xl text-muted-foreground mb-2 mx-auto h-5 w-5" />
<p className="text-xs font-medium text-foreground mb-1">No tools found</p>
<p className="text-xs text-muted-foreground">No tools match &quot;{toolSearchTerm}&quot;</p>
</div>
) : (
<div
@ -264,53 +248,51 @@ const MCPToolsViewer = ({
style={{
maxHeight: "400px",
scrollbarWidth: "auto",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
{filteredTools.map((tool: MCPTool) => (
<div
key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-sm ${selectedTool?.name === tool.name
? "border-blue-500 bg-blue-50 ring-1 ring-blue-200"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => {
setSelectedTool(tool);
setToolResult(null);
setToolError(null);
}}
>
<div className="flex items-start space-x-2">
{tool.mcp_info.logo_url && (
<img
src={tool.mcp_info.logo_url}
alt={`${tool.mcp_info.server_name} logo`}
className="w-4 h-4 object-contain flex-shrink-0 mt-0.5"
/>
)}
<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">
{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">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
<div
key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-sm ${
selectedTool?.name === tool.name
? "border-primary bg-primary/5 ring-1 ring-primary/40"
: "border-border bg-background hover:border-muted-foreground/40"
}`}
onClick={() => {
setSelectedTool(tool);
setToolResult(null);
setToolError(null);
}}
>
<div className="flex items-start space-x-2">
{tool.mcp_info.logo_url && (
<img
src={tool.mcp_info.logo_url}
alt={`${tool.mcp_info.server_name} logo`}
className="w-4 h-4 object-contain flex-shrink-0 mt-0.5"
/>
</svg>
Selected
)}
<div className="flex-1 min-w-0">
<h4 className="font-mono text-xs font-medium text-foreground truncate">
{tool.name}
</h4>
<p className="text-xs text-muted-foreground truncate">
{tool.mcp_info.server_name}
</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 leading-relaxed">
{tool.description}
</p>
</div>
</div>
{selectedTool?.name === tool.name && (
<div className="mt-2 pt-2 border-t border-primary/30">
<div className="flex items-center text-xs font-medium text-primary">
<CheckIcon className="w-3 h-3 mr-1" />
Selected
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
@ -321,23 +303,24 @@ 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="w-3/4 flex flex-col bg-background">
<div className="p-4 border-b border-border flex justify-between items-center">
<h2 className="text-xl font-semibold mb-0 text-foreground">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">
Choose a tool from the left sidebar to start testing its functionality with custom inputs.
</Text>
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<RobotOutlined className="h-12 w-12 mb-4" />
<span className="text-lg font-medium text-foreground mb-2">
Select a Tool to Test
</span>
<span className="text-center text-muted-foreground max-w-md">
Choose a tool from the left sidebar to start testing its functionality with custom
inputs.
</span>
</div>
) : (
/* Tool Test Panel */
<div className="h-full">
<ToolTestPanel
tool={selectedTool}