Skip to main content
Glama
CustomToolDialog.tsx17.6 kB
import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; import { JsonSchemaType } from "@/utils/jsonUtils"; import { ExtensionDescription } from "@mcpx/shared-model"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; // Flexible provider type that matches both TargetServerNew and ExtendedProvider type ProviderLike = { name: string; originalTools: Array<{ name: string; description?: string | { text: string; action: "append" | "rewrite" }; inputSchema?: Tool["inputSchema"]; }>; tools?: unknown[]; icon?: string; }; import { useEffect, useMemo, useState } from "react"; import McpIcon from "../dashboard/SystemConnectivity/nodes/Mcpx_Icon.svg?react"; import HierarchyBadge from "@/components/HierarchyBadge"; import { useDomainIcon } from "@/hooks/useDomainIcon"; interface CustomToolDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; providers: ProviderLike[]; onClose: () => void; onCreate: (toolData: { server: string; tool: string; name: string; originalName?: string; description: string; parameters: Array<{ name: string; description: string; value: string }>; }) => void; preSelectedServer?: string; preSelectedTool?: string; preFilledData?: { name: string; description: string | ExtensionDescription; parameters: Array<{ name: string; description: string; value: string }>; }; editDialogMode?: "edit" | "duplicate" | "customize"; isLoading?: boolean; } export function CustomToolDialog({ isOpen, onOpenChange, providers, onClose, onCreate, preSelectedServer, preSelectedTool, preFilledData, editDialogMode, isLoading = false, }: CustomToolDialogProps) { const [selectedServer, setSelectedServer] = useState(""); const [selectedTool, setSelectedTool] = useState(""); const [toolName, setToolName] = useState(""); const [toolDescription, setToolDescription] = useState(""); const [toolParameters, setToolParameters] = useState< Array<{ name: string; description: string; value: string; type?: string }> >([]); const [, setParameterActions] = useState< Record<number, "rewrite" | "append"> >({}); const [, setToolDescriptionAction] = useState<"rewrite" | "append">( "rewrite", ); const [, setNameError] = useState<string>(""); const [, setShowRenameWarning] = useState(false); const [nameErrorInline, setNameErrorInline] = useState<string | null>(null); const [nameTouched, setNameTouched] = useState(false); const [originalName, setOriginalName] = useState<string | undefined>( undefined, ); const isCustomTool = editDialogMode === "edit"; useEffect(() => { if (isOpen) { setNameError(""); setShowRenameWarning(false); if (preSelectedServer && preSelectedTool) { // Prefill for both edit (custom tool) and customize (origin tool) setSelectedServer(preSelectedServer); setSelectedTool(preSelectedTool); if (preFilledData) { // Editing existing custom tool setToolName(preFilledData.name); setNameErrorInline(null); setNameTouched(false); // Handle description that can be either string or { text, action } object const descriptionText = typeof preFilledData.description === "string" ? preFilledData.description : preFilledData.description?.text || ""; setToolDescription(descriptionText); setToolParameters(preFilledData.parameters); setOriginalName(preFilledData.name); // Store original name for editing } else { // Customizing an original tool → create a new custom tool const provider = providers.find((p) => p.name === preSelectedServer); const tool = provider?.originalTools.find( (t) => t.name === preSelectedTool, ); const autoName = `Custom_${preSelectedTool}`; setToolName(autoName); const validation = validateToolNameInline(autoName); setNameErrorInline( validation.isValid ? null : validation.error || "Invalid tool name", ); setNameTouched(false); const desc = tool?.description; setToolDescription( typeof desc === "string" ? desc : desc?.text || "", ); const parameters: Array<{ name: string; description: string; value: string; type?: string; }> = []; if (tool?.inputSchema?.properties) { Object.entries(tool.inputSchema.properties).forEach( ([paramName, paramSchema]) => { const schema = paramSchema as JsonSchemaType; parameters.push({ name: paramName, description: schema.description || "", value: String(schema.default ?? ""), type: schema.type, }); }, ); } setToolParameters(parameters); } } else { // Fresh create without pre-selected context setSelectedServer(""); setSelectedTool(""); setToolName(""); setNameErrorInline(null); setNameTouched(false); setToolDescription(""); setToolParameters([]); setOriginalName(undefined); } setParameterActions({}); setToolDescriptionAction("rewrite"); } // eslint-disable-next-line react-hooks/exhaustive-deps -- providers and validateToolNameInline are stable, only run when dialog opens }, [isOpen, preSelectedServer, preSelectedTool, preFilledData]); const handleParameterChange = (index: number, value: string) => { const newParams = [...toolParameters]; newParams[index].value = value; setToolParameters(newParams); }; const handleParameterDescriptionChange = ( index: number, description: string, ) => { const newParams = [...toolParameters]; newParams[index].description = description; setToolParameters(newParams); }; // Inline editing functions for name const validateToolNameInline = ( name: string, ): { isValid: boolean; error?: string } => { const trimmedName = name.trim(); if (!trimmedName) { return { isValid: false, error: "Tool name is required" }; } const allowed = /^[A-Za-z0-9_-]+$/; if (!allowed.test(trimmedName)) { return { isValid: false, error: "Only letters, digits, dash (-) and underscore (_) are allowed", }; } // Check for duplicate names if (selectedServer) { const server = providers.find((p) => p.name === selectedServer); if (server) { const originalToolExists = server.originalTools.some( (tool: { name: string }) => tool.name.trim() === trimmedName, ); if ( originalToolExists && (!isCustomTool || preFilledData?.name !== trimmedName) ) { return { isValid: false, error: `This name already exists. please enter a different name`, }; } const customToolExists = server.tools?.some((tool) => { const t = tool as { name?: string }; return ( t.name === trimmedName && t.name?.toLowerCase() !== (originalName || "").toLowerCase() ); }); if (customToolExists) { return { isValid: false, error: `This name already exists. please enter a different name`, }; } } } return { isValid: true }; }; const handleCreate = () => { const validation = validateToolNameInline(toolName); if (!validation.isValid) { setNameErrorInline(validation.error!); setNameTouched(true); return; } onCreate({ server: selectedServer, tool: selectedTool, name: toolName, // If tool is custom (originalName exists), we edit the existing one (use original name) // If tool is origin (originalName is undefined), we create a new custom tool originalName: originalName, description: toolDescription, parameters: toolParameters, }); }; const providerName = useMemo( () => selectedServer || preSelectedServer || "", [selectedServer, preSelectedServer], ); const originToolName = useMemo( () => selectedTool || preSelectedTool || "", [selectedTool, preSelectedTool], ); const providerIcon = useDomainIcon(providerName); const capitalizedProviderName = useMemo(() => { if (!providerName) return ""; return providerName.charAt(0).toUpperCase() + providerName.slice(1); }, [providerName]); const providerIconColor = useMemo(() => { const provider = providers.find((p) => p.name === providerName); return provider?.icon; }, [providerName, providers]); return ( <Dialog onOpenChange={onOpenChange} open={isOpen}> <DialogContent className="max-w-4xl rounded-lg p-0 overflow-hidden gap-0 [&>button]:hidden bg-white"> <DialogTitle className="sr-only">Customize Tool</DialogTitle> <div className="px-6 py-6 border-b border-gray-200 bg-white"> <div className="flex items-center justify-between bg-white"> <div className="flex items-center gap-3 bg-white"> <div className="flex flex-col"> <h2 className="text-2xl font-semibold ">Customize Tool</h2> </div> </div> <DialogClose asChild> <button onClick={onClose} className="text-2xl leading-none text-gray-500 hover:text-gray-700 px-2 py-1" aria-label="Close" > × </button> </DialogClose> </div> </div> {isLoading && ( <div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center"> <div className="flex flex-col items-center gap-3"> <Spinner size="large" /> <span className="text-sm text-gray-600"> Saving custom tool... </span> </div> </div> )} {/* Header */} <div className=" border-b border-gray-200 relative bg-white"> <div className="mx-6 py-4 bg-white border-b border-gray-200 flex flex-row items-center justify-between"> <div className="flex items-center gap-3 "> {providerIcon ? ( <img src={providerIcon} alt={`${providerName} icon`} className="h-12 w-12 rounded-full object-contain bg-white" /> ) : ( <McpIcon style={{ color: providerIconColor || "#4F33CC" }} className="w-12 h-12" /> )} <div className="flex flex-col"> <h3 className="text-2xl font-semibold "> {capitalizedProviderName} </h3> {isCustomTool ? ( <HierarchyBadge serverName={originToolName || ""} toolName={toolName || ""} /> ) : ( <span className="text-sm ">{selectedTool}</span> )} </div> </div> </div> <DialogDescription className="sr-only"></DialogDescription> <div className="px-6 bg-white"> {/* Custom Tool Name */} <div className="mt-4"> <h3 className="text-base font-medium text-gray-800 mb-1"> Custom tool name </h3> <Input value={toolName} onChange={(e) => { const value = e.target.value; setToolName(value); const result = validateToolNameInline(value); setNameErrorInline( result.isValid ? null : result.error || "Invalid tool name", ); }} onBlur={() => setNameTouched(true)} placeholder="Enter custom tool name" className={`w-full border-gray-200 focus-visible:ring-[#4F33CC] ${nameTouched && nameErrorInline ? "border-red-500" : ""}`} /> {nameTouched && nameErrorInline && ( <div className="flex pt-1 items-end gap-1"> <img alt="Warning" className="w-4 h-4" src="/icons/warningCircle.png" /> <p className=" text-xs text-[var(--color-fg-danger)]"> {nameErrorInline} </p> </div> )} </div> {/* Description Section */} <div className="mt-4"> <h3 className="text-base font-medium text-gray-800 mb-1"> Description </h3> <Input value={toolDescription} onChange={(e) => setToolDescription(e.target.value)} placeholder="Enter tool description..." className="w-full border-gray-200 focus-visible:ring-[#4F33CC]" /> </div> {/* Properties Section */} <div className="pb-6 "> <h3 className="text-base font-medium my-4">Parameters</h3> <div className="space-y-4 max-h-80 overflow-y-auto rounded-lg pr-2"> {toolParameters.length > 0 ? ( toolParameters.map((param, index) => ( <div key={index} className="border bg-[#F9F8FB] pb-4 border-gray-200 rounded-lg" > <div className="flex items-center justify-between px-4 py-3"> <div className="text-base font-semibold text-[#1D1B4B]"> {param.name} </div> <span className="text-[11px] rounded-sm font-medium text-[#1D1B4B] bg-[#E5E3EF] px-1 py-1"> {param.type || "string"} </span> </div> <div className="relative px-4 pb-4 space-y-3"> <div className={param.type ? "pt-8" : "pt-0"}> <label className="block text-xs font-medium mb-2"> Value </label> <Input value={param.value} onChange={(e) => handleParameterChange(index, e.target.value) } placeholder="Enter value" className="w-full border-gray-200 focus-visible:ring-[#4F33CC]" /> </div> <div> <label className="block text-xs font-medium mb-2"> Description </label> <Input value={param.description} onChange={(e) => handleParameterDescriptionChange( index, e.target.value, ) } placeholder="Enter parameter description" className="w-full border-gray-200 focus-visible:ring-[#4F33CC]" /> </div> </div> </div> )) ) : ( <div className="text-sm text-gray-500 italic"> No parameters found for this tool. </div> )} </div> </div> </div> </div> {/* Dialog Footer */} <div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-white flex flex-row items-center justify-between"> {/* <Button onClick={onClose} className="text-[#5147E4] bg-white px-4 py-2 rounded-lg font-medium transition-colors text-sm "> Cancel </Button> */} <div onClick={onClose} className="text-[#5147E4] px-4 py-2 rounded-lg font-medium text-sm cursor-pointer " > Cancel </div> <Button onClick={handleCreate} disabled={isLoading} className="text-white px-6 py-2 disabled:opacity-50 disabled:cursor-not-allowed" > {isLoading ? ( <div className="flex items-center gap-2"> <Spinner size="small" className="text-white" /> <span>Saving...</span> </div> ) : preSelectedServer && preSelectedTool && preFilledData ? ( "Save" ) : ( "Create" )} </Button> </div> </DialogContent> </Dialog> ); }

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