Skip to main content
Glama
AddServerModal.tsx21.1 kB
import { getMcpColorByName } from "@/components/dashboard/constants"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; import { useToast } from "@/components/ui/use-toast"; import { useAddMcpServer } from "@/data/mcp-server"; import { useGetMCPServers } from "@/data/catalog-servers"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { CatalogMCPServerItem } from "@mcpx/shared-model"; import { useSocketStore } from "@/store"; import { handleMultipleServers, validateAndProcessServer, validateServerCommand, validateServerName, } from "@/utils/server-helpers"; import { mcpJsonSchema, serverNameSchema } from "@/utils/mcpJson"; import { AxiosError } from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod/v4"; import { McpJsonForm } from "./McpJsonForm"; import { CustomTabs, CustomTabsContent, CustomTabsList, CustomTabsTrigger, } from "@/components/ui/custom-tabs"; import { JsonUpload } from "@/components/ui/json-upload"; import { Separator } from "@/components/ui/separator"; import { editor } from "monaco-editor"; import { Input } from "../ui/input"; import { ServerCard } from "./ServerCard"; import { getIconKey } from "@/hooks/useDomainIcon"; type ServerCatalogStatus = | "connected" | "inactive" | "pending-auth" | "connection-failed"; const DEFAULT_SERVER_NAME = "my-server"; const DEFAULT_SERVER_COMMAND = "my-command"; const DEFAULT_SERVER_ARGS = "--arg-key arg-value"; const DEFAULT_ENVIRONMENT_VARIABLES = { MY_ENV_VAR: "my-env-value", } as const; const DEFAULT_SERVER_CONFIGURATION_JSON = JSON.stringify( { [DEFAULT_SERVER_NAME]: { command: DEFAULT_SERVER_COMMAND, args: DEFAULT_SERVER_ARGS.split(" "), env: DEFAULT_ENVIRONMENT_VARIABLES, }, }, null, 2, ); /** * Detects and extracts nested server configurations using heuristics. * Handles formats like: * - { "mcpServers": { "server1": {}, "server2": {} } } * - { "servers": { "server1": {}, "server2": {} } } * - { "my-servers": { "server1": {}, "server2": {} } } (heuristic: single top-level key) * * Note: The single-key heuristic is just that - a heuristic. It's valid to have * multiple top-level keys like { "mcpServers": {...}, "otherConfig": {...} } */ const extractServerConfig = ( parsed: Record<string, unknown>, ): Record<string, unknown> => { if (typeof parsed !== "object" || parsed === null) { return parsed; } // First, check for known wrapper keys (not heuristic) if ( "mcpServers" in parsed && typeof parsed.mcpServers === "object" && parsed.mcpServers !== null ) { return parsed.mcpServers as Record<string, unknown>; } if ( "servers" in parsed && typeof parsed.servers === "object" && parsed.servers !== null ) { return parsed.servers as Record<string, unknown>; } // Heuristic: if there's a single top-level key with an object value that contains server definitions const keys = Object.keys(parsed); if (keys.length === 1) { const topLevelKey = keys[0]; const topLevelValue = parsed[topLevelKey]; // If the value is an object with server-like keys, extract it if (typeof topLevelValue === "object" && topLevelValue !== null) { const nestedKeys = Object.keys(topLevelValue); // Check if nested keys look like server names (at least one valid server name) const hasServerLikeKeys = nestedKeys.some((key) => { const result = serverNameSchema.safeParse(key); return result.success; }); if (hasServerLikeKeys) { return topLevelValue as Record<string, unknown>; } } } // Return original if no nested format detected return parsed; }; type TabValue = "all" | "custom" | "migrate"; const TABS = { ALL: "all" as const, CUSTOM: "custom" as const, MIGRATE: "migrate" as const, } as const; export const AddServerModal = ({ onClose }: { onClose: () => void }) => { const systemState = useSocketStore((s) => s.systemState); const { appConfig } = useSocketStore((s) => ({ appConfig: s.appConfig, })); const { mutate: addServer, isPending, error } = useAddMcpServer(); const { data: serversFromCatalogData } = useGetMCPServers(); const serversFromCatalog = serversFromCatalogData ?? []; const [name, setName] = useState(DEFAULT_SERVER_NAME); const [search, setSearch] = useState(""); const [errorMessage, setErrorMessage] = useState(""); const [customJsonContent, setCustomJsonContent] = useState( DEFAULT_SERVER_CONFIGURATION_JSON, ); const [migrateJsonContent, setMigrateJsonContent] = useState(""); const [hasUploadedFile, setHasUploadedFile] = useState(false); const [activeTab, setActiveTab] = useState<TabValue>(TABS.ALL); const colorScheme = useColorScheme(); // Tab-aware isDirty calculation - only checks the current tab's content const isDirty = useMemo(() => { if (activeTab === TABS.CUSTOM) { return ( customJsonContent.replaceAll(/\s/g, "").trim() !== DEFAULT_SERVER_CONFIGURATION_JSON.replaceAll(/\s/g, "").trim() ); } if (activeTab === TABS.MIGRATE) { return migrateJsonContent.trim() !== "" || hasUploadedFile; } return false; }, [activeTab, customJsonContent, migrateJsonContent, hasUploadedFile]); const [isValid, setIsValid] = useState(true); const { toast } = useToast(); const toastRef = useRef(toast); toastRef.current = toast; const showError = (message: string) => { setErrorMessage(message); }; useEffect(() => { if (!error) return; const message = error instanceof AxiosError && error.response?.data?.msg ? error.response.data.msg : "Failed to add server. Please try again."; showError(message); }, [error]); function getServerStatus(name: string): ServerCatalogStatus | undefined { const server = systemState?.targetServers_new.find( (s) => s.name.toLowerCase() === name.toLowerCase(), ); if (!server) { return undefined; } // Check if server is inactive from appConfig const serverAttributes = ( appConfig as { targetServerAttributes?: Record<string, { inactive: boolean }>; } | null )?.targetServerAttributes?.[server.name]; if (serverAttributes?.inactive === true) { return "inactive"; } return server.state.type; } const handleAddServer = (_name: string, jsonContent: string) => { let parsedJson; try { parsedJson = JSON.parse(jsonContent); } catch (_e) { showError("Invalid JSON format"); return; } const serversObject = parsedJson.mcpServers || parsedJson; const serverNames = Object.keys(serversObject); if (serverNames.length > 1) { handleMultipleServersUpload(serversObject, serverNames); return; } const actualServerName = serverNames[0]; const singleServerJson = parsedJson.mcpServers ? JSON.stringify( { [actualServerName]: serversObject[actualServerName] }, null, 2, ) : jsonContent; const result = validateAndProcessServer({ jsonContent: singleServerJson, icon: getIconKey(actualServerName) ? undefined : getMcpColorByName(actualServerName), existingServers: systemState?.targetServers_new || [], isEdit: false, }); if (result.success === false || !result.payload) { showError(result.error || "Failed to add server. Please try again."); return; } const nameError = validateServerName(actualServerName); if (nameError) { showError(nameError); return; } const commandError = validateServerCommand(result.payload); if (commandError) { showError(commandError); return; } // Update the JSON content to include the type if (result.updatedJsonContent) { setCustomJsonContent(result.updatedJsonContent); } addServer( { payload: result.payload, }, { onSuccess: (server: { name: string }) => { toast({ description: ( <> Server{" "} <strong> {server.name.charAt(0).toUpperCase() + server.name.slice(1)} </strong>{" "} was added successfully. </> ), title: "Server Added", duration: 4000, isClosable: true, variant: "server-info", position: "bottom-left", domain: server.name, // Pass server name as domain for icon }); // Reset form state before closing resetFormState(); // Close the modal - the system state will be updated via socket onClose(); }, onError: (error) => { console.warn("Error adding server:", error); }, }, ); }; const handleMultipleServersUpload = async ( serversObject: Record<string, unknown>, serverNames: string[], ) => { const result = await handleMultipleServers({ serversObject, serverNames, existingServers: systemState?.targetServers_new || [], getIcon: (serverName) => getIconKey(serverName) ? undefined : getMcpColorByName(serverName), addServer, }); const { successfulServers, failedServers } = result; // Show summary toast if (successfulServers.length > 0) { toast({ description: ( <> Successfully added <strong>{successfulServers.length}</strong>{" "} server{successfulServers.length > 1 ? "s" : ""}. {failedServers.length > 0 && ` Failed to add: ${failedServers.join(", ")}`} </> ), title: failedServers.length > 0 ? "Servers Added (with errors)" : "Servers Added", duration: 5000, isClosable: true, variant: failedServers.length > 0 ? "warning" : "server-info", position: "bottom-left", }); resetFormState(); onClose(); } else { showError(`Failed to add all servers: ${failedServers.join(", ")}`); } }; const handleJsonChange = useCallback( (value: string) => { setCustomJsonContent(() => value); if (errorMessage.length > 0) { showError(""); } if (!value || value === DEFAULT_SERVER_CONFIGURATION_JSON) return; try { const parsed = JSON.parse(value); const keys = Object.keys(parsed); const result = serverNameSchema.safeParse(keys[0]); if (result.success) { setName(result.data); } else { setName(""); } } catch (e) { console.warn("Invalid JSON format:", e); setName(""); } }, [errorMessage], ); const handleMigrateJsonChange = useCallback( (value: string) => { setMigrateJsonContent(value); if (errorMessage.length > 0) { showError(""); } try { const parsed = JSON.parse(value); // Extract server config (handles nested formats) const serverConfig = extractServerConfig(parsed); const keys = Object.keys(serverConfig); // Validate all server names using safeParse const validServerNames = keys.filter((key) => { const result = serverNameSchema.safeParse(key); return result.success; }); if (validServerNames.length === 0) { setName(""); return; } // Use first server name for display purposes (when multiple servers, we'll add all) setName(validServerNames[0]); } catch (e) { console.warn("Invalid JSON format:", e); setName(""); } }, [errorMessage], ); const handleMigrateFileUpload = useCallback(() => { setHasUploadedFile(true); }, []); // Reset all form state to initial values const resetFormState = useCallback(() => { setName(DEFAULT_SERVER_NAME); setCustomJsonContent(DEFAULT_SERVER_CONFIGURATION_JSON); setMigrateJsonContent(""); setHasUploadedFile(false); setErrorMessage(""); setIsValid(true); setSearch(""); setActiveTab(TABS.ALL); }, []); const handleClose = useCallback(() => { if (!isDirty) { resetFormState(); onClose?.(); } else { // Show warning toast instead of browser confirm dialog const warningToast = toastRef.current({ title: "Unsaved Changes", description: "Changes you made have not been saved. Are you sure you want to close?", variant: "warning", duration: 1000000, // Long duration to prevent auto-dismiss action: ( <Button variant="danger" size="sm" onClick={() => { warningToast.dismiss(); // Dismiss the toast when OK is clicked resetFormState(); onClose?.(); }} > OK </Button> ), position: "bottom-left", }); } }, [isDirty, onClose, resetFormState]); const handleDialogOpenChange = useCallback( (open: boolean) => { if (!open) { if (!isDirty) { resetFormState(); onClose?.(); } else { const warningToast = toastRef.current({ title: "Unsaved Changes", description: "Changes you made have not been saved. Are you sure you want to close?", variant: "warning", duration: 1000000, action: ( <Button variant="danger" size="sm" onClick={() => { warningToast.dismiss(); resetFormState(); onClose?.(); }} > OK </Button> ), position: "bottom-left", }); } } }, [isDirty, onClose, resetFormState], ); const handleUseExample = ( config: Record<string, unknown>, serverName: string, withEnvs?: boolean, ) => { const newJsonContent = JSON.stringify(config, null, 2); setCustomJsonContent(newJsonContent); setName(serverName); if (!withEnvs) { handleAddServer(serverName, newJsonContent); return; } setActiveTab(TABS.CUSTOM); }; const handleValidate = useCallback((markers: editor.IMarker[]) => { setIsValid(markers.length === 0); }, []); return ( <Dialog open onOpenChange={handleDialogOpenChange}> <DialogContent className="max-w-[1560px] max-h-[90vh+40px] flex flex-col bg-white border border-[var(--color-border-primary)] rounded-lg"> <div className="text-2xl font-semibold">Add Server</div> <hr /> <div className="flex flex-col "> <div> <CustomTabs value={activeTab} onValueChange={(value: string) => { const newTab = value as TabValue; if (activeTab === TABS.MIGRATE && newTab !== TABS.MIGRATE) { setHasUploadedFile(false); } setActiveTab(newTab); }} > <CustomTabsList> <CustomTabsTrigger value={TABS.ALL}>All</CustomTabsTrigger> <CustomTabsTrigger value={TABS.CUSTOM}> Custom </CustomTabsTrigger> <CustomTabsTrigger value={TABS.MIGRATE}> Migrate </CustomTabsTrigger> </CustomTabsList> {activeTab === TABS.ALL && ( <div className="my-4"> <div className="my-4 text-sm"> Select server to add to your configuration </div> <div> <Input onChange={(e) => setSearch(e.target.value)} placeholder="Search..." /> </div> </div> )} {activeTab === TABS.CUSTOM && ( <div className="my-4 text-sm"> Add the server to your configuration by pasting your server's JSON configuration below. </div> )} <CustomTabsContent value={TABS.ALL}> <div className="flex gap-4 bg-white flex-wrap overflow-y-auto min-h-[calc(70vh-40px)] max-h-[calc(70vh-40px)]"> {serversFromCatalog .filter((catalogServer: CatalogMCPServerItem) => catalogServer.displayName .toLowerCase() .includes(search.toLowerCase()), ) .map((example: CatalogMCPServerItem) => ( <ServerCard key={example.name} server={example} status={getServerStatus(example.name)} className="w-[calc(25%-1rem)] max-h-[260px]" onAddServer={handleUseExample} /> ))} </div> </CustomTabsContent> <CustomTabsContent value={TABS.CUSTOM}> <McpJsonForm colorScheme={colorScheme} errorMessage={errorMessage} onValidate={handleValidate} onChange={handleJsonChange} placeholder={DEFAULT_SERVER_CONFIGURATION_JSON} schema={z.toJSONSchema(mcpJsonSchema)} value={customJsonContent} /> <Separator className="my-4" /> </CustomTabsContent> <CustomTabsContent value={TABS.MIGRATE}> <div className="my-4"> <div className="my-4 text-sm"> Add servers to your configuration by pasting your JSON configuration below or upload file. </div> <JsonUpload value={migrateJsonContent} onChange={handleMigrateJsonChange} onFileUpload={handleMigrateFileUpload} onValidate={handleValidate} height="400px" /> {errorMessage && ( <div className="mb-3 p-2 bg-[var(--color-bg-danger)] border border-[var(--color-border-danger)] rounded-md"> <p className="inline-flex items-center gap-1 px-2 py-0.5 font-medium text-sm text-[var(--color-fg-danger)]"> {errorMessage} </p> </div> )} </div> <Separator className="my-4" /> </CustomTabsContent> </CustomTabs> </div> </div> {isPending && ( <div className="px-6 flex-shrink-0"> <div className="space-y-2"> <div className="relative h-2 w-full overflow-hidden rounded-full bg-[var(--color-bg-container-secondary)] animate-pulse"> <div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--color-fg-interactive)] to-transparent animate-[shimmer_2s_ease-in-out_infinite]" /> <div className="absolute inset-0 bg-gradient-to-r from-[var(--color-fg-interactive)] via-transparent to-[var(--color-fg-interactive)] animate-[shimmer_1.5s_ease-in-out_infinite_reverse]" /> </div> </div> </div> )} {(activeTab === TABS.CUSTOM || activeTab === TABS.MIGRATE) && ( <div className="w-full flex justify-between -mt-6"> {handleClose && ( <Button onClick={handleClose} className="text-component-primary" variant="ghost" type="button" > Cancel </Button> )} <Button disabled={ isPending || !isDirty || (activeTab !== TABS.CUSTOM && activeTab !== TABS.MIGRATE) || !isValid } onClick={() => { // Both CUSTOM and MIGRATE tabs use the same handler // handleAddServer automatically detects single vs multiple servers const jsonContent = activeTab === TABS.CUSTOM ? customJsonContent : migrateJsonContent; handleAddServer(name, jsonContent); }} > {isPending ? ( <> Adding... <Spinner /> </> ) : ( "Add" )} </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