Skip to main content
Glama
AgentDetailsModal.tsx29.9 kB
import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import McpIcon from "./SystemConnectivity/nodes/Mcpx_Icon.svg?react"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import ArrowRightIcon from "@/icons/arrow_line_rigth.svg?react"; import { Separator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, VisuallyHidden, } from "@/components/ui/sheet"; import { SessionIdsTooltip } from "@/components/ui/SessionIdsTooltip"; import { Agent } from "@/types"; import { formatDateTime } from "@/utils"; import { ChevronDown, ListFilter, Search } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { socketStore, useAccessControlsStore, useSocketStore } from "@/store"; import { apiClient } from "@/lib/api"; import type { ConsumerConfig, ConnectedClient } from "@mcpx/shared-model"; import type { ToolGroup } from "@/store/access-controls"; import { toast, useToast } from "@/components/ui/use-toast"; import { getAgentType } from "./helpers"; import { agentsData } from "./constants"; import { useDomainIcon } from "@/hooks/useDomainIcon"; interface AgentDetailsModalProps { agent: Agent | null; isOpen: boolean; onClose: () => void; } export const AgentDetailsModal = ({ agent, isOpen, onClose, }: AgentDetailsModalProps) => { const [searchQuery, setSearchQuery] = useState(""); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [allowAll, setAllowAll] = useState(false); const [originalAllowAll, setOriginalAllowAll] = useState(false); const [originalToolGroups, setOriginalToolGroups] = useState<Set<string>>( new Set(), ); const [hadOriginalProfile, setHadOriginalProfile] = useState(false); const [editedToolGroups, setEditedToolGroups] = useState<Set<string>>( new Set(), ); const navigate = useNavigate(); const { toolGroups, profiles, setProfiles } = useAccessControlsStore((s) => { return { toolGroups: s.toolGroups || [], profiles: s.profiles || [], setProfiles: s.setProfiles, setAppConfigUpdates: s.setAppConfigUpdates, appConfigUpdates: s.appConfigUpdates, hasPendingChanges: s.hasPendingChanges, resetAppConfigUpdates: s.resetAppConfigUpdates, }; }); const { dismiss } = useToast(); const [internalOpen, setInternalOpen] = useState(false); const agentType = getAgentType(agent?.identifier); const currentAgentData = agentsData[agentType ?? "DEFAULT"]; useEffect(() => { // If there are no tool groups, automatically enable "All Server Tools" if (!toolGroups || toolGroups.length === 0) { setAllowAll(true); // Update originalAllowAll if tool groups were deleted while drawer is open // This ensures the save button works correctly when toggling the switch if (originalToolGroups.size > 0) { // Tool groups were deleted, so update original state setOriginalAllowAll(true); setOriginalToolGroups(new Set()); setEditedToolGroups(new Set()); } } else if (editedToolGroups.size === 0) { setAllowAll(editedToolGroups.size === 0); } }, [editedToolGroups.size, toolGroups, originalToolGroups.size]); useEffect(() => { if (isOpen) { dismiss(); // Dismiss all toasts when opening Agent Details modal } setInternalOpen(isOpen); }, [isOpen]); const { systemState } = useSocketStore((s) => ({ systemState: s.systemState, })); const arraysEqual = (arr1: string[], arr2: string[]) => { if (arr1.length !== arr2.length) return false; const sorted1 = [...arr1].sort(); const sorted2 = [...arr2].sort(); return sorted1.every((val, index) => val === sorted2[index]); }; // Check if there are changes to save const hasChanges = useMemo(() => { if (!agent || !toolGroups) return false; let selectedToolGroupIds: string[]; if (allowAll) { selectedToolGroupIds = []; } else { selectedToolGroupIds = Array.from(editedToolGroups); } // Compare with original state instead of current profile const originalToolGroupIds = Array.from(originalToolGroups); // Check if allowAll state changed const allowAllChanged = allowAll !== originalAllowAll; // Check if individual selections changed let hasChangesResult = false; if (!allowAll) { hasChangesResult = !arraysEqual( originalToolGroupIds, selectedToolGroupIds, ); } else { // If allowAll is enabled, check if original had any restrictions // OR if there was originally a profile that needs to be deleted hasChangesResult = originalToolGroupIds.length > 0 || hadOriginalProfile; } // Return true if either allowAll changed or tool group selections changed return allowAllChanged || hasChangesResult; }, [ agent, toolGroups, profiles, allowAll, originalAllowAll, editedToolGroups, originalToolGroups, hadOriginalProfile, ]); const agentToolGroups = useMemo(() => { if (!toolGroups || !profiles || !agent?.identifier) return []; try { const { systemState } = socketStore.getState(); const agentConsumerTags = agent.sessionIds .map((sessionId) => { const session = systemState?.connectedClients?.find( (client: ConnectedClient) => client.sessionId === sessionId, ); return session?.consumerTag; }) .filter(Boolean) as string[]; const agentProfile = profiles.find( (profile) => profile?.name !== "default" && profile?.agents?.some((profileAgent) => agentConsumerTags.includes(profileAgent), ), ); const createToolGroup = (toolGroup: ToolGroup, enabled: boolean) => { const allTools = Object.values( toolGroup.services || {}, ).flat() as string[]; const mcpNames = Object.keys(toolGroup.services || {}); return { id: toolGroup.id, title: toolGroup.name, description: `Tools from ${toolGroup.name}`, enabled, mcpNames: [...new Set(mcpNames)], toolCount: [...new Set(allTools)].length, allTools: [...new Set(allTools)], }; }; const isEnabled = (toolGroup: ToolGroup) => agentProfile?.permission === "allow" && agentProfile?.toolGroups?.includes(toolGroup.id); return toolGroups.map((toolGroup) => createToolGroup(toolGroup, isEnabled(toolGroup)), ); } catch (_error) { return []; } }, [toolGroups, profiles, agent?.identifier]); // Track the last initialization state to detect when we need to re-initialize const lastInitializedAgentRef = useRef<string | null>(null); const lastInitializedProfilesRef = useRef<string>(""); const isInitializingRef = useRef(false); useEffect(() => { if (!agent || !isOpen || isInitializingRef.current) return; const { systemState } = socketStore.getState(); const agentConsumerTags = agent.sessionIds .map((sessionId) => { const session = systemState?.connectedClients?.find( (client: ConnectedClient) => client.sessionId === sessionId, ); return session?.consumerTag; }) .filter(Boolean) as string[]; const agentProfile = profiles?.find( (p) => p?.name !== "default" && p?.agents?.some((profileAgent) => agentConsumerTags.includes(profileAgent), ), ); // Create a hash of the current profile state to detect changes const currentProfileHash = JSON.stringify({ hasProfile: !!agentProfile, toolGroups: agentProfile?.toolGroups || [], permission: agentProfile?.permission, }); // Re-initialize if: // 1. Agent changed, OR // 2. Modal just opened (agent identifier doesn't match or was reset), OR // 3. Profile state changed (profile hash doesn't match) const agentChanged = lastInitializedAgentRef.current !== agent.identifier; const profileStateChanged = lastInitializedProfilesRef.current !== currentProfileHash; const shouldInitialize = agentChanged || profileStateChanged; if (shouldInitialize) { isInitializingRef.current = true; // Determine initial allowAll state: // - If no profile exists: allowAll = true (no restrictions, all tools allowed) // - If profile exists with empty toolGroups: allowAll = false (user explicitly disabled "All Server Tools") // - If profile exists with toolGroups: allowAll = false (has specific tool group restrictions) let initialAllowAll: boolean; if (!agentProfile) { // No profile exists - allow all tools (no restrictions) initialAllowAll = true; } else { // Profile exists - check if it has tool groups const profileToolGroups = agentProfile.toolGroups || []; if (profileToolGroups.length === 0) { // Empty toolGroups array means user explicitly disabled "All Server Tools" // This happens when user toggles from enable to disable with no tool groups initialAllowAll = false; } else { // Has tool groups - not allowing all (has specific restrictions) initialAllowAll = false; } } setAllowAll(initialAllowAll); setOriginalAllowAll(initialAllowAll); setHadOriginalProfile(!!agentProfile); const currentSelections = new Set( toolGroups ?.filter( (toolGroup) => agentProfile?.permission === "allow" && agentProfile?.toolGroups?.includes(toolGroup.id), ) .map((toolGroup) => toolGroup.id) || [], ); setOriginalToolGroups(currentSelections); setEditedToolGroups(currentSelections); // Track what we initialized for lastInitializedAgentRef.current = agent.identifier; lastInitializedProfilesRef.current = currentProfileHash; isInitializingRef.current = false; } }, [agent, isOpen, toolGroups, profiles]); // Reset the refs when drawer closes so state reloads on next open // This ensures that when the modal reopens, it will always re-initialize with fresh data useEffect(() => { if (!isOpen) { lastInitializedAgentRef.current = null; lastInitializedProfilesRef.current = ""; isInitializingRef.current = false; } }, [isOpen]); // Get consumerTag from x-lunar-consumer-tag header const consumerTag = useMemo(() => { if (!agent?.sessionIds || agent.sessionIds.length === 0) { return null; } const session = systemState?.connectedClients?.find( (client) => client.sessionId === agent.sessionIds[0], ); return session?.consumerTag || null; }, [agent?.sessionIds, systemState]); const filteredGroups = agentToolGroups.filter( (group) => group && group.title && (group.title.toLowerCase().includes(searchQuery.toLowerCase()) || group.mcpNames.some((mcpName) => mcpName.toLowerCase().includes(searchQuery.toLowerCase()), )), ); // Calculate total connected tools from all servers (same as dashboard top left card) const totalConnectedTools = useMemo(() => { if (!systemState?.targetServers_new) return 0; return systemState.targetServers_new.reduce( (total, server) => total + (server.tools?.length || 0), 0, ); }, [systemState?.targetServers_new]); const goToToolCatalog = () => { navigate("/tools"); onClose(); }; const toggleGroupExpansion = (groupId: string) => { setExpandedGroups((prev) => { const newSet = new Set(prev); if (newSet.has(groupId)) { newSet.delete(groupId); } else { newSet.add(groupId); } return newSet; }); }; const handleAllowAllToggle = (checked: boolean) => { setAllowAll(checked); // When "Allow All" is enabled, clear individual selections if (checked) { setEditedToolGroups(new Set()); } // When "Allow All" is disabled, keep current selections // This allows user to work with individual tool groups without losing their selections }; const handleToolGroupToggle = (groupId: string, checked: boolean) => { // Turn off "Allow All" when individual groups are toggled setAllowAll(false); setEditedToolGroups((prev) => { const newSet = new Set(prev); if (checked) { newSet.add(groupId); } else { newSet.delete(groupId); } return newSet; }); }; const saveConfiguration = useCallback(async () => { if (!agent?.identifier) { toast({ title: "Error", description: "Agent identifier is missing", variant: "destructive", }); return; } try { const { systemState } = socketStore.getState(); const agentConsumerTags = agent.sessionIds .map((sessionId) => { const session = systemState?.connectedClients?.find( (client) => client.sessionId === sessionId, ); return session?.consumerTag; }) .filter(Boolean) as string[]; // Use consumer tags if available, otherwise fall back to agent identifier const consumersToProcess: string[] = agentConsumerTags.length > 0 ? agentConsumerTags : agent.identifier ? [agent.identifier] : []; // Get current consumers from API const currentConsumers: Record<string, ConsumerConfig> = await apiClient .getPermissionConsumers() .catch(() => ({}) as Record<string, ConsumerConfig>); // Determine selected tool group IDs const selectedToolGroupIds = allowAll ? [] : Array.from(editedToolGroups); // Convert tool group IDs to names for ConsumerConfig const selectedToolGroupNames = selectedToolGroupIds .map((id) => toolGroups.find((g) => g.id === id)?.name) .filter(Boolean) as string[]; // Build ConsumerConfig based on state let newConsumerConfig: ConsumerConfig | null = null; if (!allowAll) { // If allowAll is false, create a profile with restrictions if (selectedToolGroupNames.length > 0) { // Has specific tool groups - default-block with allow list newConsumerConfig = { _type: "default-block", consumerGroupKey: `${agent.identifier} Profile`, allow: selectedToolGroupNames, }; } else { // No tool groups selected but allowAll is false - block all newConsumerConfig = { _type: "default-block", consumerGroupKey: `${agent.identifier} Profile`, allow: [], }; } } // If allowAll is true, newConsumerConfig remains null (will delete if exists) // Process each consumer tag (or agent identifier if no tags) const operations: Array<{ type: "create" | "update" | "delete"; name: string; config?: ConsumerConfig; }> = []; for (const consumerName of consumersToProcess) { const currentConsumer = currentConsumers[consumerName]; const shouldHaveConsumer = newConsumerConfig !== null; if (!currentConsumer && shouldHaveConsumer) { // Create new consumer operations.push({ type: "create", name: consumerName, config: newConsumerConfig!, }); } else if (currentConsumer && shouldHaveConsumer) { // Update existing consumer operations.push({ type: "update", name: consumerName, config: newConsumerConfig!, }); } else if (currentConsumer && !shouldHaveConsumer) { // Delete existing consumer (allowAll is true, should fall back to default) operations.push({ type: "delete", name: consumerName, }); } // If !currentConsumer && !shouldHaveConsumer, no action needed } // Execute all operations await Promise.all( operations.map(async (op) => { if (op.type === "create" && op.config) { await apiClient.createPermissionConsumer({ name: op.name, config: op.config, }); } else if (op.type === "update" && op.config) { await apiClient.updatePermissionConsumer(op.name, op.config); } else if (op.type === "delete") { await apiClient.deletePermissionConsumer(op.name); } }), ); // Update local profile state for UI consistency const currentProfiles = profiles || []; const agentProfile = currentProfiles.find( (profile) => profile && profile.name !== "default" && profile.agents && profile.agents.some((profileAgent) => consumersToProcess.includes(profileAgent), ), ); if (agentProfile) { if (allowAll) { // Delete profile if allowAll is enabled setProfiles( (prev) => prev.filter((p) => p.id !== agentProfile.id), true, ); } else { // Update profile with new tool groups setProfiles( (prev) => prev.map((p) => p.id === agentProfile.id ? { ...p, toolGroups: selectedToolGroupIds, permission: "allow" as const, } : p, ), true, ); } } else if (!allowAll) { // Create new profile if it doesn't exist and allowAll is false const newProfile = { id: `profile_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, name: `${agent.identifier} Profile`, agents: consumersToProcess, permission: "allow" as const, toolGroups: selectedToolGroupIds, }; setProfiles((prev) => [...prev, newProfile], true); } toast({ title: "AI Agent Edited", description: ( <> <strong> {currentAgentData.name.charAt(0).toUpperCase() + currentAgentData.name.slice(1)} </strong>{" "} agent profile was updated successfully </> ), }); // Close drawer after a brief delay to allow toast to be visible setTimeout(() => { onClose(); }, 500); } catch (error) { console.error("Error saving to backend:", error); toast({ title: "Error", description: error instanceof Error ? error.message : "Failed to save agent permissions", variant: "destructive", }); } }, [ agent, profiles, toolGroups, setProfiles, allowAll, editedToolGroups, onClose, currentAgentData.name, ]); const handleClose = () => { dismiss(); // Dismiss all toasts when closing Agent Details modal setInternalOpen(false); setTimeout(() => onClose(), 300); // Allow animation to complete }; if (!isOpen || !agent) return null; if (!agent.sessionIds || agent.sessionIds.length === 0) { return null; } return ( <Sheet open={internalOpen} onOpenChange={(open: boolean) => !open && handleClose()} > <SheetContent side="right" className="!w-[600px] gap-0 !max-w-[600px] bg-white p-0 flex flex-col [&>button]:hidden" > <SheetHeader className="px-6 pt-2 pb-4 flex flex-row justify-between items-center border-b gap-2"> <VisuallyHidden> <SheetTitle> {consumerTag || currentAgentData.name || "AI Agent"} Details </SheetTitle> <SheetDescription> Configure tool access permissions for{" "} {consumerTag || currentAgentData.name || "AI Agent"} </SheetDescription> </VisuallyHidden> <div></div> <div className="flex mt-0 gap-1.5 items-center text-[#7F7999]"> <Button variant="ghost" size="icon" className="w-4 h-4" onClick={handleClose} > <ArrowRightIcon /> </Button> </div> </SheetHeader> <div className="px-6 py-2 flex flex-col overflow-y-auto"> <div className="flex items-center gap-2 text-lg font-semibold mt-2 mb-1"> <img src={currentAgentData.icon} alt={`${currentAgentData.name} Agent Avatar`} className="w-12 h-12 rounded-md" /> <div className="flex flex-col items-start "> <p className="text-2xl font-medium capitalize"> {consumerTag || currentAgentData.name || "AI Agent"} </p> <p className="text-xs bg-[#F0EEF5] px-1 rounded text-[#7F7999]"> {consumerTag || "AI Agent"} </p> </div> </div> <SessionIdsTooltip sessionIds={agent.sessionIds} className="text-[#7F7999] font-medium text-sm" /> </div> <div className="px-6"> <div className="grid grid-cols-3 gap-6 text-sm w-full"> <div className="text-left border rounded-lg p-4"> <div className="text-gray-600 font-medium mb-1">Status</div> <Badge className="bg-green-100 text-green-800 border-green-200"> {agent.status || "CONNECTED"} </Badge> </div> <div className="text-left border border-gray-200 rounded-lg p-4"> <div className="text-gray-600 font-medium mb-1">Calls</div> <div className="">{agent.usage?.callCount || 0}</div> </div> <div className="text-left border border-gray-200 rounded-lg p-4"> <div className="text-gray-600 font-medium mb-1">Last Call</div> <div className=""> {agent.usage?.lastCalledAt ? formatDateTime(agent.usage.lastCalledAt) : "N/A"} </div> </div> </div> </div> {/* Tool Catalog Section */} <div className="px-6 flex-1 flex flex-col overflow-hidden"> <Separator className="my-4" /> <div className="text-lg font-semibold mb-2">Tools Access</div> <div className="flex items-center border rounded-lg p-4 justify-between mb-4 flex-shrink-0"> <h3 className="text-sm font-semibold "> All Server Tools ({totalConnectedTools}) </h3> <div className="flex items-center gap-2"> <Switch checked={allowAll} onCheckedChange={handleAllowAllToggle} /> </div> </div> {/* Search */} {/* Tool Groups List */} <div className="space-y-3 overflow-y-auto pb-6 mb-4 border rounded-lg p-4"> <div className="text-lg font-bold mb-2">Tools </div> <div className="flex gap-4 items-center"> <div className="relative flex-1 flex-shrink-0"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" /> <Input placeholder="Search" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-10 border! bg-[#FBFBFF]" /> </div> <div className="flex items-center text-[#7F7999] gap-2"> <ListFilter className="w-4 h-4" /> Filter </div> </div> {filteredGroups.length === 0 ? ( <div className="text-center py-8 bg-gray-50 rounded-lg border border-gray-200"> <h4 className="font-semibold mb-2">No Tool Groups Defined</h4> <p className="text-gray-600 mb-4"> Create a Tool Group for effective agent control. </p> <p className="text-gray-500 text-sm mb-4"> Go to the Tool Catalog area to set this up. </p> <Button onClick={goToToolCatalog}> Go to Tool Catalog &gt; </Button> </div> ) : ( filteredGroups.map((group) => ( <Card key={group.id} className="border bg-white"> <CardHeader className="p-3 pb-2"> <div className="flex justify-between"> <div className="flex-1"> <CardTitle className="text-sm font-semibold "> {group.title} </CardTitle> <p className="text-[10px] font-regular mt-1"> {group.description} </p> </div> <div className="flex h-full gap-2"> <div className="flex cursor-pointer items-center font-normal text-[10px]" onClick={() => toggleGroupExpansion(group.id)} > {expandedGroups.has(group.id) ? ( <> <span> View Less </span> <ChevronDown className="w-3 h-3 ml-1 rotate-180" /> </> ) : ( <> <span> View More </span> <ChevronDown className="w-3 h-3 ml-1" /> </> )} </div> <Switch checked={!allowAll && editedToolGroups.has(group.id)} onCheckedChange={(checked: boolean) => { handleToolGroupToggle(group.id, checked); }} /> </div> </div> </CardHeader> <CardContent className="p-3 pt-0"> {/* MCPs and Tool Count */} <div className="flex items-center gap-2 mb-2 flex-wrap"> {group.mcpNames.map((mcpName, index) => ( <DomainBadge key={index} domain={mcpName} groupId={group.id} /> ))} <span className="text-xs text-[#7F7999]"> {group.toolCount} tools </span> </div> {/* Expanded Tools View */} {expandedGroups.has(group.id) && ( <div className="max-h-64 overflow-y-auto rounded-md p-3"> <div className="flex items-center mb-2 flex-wrap gap-2"> {group.allTools.map((tool, index) => ( <Badge key={index} variant="outline" className="text-xs bg-gray-100 rounded-none" > {tool} </Badge> ))} </div> </div> )} {/* View More/Less Button */} </CardContent> </Card> )) )} </div> </div> {/* Footer */} <div className="p-4 border-t border-gray-200 bg-white flex-shrink-0"> <div className="flex gap-3 justify-end"> <Button className=" disabled:bg-gray-400 disabled:cursor-not-allowed" onClick={saveConfiguration} disabled={!hasChanges} > Save </Button> </div> </div> </SheetContent> </Sheet> ); }; export const DomainBadge = ({ domain, groupId, }: { domain: string; groupId: string; }) => { const { systemState } = useSocketStore((s) => ({ systemState: s.systemState, appConfig: s.appConfig, })); const { toolGroups } = useAccessControlsStore((s) => { return { toolGroups: s.toolGroups || [], }; }); const toolGroup = toolGroups.find((group) => group.id === groupId); const server = systemState?.targetServers_new?.find( (server) => server.name === domain, ); const domainIconUrl = useDomainIcon(domain); const toolsNumber = toolGroup?.services[domain]?.length; return ( <Badge variant="outline" className="text-sm flex gap-1 items-center bg-white px-2 py-1 border" > {domainIconUrl ? ( <img src={domainIconUrl} alt="Domain Icon" className="w-4 h-4" /> ) : ( <McpIcon style={{ color: server?.icon }} className="w-4 h-4" /> )} <span className="text-[10px] capitalize font-normal text-foreground "> {domain} </span> <span className="text-[10px] bg-[#F9F8FB] rounded-full w-[16px] h-[16px] flex items-center justify-center font-normal text-[#7F7999]"> {toolsNumber} </span> </Badge> ); };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/TheLunarCompany/lunar'

If you have feedback or need assistance with the MCP directory API, please join our Discord server