941 lines
39 KiB
TypeScript
941 lines
39 KiB
TypeScript
/*
|
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
│ @author: Davidson Gomes │
|
|
│ @file: /app/mcp-servers/page.tsx │
|
|
│ Developed by: Davidson Gomes │
|
|
│ Creation date: May 13, 2025 │
|
|
│ Contact: contato@evolution-api.com │
|
|
├──────────────────────────────────────────────────────────────────────────────┤
|
|
│ @copyright © Evolution API 2025. All rights reserved. │
|
|
│ Licensed under the Apache License, Version 2.0 │
|
|
│ │
|
|
│ You may not use this file except in compliance with the License. │
|
|
│ You may obtain a copy of the License at │
|
|
│ │
|
|
│ http://www.apache.org/licenses/LICENSE-2.0 │
|
|
│ │
|
|
│ Unless required by applicable law or agreed to in writing, software │
|
|
│ distributed under the License is distributed on an "AS IS" BASIS, │
|
|
│ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. │
|
|
│ See the License for the specific language governing permissions and │
|
|
│ limitations under the License. │
|
|
├──────────────────────────────────────────────────────────────────────────────┤
|
|
│ @important │
|
|
│ For any future changes to the code in this file, it is recommended to │
|
|
│ include, together with the modification, the information of the developer │
|
|
│ who changed it and the date of modification. │
|
|
└──────────────────────────────────────────────────────────────────────────────┘
|
|
*/
|
|
"use client"
|
|
|
|
import type React from "react"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog"
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { useToast } from "@/components/ui/use-toast"
|
|
import { Plus, MoreHorizontal, Edit, Trash2, Search, PenToolIcon as Tool } from "lucide-react"
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import {
|
|
createMCPServer,
|
|
listMCPServers,
|
|
getMCPServer,
|
|
updateMCPServer,
|
|
deleteMCPServer,
|
|
} from "@/services/mcpServerService"
|
|
import { MCPServer, MCPServerCreate, ToolConfig } from "@/types/mcpServer"
|
|
|
|
export default function MCPServersPage() {
|
|
const { toast } = useToast()
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
|
const [selectedServer, setSelectedServer] = useState<MCPServer | null>(null)
|
|
const [activeTab, setActiveTab] = useState("basic")
|
|
|
|
const [serverData, setServerData] = useState<{
|
|
name: string
|
|
description: string
|
|
type: string
|
|
config_type: "sse" | "studio"
|
|
url: string
|
|
headers: { key: string; value: string }[]
|
|
command: string
|
|
args: string
|
|
environments: { key: string }[]
|
|
tools: ToolConfig[]
|
|
}>({
|
|
name: "",
|
|
description: "",
|
|
type: "official",
|
|
config_type: "sse",
|
|
url: "",
|
|
headers: [{ key: "x-api-key", value: "" }],
|
|
command: "npx",
|
|
args: "",
|
|
environments: [],
|
|
tools: [],
|
|
})
|
|
|
|
const [page, setPage] = useState(1)
|
|
const [limit, setLimit] = useState(10)
|
|
const [total, setTotal] = useState(0)
|
|
|
|
const [mcpServers, setMcpServers] = useState<MCPServer[]>([])
|
|
|
|
useEffect(() => {
|
|
const fetchServers = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const res = await listMCPServers((page - 1) * limit, limit)
|
|
setMcpServers(res.data)
|
|
setTotal(res.data.length)
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error loading MCP servers",
|
|
description: "Unable to load servers.",
|
|
variant: "destructive",
|
|
})
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
fetchServers()
|
|
}, [page, limit])
|
|
|
|
// Search server by name/description (local filter)
|
|
const filteredServers = Array.isArray(mcpServers)
|
|
? mcpServers.filter(
|
|
(server) =>
|
|
server.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(server.description || "").toLowerCase().includes(searchQuery.toLowerCase()),
|
|
)
|
|
: []
|
|
|
|
const handleAddHeader = () => {
|
|
setServerData({
|
|
...serverData,
|
|
headers: [...serverData.headers, { key: "", value: "" }],
|
|
})
|
|
}
|
|
|
|
const handleRemoveHeader = (index: number) => {
|
|
const updatedHeaders = [...serverData.headers]
|
|
updatedHeaders.splice(index, 1)
|
|
setServerData({
|
|
...serverData,
|
|
headers: updatedHeaders,
|
|
})
|
|
}
|
|
|
|
const handleHeaderChange = (index: number, field: "key" | "value", value: string) => {
|
|
const updatedHeaders = [...serverData.headers]
|
|
updatedHeaders[index][field] = value
|
|
setServerData({
|
|
...serverData,
|
|
headers: updatedHeaders,
|
|
})
|
|
}
|
|
|
|
const handleAddEnvironment = () => {
|
|
setServerData({
|
|
...serverData,
|
|
environments: [...serverData.environments, { key: "" }],
|
|
})
|
|
}
|
|
|
|
const handleRemoveEnvironment = (index: number) => {
|
|
const updatedEnvironments = [...serverData.environments]
|
|
updatedEnvironments.splice(index, 1)
|
|
setServerData({
|
|
...serverData,
|
|
environments: updatedEnvironments,
|
|
})
|
|
}
|
|
|
|
const handleEnvironmentChange = (index: number, value: string) => {
|
|
const updatedEnvironments = [...serverData.environments]
|
|
updatedEnvironments[index].key = value
|
|
setServerData({
|
|
...serverData,
|
|
environments: updatedEnvironments,
|
|
})
|
|
}
|
|
|
|
const handleAddTool = () => {
|
|
const name = "new_tool";
|
|
const newTool: ToolConfig = {
|
|
id: name,
|
|
name: name,
|
|
description: "",
|
|
tags: [],
|
|
examples: [],
|
|
inputModes: ["text"],
|
|
outputModes: ["text"],
|
|
}
|
|
setServerData({
|
|
...serverData,
|
|
tools: [...serverData.tools, newTool],
|
|
})
|
|
}
|
|
|
|
const handleRemoveTool = (index: number) => {
|
|
const updatedTools = [...serverData.tools]
|
|
updatedTools.splice(index, 1)
|
|
setServerData({
|
|
...serverData,
|
|
tools: updatedTools,
|
|
})
|
|
}
|
|
|
|
const handleToolChange = (index: number, field: keyof ToolConfig, value: any) => {
|
|
const updatedTools = [...serverData.tools]
|
|
updatedTools[index] = {
|
|
...updatedTools[index],
|
|
[field]: value,
|
|
}
|
|
|
|
if (field === 'name') {
|
|
updatedTools[index].id = value;
|
|
}
|
|
|
|
setServerData({
|
|
...serverData,
|
|
tools: updatedTools,
|
|
})
|
|
}
|
|
|
|
const handleAddServer = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setIsLoading(true)
|
|
try {
|
|
// Convert environments array to object
|
|
const environmentsObj: Record<string, string> = {}
|
|
serverData.environments.forEach((env) => {
|
|
if (env.key) {
|
|
environmentsObj[env.key] = `env@@${env.key}`
|
|
}
|
|
})
|
|
// Convert headers array to object
|
|
const headersObj: Record<string, string> = {}
|
|
serverData.headers.forEach((header) => {
|
|
if (header.key) {
|
|
headersObj[header.key] = header.value
|
|
}
|
|
})
|
|
let config_json: any = {}
|
|
if (serverData.config_type === "sse") {
|
|
config_json = {
|
|
url: serverData.url,
|
|
headers: headersObj,
|
|
}
|
|
} else if (serverData.config_type === "studio") {
|
|
const args = serverData.args.split("\n").filter((arg) => arg.trim() !== "")
|
|
const envObj: Record<string, string> = {}
|
|
serverData.environments.forEach((env) => {
|
|
if (env.key) {
|
|
envObj[env.key] = `env@@${env.key}`
|
|
}
|
|
})
|
|
config_json = {
|
|
command: serverData.command,
|
|
args: args,
|
|
env: envObj,
|
|
}
|
|
}
|
|
const payload: MCPServerCreate = {
|
|
name: serverData.name,
|
|
description: serverData.description,
|
|
type: serverData.type,
|
|
config_type: serverData.config_type,
|
|
config_json,
|
|
environments: environmentsObj,
|
|
tools: serverData.tools,
|
|
}
|
|
if (selectedServer) {
|
|
await updateMCPServer(selectedServer.id, payload)
|
|
toast({
|
|
title: "Server updated",
|
|
description: `${serverData.name} was updated successfully.`,
|
|
})
|
|
} else {
|
|
await createMCPServer(payload)
|
|
toast({
|
|
title: "Server added",
|
|
description: `${serverData.name} was added successfully.`,
|
|
})
|
|
}
|
|
setIsDialogOpen(false)
|
|
resetForm()
|
|
// Reload list
|
|
const res = await listMCPServers((page - 1) * limit, limit)
|
|
setMcpServers(res.data)
|
|
setTotal(res.data.length)
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error",
|
|
description: "Unable to save the MCP server. Please try again.",
|
|
variant: "destructive",
|
|
})
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleEditServer = async (server: MCPServer) => {
|
|
setIsLoading(true)
|
|
try {
|
|
const res = await getMCPServer(server.id)
|
|
setSelectedServer(res.data)
|
|
// Convert environments object to array
|
|
const environmentsArray = Object.keys(res.data.environments || {}).map((key) => ({ key }))
|
|
// Convert headers object to array
|
|
const headersArray = res.data.config_json.headers
|
|
? Object.entries(res.data.config_json.headers).map(([key, value]) => ({ key, value: value as string }))
|
|
: [{ key: "x-api-key", value: "" }]
|
|
if (res.data.config_type === "sse") {
|
|
setServerData({
|
|
name: res.data.name,
|
|
description: res.data.description || "",
|
|
type: res.data.type,
|
|
config_type: res.data.config_type as any,
|
|
url: res.data.config_json.url || "",
|
|
headers: headersArray,
|
|
command: "",
|
|
args: "",
|
|
environments: environmentsArray,
|
|
tools: res.data.tools,
|
|
})
|
|
} else if (res.data.config_type === "studio") {
|
|
setServerData({
|
|
name: res.data.name,
|
|
description: res.data.description || "",
|
|
type: res.data.type,
|
|
config_type: res.data.config_type as any,
|
|
url: "",
|
|
headers: [],
|
|
command: res.data.config_json.command || "npx",
|
|
args: (res.data.config_json.args || []).join("\n"),
|
|
environments: environmentsArray,
|
|
tools: res.data.tools,
|
|
})
|
|
}
|
|
setActiveTab("basic")
|
|
setIsDialogOpen(true)
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error searching MCP server",
|
|
description: "Unable to search the server.",
|
|
variant: "destructive",
|
|
})
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteServer = (server: MCPServer) => {
|
|
setSelectedServer(server)
|
|
setIsDeleteDialogOpen(true)
|
|
}
|
|
|
|
const confirmDeleteServer = async () => {
|
|
if (!selectedServer) return
|
|
setIsLoading(true)
|
|
try {
|
|
await deleteMCPServer(selectedServer.id)
|
|
toast({
|
|
title: "Server deleted",
|
|
description: `${selectedServer.name} was deleted successfully.`,
|
|
})
|
|
setIsDeleteDialogOpen(false)
|
|
setSelectedServer(null)
|
|
// Reload list
|
|
const res = await listMCPServers((page - 1) * limit, limit)
|
|
setMcpServers(res.data)
|
|
setTotal(res.data.length)
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error",
|
|
description: "Unable to delete the server. Please try again.",
|
|
variant: "destructive",
|
|
})
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const resetForm = () => {
|
|
setServerData({
|
|
name: "",
|
|
description: "",
|
|
type: "official",
|
|
config_type: "sse",
|
|
url: "",
|
|
headers: [{ key: "x-api-key", value: "" }],
|
|
command: "npx",
|
|
args: "",
|
|
environments: [],
|
|
tools: [],
|
|
})
|
|
setSelectedServer(null)
|
|
setActiveTab("basic")
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h1 className="text-3xl font-bold text-white">MCP Servers Management</h1>
|
|
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button onClick={resetForm} className="bg-emerald-400 text-black hover:bg-[#00cc7d]">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
New MCP Server
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-hidden flex flex-col bg-[#1a1a1a] border-[#333]">
|
|
<form onSubmit={handleAddServer}>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-white">
|
|
{selectedServer ? "Edit MCP Server" : "New MCP Server"}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-neutral-400">
|
|
{selectedServer
|
|
? "Edit the existing MCP server information."
|
|
: "Fill in the information to create a new MCP server."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 overflow-hidden flex flex-col">
|
|
<TabsList className="grid grid-cols-3 bg-[#222]">
|
|
<TabsTrigger
|
|
value="basic"
|
|
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
|
|
>
|
|
Basic Information
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="environments"
|
|
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
|
|
>
|
|
Environment Variables
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="tools"
|
|
className="data-[state=active]:bg-[#333] data-[state=active]:text-emerald-400"
|
|
>
|
|
Tools
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<div className="overflow-y-auto max-h-[60vh] p-4">
|
|
<TabsContent value="basic" className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name" className="text-neutral-300">
|
|
Name
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
value={serverData.name}
|
|
onChange={(e) => setServerData({ ...serverData, name: e.target.value })}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="MCP Server Name"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description" className="text-neutral-300">
|
|
Description
|
|
</Label>
|
|
<Textarea
|
|
id="description"
|
|
value={serverData.description}
|
|
onChange={(e) => setServerData({ ...serverData, description: e.target.value })}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="MCP Server Description"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="type" className="text-neutral-300">
|
|
Type
|
|
</Label>
|
|
<Select
|
|
value={serverData.type}
|
|
onValueChange={(value) => setServerData({ ...serverData, type: value })}
|
|
>
|
|
<SelectTrigger id="type" className="w-full bg-[#222] border-[#444] text-white">
|
|
<SelectValue placeholder="Select the type" />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#222] border-[#444] text-white">
|
|
<SelectItem value="official">Official</SelectItem>
|
|
<SelectItem value="community">Community</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="config_type" className="text-neutral-300">
|
|
Configuration Type
|
|
</Label>
|
|
<Select
|
|
value={serverData.config_type}
|
|
onValueChange={(value: "sse" | "studio") =>
|
|
setServerData({ ...serverData, config_type: value })
|
|
}
|
|
>
|
|
<SelectTrigger id="config_type" className="w-full bg-[#222] border-[#444] text-white">
|
|
<SelectValue placeholder="Select the configuration type" />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#222] border-[#444] text-white">
|
|
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
|
|
<SelectItem value="studio">Studio</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Specific fields for SSE */}
|
|
{serverData.config_type === "sse" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="url" className="text-neutral-300">
|
|
URL
|
|
</Label>
|
|
<Input
|
|
id="url"
|
|
value={serverData.url}
|
|
onChange={(e) => setServerData({ ...serverData, url: e.target.value })}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="https://your_server.com/sse"
|
|
required={serverData.config_type === "sse"}
|
|
/>
|
|
</div>
|
|
|
|
{/* Dynamic headers */}
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<Label className="text-neutral-300">Headers</Label>
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddHeader}
|
|
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
|
|
size="sm"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Header
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{serverData.headers.map((header, index) => (
|
|
<div key={index} className="flex gap-2 items-start">
|
|
<div className="flex-1">
|
|
<Label htmlFor={`header-key-${index}`} className="sr-only">
|
|
Header Name
|
|
</Label>
|
|
<Input
|
|
id={`header-key-${index}`}
|
|
value={header.key}
|
|
onChange={(e) => handleHeaderChange(index, "key", e.target.value)}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="Header Name"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<Label htmlFor={`header-value-${index}`} className="sr-only">
|
|
Header Value
|
|
</Label>
|
|
<Input
|
|
id={`header-value-${index}`}
|
|
value={header.value}
|
|
onChange={(e) => handleHeaderChange(index, "value", e.target.value)}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="Header Value"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveHeader(index)}
|
|
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Specific fields for Studio */}
|
|
{serverData.config_type === "studio" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="command" className="text-neutral-300">
|
|
Command
|
|
</Label>
|
|
<Input
|
|
id="command"
|
|
value={serverData.command}
|
|
onChange={(e) => setServerData({ ...serverData, command: e.target.value })}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="npx"
|
|
required={serverData.config_type === "studio"}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="args" className="text-neutral-300">
|
|
Arguments (one per line)
|
|
</Label>
|
|
<Textarea
|
|
id="args"
|
|
value={serverData.args}
|
|
onChange={(e) => setServerData({ ...serverData, args: e.target.value })}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="-y
|
|
@modelcontextprotocol/server-brave-search"
|
|
required={serverData.config_type === "studio"}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="environments" className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-lg font-medium text-white">Environment Variables</h3>
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddEnvironment}
|
|
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
|
|
size="sm"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Environment Variable
|
|
</Button>
|
|
</div>
|
|
|
|
{serverData.environments.length === 0 ? (
|
|
<div className="text-center py-8 text-neutral-400">
|
|
No environment variables configured. Click "Add Variable" to start.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{serverData.environments.map((env, index) => (
|
|
<div key={index} className="flex gap-2 items-start">
|
|
<div className="flex-1">
|
|
<Label htmlFor={`env-key-${index}`} className="sr-only">
|
|
Environment Variable Name
|
|
</Label>
|
|
<Input
|
|
id={`env-key-${index}`}
|
|
value={env.key}
|
|
onChange={(e) => handleEnvironmentChange(index, e.target.value)}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="ENV_VARIABLE_NAME"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveEnvironment(index)}
|
|
className="text-red-500 hover:text-red-400 hover:bg-[#333]"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tools" className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-lg font-medium text-white">Tools</h3>
|
|
<Button
|
|
type="button"
|
|
onClick={handleAddTool}
|
|
className="bg-emerald-400 text-black hover:bg-[#00cc7d]"
|
|
size="sm"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Tool
|
|
</Button>
|
|
</div>
|
|
|
|
{serverData.tools.length === 0 ? (
|
|
<div className="text-center py-8 text-neutral-400">
|
|
No tools configured. Click "Add Tool" to start.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{serverData.tools.map((tool, index) => (
|
|
<Card key={index} className="bg-[#222] border-[#444]">
|
|
<CardHeader className="pb-2 flex flex-row justify-between items-start">
|
|
<div>
|
|
<CardTitle className="text-white text-base">Tool {index + 1}</CardTitle>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveTool(index)}
|
|
className="text-red-500 hover:text-red-400 hover:bg-[#333] h-8 w-8"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`tool-name-${index}`} className="text-neutral-300">
|
|
Name
|
|
</Label>
|
|
<Input
|
|
id={`tool-name-${index}`}
|
|
value={tool.name}
|
|
onChange={(e) => handleToolChange(index, "name", e.target.value)}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="tool_name"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`tool-description-${index}`} className="text-neutral-300">
|
|
Description
|
|
</Label>
|
|
<Textarea
|
|
id={`tool-description-${index}`}
|
|
value={tool.description}
|
|
onChange={(e) => handleToolChange(index, "description", e.target.value)}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="Tool Description"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`tool-tags-${index}`} className="text-neutral-300">
|
|
Tags (separated by comma)
|
|
</Label>
|
|
<Input
|
|
id={`tool-tags-${index}`}
|
|
value={(tool.tags ?? []).join(", ")}
|
|
onChange={(e) => handleToolChange(index, "tags", e.target.value.split(", "))}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="tag1, tag2, tag3"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`tool-examples-${index}`} className="text-neutral-300">
|
|
Examples (separated by comma)
|
|
</Label>
|
|
<Textarea
|
|
id={`tool-examples-${index}`}
|
|
value={(tool.examples ?? []).join(", ")}
|
|
onChange={(e) => handleToolChange(index, "examples", e.target.value.split(", "))}
|
|
className="bg-[#222] border-[#444] text-white"
|
|
placeholder="Example 1, Example 2"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
|
|
<DialogFooter className="mt-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setIsDialogOpen(false)}
|
|
className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" className="bg-emerald-400 text-black hover:bg-[#00cc7d]" disabled={isLoading}>
|
|
{isLoading ? "Saving..." : selectedServer ? "Save Changes" : "Add Server"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent className="bg-[#1a1a1a] border-[#333] text-white">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirm delete</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-neutral-400">
|
|
Are you sure you want to delete the server "{selectedServer?.name}"? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="border-[#444] text-neutral-300 hover:bg-[#333] hover:text-white">
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmDeleteServer}
|
|
className="bg-red-600 text-white hover:bg-red-700"
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? "Deleting..." : "Delete"}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
|
|
<Card className="bg-[#1a1a1a] border-[#333] mb-6">
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-white text-lg">Search MCP Servers</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-500" />
|
|
<Input
|
|
placeholder="Search by name or description..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="bg-[#222] border-[#444] text-white pl-10"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-[#1a1a1a] border-[#333]">
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="border-[#333] hover:bg-[#222]">
|
|
<TableHead className="text-neutral-300">Name</TableHead>
|
|
<TableHead className="text-neutral-300">Description</TableHead>
|
|
<TableHead className="text-neutral-300">Type</TableHead>
|
|
<TableHead className="text-neutral-300">Configuration</TableHead>
|
|
<TableHead className="text-neutral-300">Tools</TableHead>
|
|
<TableHead className="text-neutral-300 text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredServers.length > 0 ? (
|
|
filteredServers.map((server) => (
|
|
<TableRow key={server.id} className="border-[#333] hover:bg-[#222]">
|
|
<TableCell className="font-medium text-white">{server.name}</TableCell>
|
|
<TableCell className="text-neutral-300">{server.description}</TableCell>
|
|
<TableCell className="text-neutral-300">
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
server.type === "official"
|
|
? "bg-emerald-400/10 text-emerald-400 border-emerald-400/30"
|
|
: "bg-[#ff9d00]/10 text-[#ff9d00] border-[#ff9d00]/30"
|
|
}
|
|
>
|
|
{server.type === "official" ? "Official" : "Community"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-neutral-300">
|
|
<div className="flex flex-col gap-1">
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
server.config_type === "sse"
|
|
? "bg-[#00b8ff]/10 text-[#00b8ff] border-[#00b8ff]/30"
|
|
: "bg-[#ff5e00]/10 text-[#ff5e00] border-[#ff5e00]/30"
|
|
}
|
|
>
|
|
{server.config_type === "sse" ? "SSE" : "Studio"}
|
|
</Badge>
|
|
{server.config_type === "sse" && (
|
|
<span className="text-xs truncate max-w-[200px]">{server.config_json.url}</span>
|
|
)}
|
|
{server.config_type === "studio" && (
|
|
<span className="text-xs truncate max-w-[200px]">
|
|
{server.config_json.command} {server.config_json.args?.join(" ")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-neutral-300">
|
|
<div className="flex items-center">
|
|
<Tool className="h-4 w-4 mr-1 text-emerald-400" />
|
|
{server.tools.length}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="h-8 w-8 p-0 text-neutral-300 hover:bg-[#333]">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="bg-[#222] border-[#444] text-white">
|
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
<DropdownMenuSeparator className="bg-[#444]" />
|
|
<DropdownMenuItem
|
|
className="cursor-pointer hover:bg-[#333]"
|
|
onClick={() => handleEditServer(server)}
|
|
>
|
|
<Edit className="mr-2 h-4 w-4 text-emerald-400" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
className="cursor-pointer hover:bg-[#333] text-red-500"
|
|
onClick={() => handleDeleteServer(server)}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-24 text-center text-neutral-500">
|
|
No MCP servers found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|