mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
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:
parent
a6e177e123
commit
26c6978808
8 changed files with 1372 additions and 1288 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "Call Tool" 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
›
|
||||
</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">›</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "Configure" 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 "{toolSearchTerm}"</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 "{toolSearchTerm}"</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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue