Spaces:
Running
Running
| "use client"; | |
| import { useState } from "react"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogHeader, | |
| DialogTitle | |
| } from "./ui/dialog"; | |
| import { Button } from "./ui/button"; | |
| import { Input } from "./ui/input"; | |
| import { Label } from "./ui/label"; | |
| import { | |
| PlusCircle, | |
| ServerIcon, | |
| X, | |
| Terminal, | |
| Globe, | |
| ExternalLink, | |
| Trash2, | |
| CheckCircle, | |
| Plus, | |
| Cog, | |
| Edit2, | |
| Eye, | |
| EyeOff, | |
| AlertTriangle, | |
| RefreshCw, | |
| Power | |
| } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { | |
| Accordion, | |
| AccordionContent, | |
| AccordionItem, | |
| AccordionTrigger | |
| } from "./ui/accordion"; | |
| import { KeyValuePair, MCPServer, ServerStatus, useMCP } from "@/lib/context/mcp-context"; | |
| import { | |
| Tooltip, | |
| TooltipContent, | |
| TooltipProvider, | |
| TooltipTrigger, | |
| } from "./ui/tooltip"; | |
| // Default template for a new MCP server | |
| const INITIAL_NEW_SERVER: Omit<MCPServer, 'id'> = { | |
| name: '', | |
| url: '', | |
| type: 'sse', | |
| command: 'node', | |
| args: [], | |
| env: [], | |
| headers: [] | |
| }; | |
| interface MCPServerManagerProps { | |
| servers: MCPServer[]; | |
| onServersChange: (servers: MCPServer[]) => void; | |
| selectedServers: string[]; | |
| onSelectedServersChange: (serverIds: string[]) => void; | |
| open: boolean; | |
| onOpenChange: (open: boolean) => void; | |
| } | |
| // Check if a key name might contain sensitive information | |
| const isSensitiveKey = (key: string): boolean => { | |
| const sensitivePatterns = [ | |
| /key/i, | |
| /token/i, | |
| /secret/i, | |
| /password/i, | |
| /pass/i, | |
| /auth/i, | |
| /credential/i | |
| ]; | |
| return sensitivePatterns.some(pattern => pattern.test(key)); | |
| }; | |
| // Mask a sensitive value | |
| const maskValue = (value: string): string => { | |
| if (!value) return ''; | |
| if (value.length < 8) return '••••••'; | |
| return value.substring(0, 3) + '•'.repeat(Math.min(10, value.length - 4)) + value.substring(value.length - 1); | |
| }; | |
| // Update the StatusIndicator to use Tooltip component | |
| const StatusIndicator = ({ status, onClick, hoverInfo }: { | |
| status?: ServerStatus, | |
| onClick?: () => void, | |
| hoverInfo?: string | |
| }) => { | |
| const isClickable = !!onClick; | |
| const hasHoverInfo = !!hoverInfo; | |
| const className = `flex-shrink-0 flex items-center gap-1 ${isClickable ? 'cursor-pointer' : ''}`; | |
| const statusIndicator = (status: ServerStatus | undefined) => { | |
| switch (status) { | |
| case 'connected': | |
| return ( | |
| <div className={className} onClick={onClick}> | |
| <div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> | |
| <span className="text-xs text-green-500 hover:underline">Connected</span> | |
| </div> | |
| ); | |
| case 'connecting': | |
| return ( | |
| <div className={className} onClick={onClick}> | |
| <RefreshCw className="w-3 h-3 text-amber-500 animate-spin" /> | |
| <span className="text-xs text-amber-500">Connecting</span> | |
| </div> | |
| ); | |
| case 'error': | |
| return ( | |
| <div className={className} onClick={onClick}> | |
| <AlertTriangle className="w-3 h-3 text-red-500" /> | |
| <span className="text-xs text-red-500 hover:underline">Error</span> | |
| </div> | |
| ); | |
| case 'disconnected': | |
| default: | |
| return ( | |
| <div className={className} onClick={onClick}> | |
| <div className="w-2 h-2 rounded-full bg-gray-400" /> | |
| <span className="text-xs text-muted-foreground">Disconnected</span> | |
| </div> | |
| ); | |
| } | |
| }; | |
| // Use Tooltip if we have hover info | |
| if (hasHoverInfo) { | |
| return ( | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| {statusIndicator(status)} | |
| </TooltipTrigger> | |
| <TooltipContent side="top" align="center" className="max-w-[300px] break-all text-wrap"> | |
| {hoverInfo} | |
| </TooltipContent> | |
| </Tooltip> | |
| ); | |
| } | |
| // Otherwise just return the status indicator | |
| return statusIndicator(status); | |
| }; | |
| export const MCPServerManager = ({ | |
| servers, | |
| onServersChange, | |
| selectedServers, | |
| onSelectedServersChange, | |
| open, | |
| onOpenChange | |
| }: MCPServerManagerProps) => { | |
| const [newServer, setNewServer] = useState<Omit<MCPServer, 'id'>>(INITIAL_NEW_SERVER); | |
| const [view, setView] = useState<'list' | 'add'>('list'); | |
| const [newEnvVar, setNewEnvVar] = useState<KeyValuePair>({ key: '', value: '' }); | |
| const [newHeader, setNewHeader] = useState<KeyValuePair>({ key: '', value: '' }); | |
| const [editingServerId, setEditingServerId] = useState<string | null>(null); | |
| const [showSensitiveEnvValues, setShowSensitiveEnvValues] = useState<Record<number, boolean>>({}); | |
| const [showSensitiveHeaderValues, setShowSensitiveHeaderValues] = useState<Record<number, boolean>>({}); | |
| const [editingEnvIndex, setEditingEnvIndex] = useState<number | null>(null); | |
| const [editingHeaderIndex, setEditingHeaderIndex] = useState<number | null>(null); | |
| const [editedEnvValue, setEditedEnvValue] = useState<string>(''); | |
| const [editedHeaderValue, setEditedHeaderValue] = useState<string>(''); | |
| // Add access to the MCP context for server control | |
| const { startServer, stopServer, updateServerStatus } = useMCP(); | |
| const resetAndClose = () => { | |
| setView('list'); | |
| setNewServer(INITIAL_NEW_SERVER); | |
| setNewEnvVar({ key: '', value: '' }); | |
| setNewHeader({ key: '', value: '' }); | |
| setShowSensitiveEnvValues({}); | |
| setShowSensitiveHeaderValues({}); | |
| setEditingEnvIndex(null); | |
| setEditingHeaderIndex(null); | |
| onOpenChange(false); | |
| }; | |
| const addServer = () => { | |
| if (!newServer.name) { | |
| toast.error("Server name is required"); | |
| return; | |
| } | |
| if (newServer.type === 'sse' && !newServer.url) { | |
| toast.error("Server URL is required for HTTP or SSE transport"); | |
| return; | |
| } | |
| if (newServer.type === 'stdio' && (!newServer.command || !newServer.args?.length)) { | |
| toast.error("Command and at least one argument are required for stdio transport"); | |
| return; | |
| } | |
| const id = crypto.randomUUID(); | |
| const updatedServers = [...servers, { ...newServer, id }]; | |
| onServersChange(updatedServers); | |
| toast.success(`Added MCP server: ${newServer.name}`); | |
| setView('list'); | |
| setNewServer(INITIAL_NEW_SERVER); | |
| setNewEnvVar({ key: '', value: '' }); | |
| setNewHeader({ key: '', value: '' }); | |
| setShowSensitiveEnvValues({}); | |
| setShowSensitiveHeaderValues({}); | |
| }; | |
| const removeServer = (id: string, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| const updatedServers = servers.filter(server => server.id !== id); | |
| onServersChange(updatedServers); | |
| // If the removed server was selected, remove it from selected servers | |
| if (selectedServers.includes(id)) { | |
| onSelectedServersChange(selectedServers.filter(serverId => serverId !== id)); | |
| } | |
| toast.success("Server removed"); | |
| }; | |
| const toggleServer = (id: string) => { | |
| if (selectedServers.includes(id)) { | |
| // Remove from selected servers but DON'T stop the server | |
| onSelectedServersChange(selectedServers.filter(serverId => serverId !== id)); | |
| const server = servers.find(s => s.id === id); | |
| if (server) { | |
| toast.success(`Disabled MCP server: ${server.name}`); | |
| } | |
| } else { | |
| // Add to selected servers | |
| onSelectedServersChange([...selectedServers, id]); | |
| const server = servers.find(s => s.id === id); | |
| if (server) { | |
| // Auto-start the server if it's disconnected | |
| if (!server.status || server.status === 'disconnected' || server.status === 'error') { | |
| updateServerStatus(server.id, 'connecting'); | |
| startServer(id) | |
| .then(success => { | |
| if (success) { | |
| console.log(`Server ${server.name} successfully connected`); | |
| } else { | |
| console.error(`Failed to connect server ${server.name}`); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error(`Error connecting server ${server.name}:`, error); | |
| updateServerStatus(server.id, 'error', | |
| `Failed to connect: ${error instanceof Error ? error.message : String(error)}`); | |
| }); | |
| } | |
| toast.success(`Enabled MCP server: ${server.name}`); | |
| } | |
| } | |
| }; | |
| const clearAllServers = () => { | |
| if (selectedServers.length > 0) { | |
| // Just deselect all servers without stopping them | |
| onSelectedServersChange([]); | |
| toast.success("All MCP servers disabled"); | |
| resetAndClose(); | |
| } | |
| }; | |
| const handleArgsChange = (value: string) => { | |
| try { | |
| // Try to parse as JSON if it starts with [ (array) | |
| const argsArray = value.trim().startsWith('[') | |
| ? JSON.parse(value) | |
| : value.split(' ').filter(Boolean); | |
| setNewServer({ ...newServer, args: argsArray }); | |
| } catch (error) { | |
| // If parsing fails, just split by spaces | |
| setNewServer({ ...newServer, args: value.split(' ').filter(Boolean) }); | |
| } | |
| }; | |
| const addEnvVar = () => { | |
| if (!newEnvVar.key) return; | |
| setNewServer({ | |
| ...newServer, | |
| env: [...(newServer.env || []), { ...newEnvVar }] | |
| }); | |
| setNewEnvVar({ key: '', value: '' }); | |
| }; | |
| const removeEnvVar = (index: number) => { | |
| const updatedEnv = [...(newServer.env || [])]; | |
| updatedEnv.splice(index, 1); | |
| setNewServer({ ...newServer, env: updatedEnv }); | |
| // Clean up visibility state for this index | |
| const updatedVisibility = { ...showSensitiveEnvValues }; | |
| delete updatedVisibility[index]; | |
| setShowSensitiveEnvValues(updatedVisibility); | |
| // If currently editing this value, cancel editing | |
| if (editingEnvIndex === index) { | |
| setEditingEnvIndex(null); | |
| } | |
| }; | |
| const startEditEnvValue = (index: number, value: string) => { | |
| setEditingEnvIndex(index); | |
| setEditedEnvValue(value); | |
| }; | |
| const saveEditedEnvValue = () => { | |
| if (editingEnvIndex !== null) { | |
| const updatedEnv = [...(newServer.env || [])]; | |
| updatedEnv[editingEnvIndex] = { | |
| ...updatedEnv[editingEnvIndex], | |
| value: editedEnvValue | |
| }; | |
| setNewServer({ ...newServer, env: updatedEnv }); | |
| setEditingEnvIndex(null); | |
| } | |
| }; | |
| const addHeader = () => { | |
| if (!newHeader.key) return; | |
| setNewServer({ | |
| ...newServer, | |
| headers: [...(newServer.headers || []), { ...newHeader }] | |
| }); | |
| setNewHeader({ key: '', value: '' }); | |
| }; | |
| const removeHeader = (index: number) => { | |
| const updatedHeaders = [...(newServer.headers || [])]; | |
| updatedHeaders.splice(index, 1); | |
| setNewServer({ ...newServer, headers: updatedHeaders }); | |
| // Clean up visibility state for this index | |
| const updatedVisibility = { ...showSensitiveHeaderValues }; | |
| delete updatedVisibility[index]; | |
| setShowSensitiveHeaderValues(updatedVisibility); | |
| // If currently editing this value, cancel editing | |
| if (editingHeaderIndex === index) { | |
| setEditingHeaderIndex(null); | |
| } | |
| }; | |
| const startEditHeaderValue = (index: number, value: string) => { | |
| setEditingHeaderIndex(index); | |
| setEditedHeaderValue(value); | |
| }; | |
| const saveEditedHeaderValue = () => { | |
| if (editingHeaderIndex !== null) { | |
| const updatedHeaders = [...(newServer.headers || [])]; | |
| updatedHeaders[editingHeaderIndex] = { | |
| ...updatedHeaders[editingHeaderIndex], | |
| value: editedHeaderValue | |
| }; | |
| setNewServer({ ...newServer, headers: updatedHeaders }); | |
| setEditingHeaderIndex(null); | |
| } | |
| }; | |
| const toggleSensitiveEnvValue = (index: number) => { | |
| setShowSensitiveEnvValues(prev => ({ | |
| ...prev, | |
| [index]: !prev[index] | |
| })); | |
| }; | |
| const toggleSensitiveHeaderValue = (index: number) => { | |
| setShowSensitiveHeaderValues(prev => ({ | |
| ...prev, | |
| [index]: !prev[index] | |
| })); | |
| }; | |
| const hasAdvancedConfig = (server: MCPServer) => { | |
| return (server.env && server.env.length > 0) || | |
| (server.headers && server.headers.length > 0); | |
| }; | |
| // Editing support | |
| const startEditing = (server: MCPServer) => { | |
| setEditingServerId(server.id); | |
| setNewServer({ | |
| name: server.name, | |
| url: server.url, | |
| type: server.type, | |
| command: server.command, | |
| args: server.args, | |
| env: server.env, | |
| headers: server.headers | |
| }); | |
| setView('add'); | |
| // Reset sensitive value visibility states | |
| setShowSensitiveEnvValues({}); | |
| setShowSensitiveHeaderValues({}); | |
| setEditingEnvIndex(null); | |
| setEditingHeaderIndex(null); | |
| }; | |
| const handleFormCancel = () => { | |
| if (view === 'add') { | |
| setView('list'); | |
| setEditingServerId(null); | |
| setNewServer(INITIAL_NEW_SERVER); | |
| setShowSensitiveEnvValues({}); | |
| setShowSensitiveHeaderValues({}); | |
| setEditingEnvIndex(null); | |
| setEditingHeaderIndex(null); | |
| } else { | |
| resetAndClose(); | |
| } | |
| }; | |
| const updateServer = () => { | |
| if (!newServer.name) { | |
| toast.error("Server name is required"); | |
| return; | |
| } | |
| if (newServer.type === 'sse' && !newServer.url) { | |
| toast.error("Server URL is required for HTTP or SSE transport"); | |
| return; | |
| } | |
| if (newServer.type === 'stdio' && (!newServer.command || !newServer.args?.length)) { | |
| toast.error("Command and at least one argument are required for stdio transport"); | |
| return; | |
| } | |
| const updated = servers.map(s => | |
| s.id === editingServerId ? { ...newServer, id: editingServerId! } : s | |
| ); | |
| onServersChange(updated); | |
| toast.success(`Updated MCP server: ${newServer.name}`); | |
| setView('list'); | |
| setEditingServerId(null); | |
| setNewServer(INITIAL_NEW_SERVER); | |
| setShowSensitiveEnvValues({}); | |
| setShowSensitiveHeaderValues({}); | |
| }; | |
| // Update functions to control servers | |
| const toggleServerStatus = async (server: MCPServer, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| if (!server.status || server.status === 'disconnected' || server.status === 'error') { | |
| try { | |
| updateServerStatus(server.id, 'connecting'); | |
| const success = await startServer(server.id); | |
| if (success) { | |
| toast.success(`Started server: ${server.name}`); | |
| } else { | |
| toast.error(`Failed to start server: ${server.name}`); | |
| } | |
| } catch (error) { | |
| updateServerStatus(server.id, 'error', | |
| `Error: ${error instanceof Error ? error.message : String(error)}`); | |
| toast.error(`Error starting server: ${error instanceof Error ? error.message : String(error)}`); | |
| } | |
| } else { | |
| try { | |
| const success = await stopServer(server.id); | |
| if (success) { | |
| toast.success(`Stopped server: ${server.name}`); | |
| } else { | |
| toast.error(`Failed to stop server: ${server.name}`); | |
| } | |
| } catch (error) { | |
| toast.error(`Error stopping server: ${error instanceof Error ? error.message : String(error)}`); | |
| } | |
| } | |
| }; | |
| // Update function to restart a server | |
| const restartServer = async (server: MCPServer, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| try { | |
| // First stop it | |
| if (server.status === 'connected' || server.status === 'connecting') { | |
| await stopServer(server.id); | |
| } | |
| // Then start it again (with delay to ensure proper cleanup) | |
| setTimeout(async () => { | |
| updateServerStatus(server.id, 'connecting'); | |
| const success = await startServer(server.id); | |
| if (success) { | |
| toast.success(`Restarted server: ${server.name}`); | |
| } else { | |
| toast.error(`Failed to restart server: ${server.name}`); | |
| } | |
| }, 500); | |
| } catch (error) { | |
| updateServerStatus(server.id, 'error', | |
| `Error: ${error instanceof Error ? error.message : String(error)}`); | |
| toast.error(`Error restarting server: ${error instanceof Error ? error.message : String(error)}`); | |
| } | |
| }; | |
| // UI element to display the correct server URL | |
| const getServerDisplayUrl = (server: MCPServer): string => { | |
| // Always show the configured URL or command, not the sandbox URL | |
| return server.type === 'sse' | |
| ? server.url | |
| : `${server.command} ${server.args?.join(' ')}`; | |
| }; | |
| // Update the hover info function to return richer content | |
| const getServerStatusHoverInfo = (server: MCPServer): string | undefined => { | |
| // For connected stdio servers, show the sandbox URL as hover info | |
| if (server.type === 'stdio' && server.status === 'connected' && server.sandboxUrl) { | |
| return `Running at: ${server.sandboxUrl}`; | |
| } | |
| // For error status, show the error message | |
| if (server.status === 'error' && server.errorMessage) { | |
| return `Error: ${server.errorMessage}`; | |
| } | |
| return undefined; | |
| }; | |
| return ( | |
| <Dialog open={open} onOpenChange={onOpenChange}> | |
| <DialogContent className="sm:max-w-[480px] max-h-[85vh] overflow-hidden flex flex-col"> | |
| <DialogHeader> | |
| <DialogTitle className="flex items-center gap-2"> | |
| <ServerIcon className="h-5 w-5 text-primary" /> | |
| MCP Server Configuration | |
| </DialogTitle> | |
| <DialogDescription> | |
| Connect to Model Context Protocol servers to access additional AI tools. | |
| {selectedServers.length > 0 && ( | |
| <span className="block mt-1 text-xs font-medium text-primary"> | |
| {selectedServers.length} server{selectedServers.length !== 1 ? 's' : ''} currently active | |
| </span> | |
| )} | |
| </DialogDescription> | |
| </DialogHeader> | |
| {view === 'list' ? ( | |
| <div className="flex-1 overflow-hidden flex flex-col"> | |
| {servers.length > 0 ? ( | |
| <div className="flex-1 overflow-hidden flex flex-col"> | |
| <div className="flex-1 overflow-hidden flex flex-col"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="text-sm font-medium">Available Servers</h3> | |
| <span className="text-xs text-muted-foreground"> | |
| Select multiple servers to combine their tools | |
| </span> | |
| </div> | |
| <div className="overflow-y-auto pr-1 flex-1 gap-2.5 flex flex-col pb-16"> | |
| {servers | |
| .sort((a, b) => { | |
| const aActive = selectedServers.includes(a.id); | |
| const bActive = selectedServers.includes(b.id); | |
| if (aActive && !bActive) return -1; | |
| if (!aActive && bActive) return 1; | |
| return 0; | |
| }) | |
| .map((server) => { | |
| const isActive = selectedServers.includes(server.id); | |
| const isRunning = server.status === 'connected' || server.status === 'connecting'; | |
| return ( | |
| <div | |
| key={server.id} | |
| className={` | |
| relative flex flex-col p-3.5 rounded-xl transition-colors | |
| border ${isActive | |
| ? 'border-primary bg-primary/10' | |
| : 'border-border hover:border-primary/30 hover:bg-primary/5'} | |
| `} | |
| > | |
| {/* Server Header with Type Badge and Actions */} | |
| <div className="flex items-center justify-between mb-2"> | |
| <div className="flex items-center gap-2"> | |
| {server.type === 'sse' ? ( | |
| <Globe className={`h-4 w-4 ${isActive ? 'text-primary' : 'text-muted-foreground'} flex-shrink-0`} /> | |
| ) : ( | |
| <Terminal className={`h-4 w-4 ${isActive ? 'text-primary' : 'text-muted-foreground'} flex-shrink-0`} /> | |
| )} | |
| <h4 className="text-sm font-medium truncate max-w-[160px]">{server.name}</h4> | |
| {hasAdvancedConfig(server) && ( | |
| <span className="flex-shrink-0"> | |
| <Cog className="h-3 w-3 text-muted-foreground" /> | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs px-2 py-0.5 rounded-full bg-secondary text-secondary-foreground"> | |
| {server.type === "stdio" ? "STDIO" : server.url?.endsWith("/sse") ? "SSE" : "HTTP"} | |
| </span> | |
| {/* Status indicator */} | |
| <StatusIndicator | |
| status={server.status} | |
| onClick={() => server.errorMessage && toast.error(server.errorMessage)} | |
| hoverInfo={getServerStatusHoverInfo(server)} | |
| /> | |
| {/* Server actions */} | |
| <div className="flex items-center"> | |
| <button | |
| onClick={(e) => toggleServerStatus(server, e)} | |
| className="p-1 rounded-full hover:bg-muted/70" | |
| aria-label={isRunning ? "Stop server" : "Start server"} | |
| title={isRunning ? "Stop server" : "Start server"} | |
| > | |
| <Power className={`h-3.5 w-3.5 ${isRunning ? 'text-red-500' : 'text-green-500'}`} /> | |
| </button> | |
| <button | |
| onClick={(e) => restartServer(server, e)} | |
| className="p-1 rounded-full hover:bg-muted/70" | |
| aria-label="Restart server" | |
| title="Restart server" | |
| disabled={server.status === 'connecting'} | |
| > | |
| <RefreshCw className={`h-3.5 w-3.5 text-muted-foreground ${server.status === 'connecting' ? 'opacity-50' : ''}`} /> | |
| </button> | |
| <button | |
| onClick={(e) => removeServer(server.id, e)} | |
| className="p-1 rounded-full hover:bg-muted/70" | |
| aria-label="Remove server" | |
| title="Remove server" | |
| > | |
| <Trash2 className="h-3.5 w-3.5 text-muted-foreground" /> | |
| </button> | |
| <button | |
| onClick={() => startEditing(server)} | |
| className="p-1 rounded-full hover:bg-muted/50" | |
| aria-label="Edit server" | |
| title="Edit server" | |
| > | |
| <Edit2 className="h-3.5 w-3.5 text-muted-foreground" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Server Details */} | |
| <p className="text-xs text-muted-foreground mb-2.5 truncate"> | |
| {getServerDisplayUrl(server)} | |
| </p> | |
| {/* Action Button */} | |
| <Button | |
| size="sm" | |
| className="w-full gap-1.5 hover:text-black hover:dark:text-white rounded-lg" | |
| variant={isActive ? "default" : "outline"} | |
| onClick={() => toggleServer(server.id)} | |
| > | |
| {isActive && <CheckCircle className="h-3.5 w-3.5" />} | |
| {isActive ? "Active" : "Enable Server"} | |
| </Button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex-1 py-8 pb-16 flex flex-col items-center justify-center space-y-4"> | |
| <div className="rounded-full p-3 bg-primary/10"> | |
| <ServerIcon className="h-7 w-7 text-primary" /> | |
| </div> | |
| <div className="text-center space-y-1"> | |
| <h3 className="text-base font-medium">No MCP Servers Added</h3> | |
| <p className="text-sm text-muted-foreground max-w-[300px]"> | |
| Add your first MCP server to access additional AI tools | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-4"> | |
| <a | |
| href="https://modelcontextprotocol.io" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex items-center gap-1 hover:text-primary transition-colors" | |
| > | |
| Learn about MCP | |
| <ExternalLink className="h-3 w-3" /> | |
| </a> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="space-y-4 overflow-y-auto px-1 py-0.5 mb-14 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"> | |
| <h3 className="text-sm font-medium">{editingServerId ? "Edit MCP Server" : "Add New MCP Server"}</h3> | |
| <div className="space-y-4"> | |
| <div className="grid gap-1.5"> | |
| <Label htmlFor="name"> | |
| Server Name | |
| </Label> | |
| <Input | |
| id="name" | |
| value={newServer.name} | |
| onChange={(e) => setNewServer({ ...newServer, name: e.target.value })} | |
| placeholder="My MCP Server" | |
| className="relative z-0" | |
| /> | |
| </div> | |
| <div className="grid gap-1.5"> | |
| <Label htmlFor="transport-type"> | |
| Transport Type | |
| </Label> | |
| <div className="space-y-2"> | |
| <p className="text-xs text-muted-foreground">Choose how to connect to your MCP server:</p> | |
| <div className="grid gap-2 grid-cols-2"> | |
| <button | |
| type="button" | |
| onClick={() => setNewServer({ ...newServer, type: 'sse' })} | |
| className={`flex items-center gap-2 p-3 rounded-md text-left border transition-all ${ | |
| newServer.type === 'sse' | |
| ? 'border-primary bg-primary/10 ring-1 ring-primary' | |
| : 'border-border hover:border-border/80 hover:bg-muted/50' | |
| }`} | |
| > | |
| <Globe className={`h-5 w-5 shrink-0 ${newServer.type === 'sse' ? 'text-primary' : ''}`} /> | |
| <div> | |
| <p className="font-medium">HTTP / SSE</p> | |
| <p className="text-xs text-muted-foreground">Remote Server</p> | |
| </div> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setNewServer({ ...newServer, type: 'stdio' })} | |
| className={`flex items-center gap-2 p-3 rounded-md text-left border transition-all ${ | |
| newServer.type === 'stdio' | |
| ? 'border-primary bg-primary/10 ring-1 ring-primary' | |
| : 'border-border hover:border-border/80 hover:bg-muted/50' | |
| }`} | |
| > | |
| <Terminal className={`h-5 w-5 shrink-0 ${newServer.type === 'stdio' ? 'text-primary' : ''}`} /> | |
| <div> | |
| <p className="font-medium">stdio</p> | |
| <p className="text-xs text-muted-foreground">Standard I/O</p> | |
| </div> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {newServer.type === 'sse' ? ( | |
| <div className="grid gap-1.5"> | |
| <Label htmlFor="url"> | |
| Server URL | |
| </Label> | |
| <Input | |
| id="url" | |
| value={newServer.url} | |
| onChange={(e) => setNewServer({ ...newServer, url: e.target.value })} | |
| placeholder="https://mcp.example.com/token/mcp" | |
| className="relative z-0" | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| Full URL to the HTTP or SSE endpoint of the MCP server | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="grid gap-1.5"> | |
| <Label htmlFor="command"> | |
| Command | |
| </Label> | |
| <Input | |
| id="command" | |
| value={newServer.command} | |
| onChange={(e) => setNewServer({ ...newServer, command: e.target.value })} | |
| placeholder="node" | |
| className="relative z-0" | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| Executable to run (e.g., node, python) | |
| </p> | |
| </div> | |
| <div className="grid gap-1.5"> | |
| <Label htmlFor="args"> | |
| Arguments | |
| </Label> | |
| <Input | |
| id="args" | |
| value={newServer.args?.join(' ') || ''} | |
| onChange={(e) => handleArgsChange(e.target.value)} | |
| placeholder="src/mcp-server.js --port 3001" | |
| className="relative z-0" | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| Space-separated arguments or JSON array | |
| </p> | |
| </div> | |
| </> | |
| )} | |
| {/* Advanced Configuration */} | |
| <Accordion type="single" collapsible className="w-full"> | |
| <AccordionItem value="env-vars"> | |
| <AccordionTrigger className="text-sm py-2"> | |
| Environment Variables | |
| </AccordionTrigger> | |
| <AccordionContent> | |
| <div className="space-y-3"> | |
| <div className="flex items-end gap-2"> | |
| <div className="flex-1"> | |
| <Label htmlFor="env-key" className="text-xs mb-1 block"> | |
| Key | |
| </Label> | |
| <Input | |
| id="env-key" | |
| value={newEnvVar.key} | |
| onChange={(e) => setNewEnvVar({ ...newEnvVar, key: e.target.value })} | |
| placeholder="API_KEY" | |
| className="h-8 relative z-0" | |
| /> | |
| </div> | |
| <div className="flex-1"> | |
| <Label htmlFor="env-value" className="text-xs mb-1 block"> | |
| Value | |
| </Label> | |
| <Input | |
| id="env-value" | |
| value={newEnvVar.value} | |
| onChange={(e) => setNewEnvVar({ ...newEnvVar, value: e.target.value })} | |
| placeholder="your-secret-key" | |
| className="h-8 relative z-0" | |
| type="text" | |
| /> | |
| </div> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| size="sm" | |
| onClick={addEnvVar} | |
| disabled={!newEnvVar.key} | |
| className="h-8 mt-1" | |
| > | |
| <Plus className="h-3.5 w-3.5" /> | |
| </Button> | |
| </div> | |
| {newServer.env && newServer.env.length > 0 ? ( | |
| <div className="border rounded-md divide-y"> | |
| {newServer.env.map((env, index) => ( | |
| <div key={index} className="flex items-center justify-between p-2 text-sm"> | |
| <div className="flex-1 flex items-center gap-1 truncate"> | |
| <span className="font-mono text-xs">{env.key}</span> | |
| <span className="mx-2 text-muted-foreground">=</span> | |
| {editingEnvIndex === index ? ( | |
| <div className="flex gap-1 flex-1"> | |
| <Input | |
| className="h-6 text-xs py-1 px-2" | |
| value={editedEnvValue} | |
| onChange={(e) => setEditedEnvValue(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && saveEditedEnvValue()} | |
| autoFocus | |
| /> | |
| <Button | |
| size="sm" | |
| className="h-6 px-2" | |
| onClick={saveEditedEnvValue} | |
| > | |
| Save | |
| </Button> | |
| </div> | |
| ) : ( | |
| <> | |
| <span className="text-xs text-muted-foreground truncate"> | |
| {isSensitiveKey(env.key) && !showSensitiveEnvValues[index] | |
| ? maskValue(env.value) | |
| : env.value} | |
| </span> | |
| <span className="flex ml-1 gap-1"> | |
| {isSensitiveKey(env.key) && ( | |
| <button | |
| onClick={() => toggleSensitiveEnvValue(index)} | |
| className="p-1 hover:bg-muted/50 rounded-full" | |
| > | |
| {showSensitiveEnvValues[index] ? ( | |
| <EyeOff className="h-3 w-3 text-muted-foreground" /> | |
| ) : ( | |
| <Eye className="h-3 w-3 text-muted-foreground" /> | |
| )} | |
| </button> | |
| )} | |
| <button | |
| onClick={() => startEditEnvValue(index, env.value)} | |
| className="p-1 hover:bg-muted/50 rounded-full" | |
| > | |
| <Edit2 className="h-3 w-3 text-muted-foreground" /> | |
| </button> | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => removeEnvVar(index)} | |
| className="h-6 w-6 p-0 ml-2" | |
| > | |
| <X className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| ))} | |
| </div> | |
| ) : ( | |
| <p className="text-xs text-muted-foreground text-center py-2"> | |
| No environment variables added | |
| </p> | |
| )} | |
| <p className="text-xs text-muted-foreground"> | |
| Environment variables will be passed to the MCP server process. | |
| </p> | |
| </div> | |
| </AccordionContent> | |
| </AccordionItem> | |
| <AccordionItem value="headers"> | |
| <AccordionTrigger className="text-sm py-2"> | |
| {newServer.type === 'sse' ? 'HTTP Headers' : 'Additional Configuration'} | |
| </AccordionTrigger> | |
| <AccordionContent> | |
| <div className="space-y-3"> | |
| <div className="flex items-end gap-2"> | |
| <div className="flex-1"> | |
| <Label htmlFor="header-key" className="text-xs mb-1 block"> | |
| Key | |
| </Label> | |
| <Input | |
| id="header-key" | |
| value={newHeader.key} | |
| onChange={(e) => setNewHeader({ ...newHeader, key: e.target.value })} | |
| placeholder="Authorization" | |
| className="h-8 relative z-0" | |
| /> | |
| </div> | |
| <div className="flex-1"> | |
| <Label htmlFor="header-value" className="text-xs mb-1 block"> | |
| Value | |
| </Label> | |
| <Input | |
| id="header-value" | |
| value={newHeader.value} | |
| onChange={(e) => setNewHeader({ ...newHeader, value: e.target.value })} | |
| placeholder="Bearer token123" | |
| className="h-8 relative z-0" | |
| /> | |
| </div> | |
| <Button | |
| type="button" | |
| variant="outline" | |
| size="sm" | |
| onClick={addHeader} | |
| disabled={!newHeader.key} | |
| className="h-8 mt-1" | |
| > | |
| <Plus className="h-3.5 w-3.5" /> | |
| </Button> | |
| </div> | |
| {newServer.headers && newServer.headers.length > 0 ? ( | |
| <div className="border rounded-md divide-y"> | |
| {newServer.headers.map((header, index) => ( | |
| <div key={index} className="flex items-center justify-between p-2 text-sm"> | |
| <div className="flex-1 flex items-center gap-1 truncate"> | |
| <span className="font-mono text-xs">{header.key}</span> | |
| <span className="mx-2 text-muted-foreground">:</span> | |
| {editingHeaderIndex === index ? ( | |
| <div className="flex gap-1 flex-1"> | |
| <Input | |
| className="h-6 text-xs py-1 px-2" | |
| value={editedHeaderValue} | |
| onChange={(e) => setEditedHeaderValue(e.target.value)} | |
| onKeyDown={(e) => e.key === 'Enter' && saveEditedHeaderValue()} | |
| autoFocus | |
| /> | |
| <Button | |
| size="sm" | |
| className="h-6 px-2" | |
| onClick={saveEditedHeaderValue} | |
| > | |
| Save | |
| </Button> | |
| </div> | |
| ) : ( | |
| <> | |
| <span className="text-xs text-muted-foreground truncate"> | |
| {isSensitiveKey(header.key) && !showSensitiveHeaderValues[index] | |
| ? maskValue(header.value) | |
| : header.value} | |
| </span> | |
| <span className="flex ml-1 gap-1"> | |
| {isSensitiveKey(header.key) && ( | |
| <button | |
| onClick={() => toggleSensitiveHeaderValue(index)} | |
| className="p-1 hover:bg-muted/50 rounded-full" | |
| > | |
| {showSensitiveHeaderValues[index] ? ( | |
| <EyeOff className="h-3 w-3 text-muted-foreground" /> | |
| ) : ( | |
| <Eye className="h-3 w-3 text-muted-foreground" /> | |
| )} | |
| </button> | |
| )} | |
| <button | |
| onClick={() => startEditHeaderValue(index, header.value)} | |
| className="p-1 hover:bg-muted/50 rounded-full" | |
| > | |
| <Edit2 className="h-3 w-3 text-muted-foreground" /> | |
| </button> | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| <Button | |
| type="button" | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => removeHeader(index)} | |
| className="h-6 w-6 p-0 ml-2" | |
| > | |
| <X className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| ))} | |
| </div> | |
| ) : ( | |
| <p className="text-xs text-muted-foreground text-center py-2"> | |
| No {newServer.type === 'sse' ? 'headers' : 'additional configuration'} added | |
| </p> | |
| )} | |
| <p className="text-xs text-muted-foreground"> | |
| {newServer.type === 'sse' | |
| ? 'HTTP headers will be sent with requests to the HTTP or SSE endpoint.' | |
| : 'Additional configuration parameters for the stdio transport.'} | |
| </p> | |
| </div> | |
| </AccordionContent> | |
| </AccordionItem> | |
| </Accordion> | |
| </div> | |
| </div> | |
| )} | |
| {/* Persistent fixed footer with buttons */} | |
| <div className="absolute bottom-0 left-0 right-0 p-4 bg-background border-t border-border flex justify-between z-10"> | |
| {view === 'list' ? ( | |
| <> | |
| <Button | |
| variant="outline" | |
| onClick={clearAllServers} | |
| size="sm" | |
| className="gap-1.5 hover:text-black hover:dark:text-white" | |
| disabled={selectedServers.length === 0} | |
| > | |
| <X className="h-3.5 w-3.5" /> | |
| Disable All | |
| </Button> | |
| <Button | |
| onClick={() => setView('add')} | |
| size="sm" | |
| className="gap-1.5" | |
| > | |
| <PlusCircle className="h-3.5 w-3.5" /> | |
| Add Server | |
| </Button> | |
| </> | |
| ) : ( | |
| <> | |
| <Button variant="outline" onClick={handleFormCancel}> | |
| Cancel | |
| </Button> | |
| <Button | |
| onClick={editingServerId ? updateServer : addServer} | |
| disabled={ | |
| !newServer.name || | |
| (newServer.type === 'sse' && !newServer.url) || | |
| (newServer.type === 'stdio' && (!newServer.command || !newServer.args?.length)) | |
| } | |
| > | |
| {editingServerId ? "Save Changes" : "Add Server"} | |
| </Button> | |
| </> | |
| )} | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| }; | |