"use client"; import { useState, useMemo } from "react"; import { MCPServerConfig, MCPRemoteConfigZodSchema, MCPStdioConfigZodSchema, } from "app-types/mcp"; import { Input } from "./ui/input"; import { Button } from "./ui/button"; import { Label } from "./ui/label"; import { Textarea } from "./ui/textarea"; import JsonView from "./ui/json-view"; import { toast } from "sonner"; import { safe } from "ts-safe"; import { useRouter } from "next/navigation"; import { createDebounce, fetcher, isNull, safeJSONParse } from "lib/utils"; import { handleErrorWithToast } from "ui/shared-toast"; import { mutate } from "swr"; import { Loader } from "lucide-react"; import { isMaybeMCPServerConfig, isMaybeRemoteConfig, } from "lib/ai/mcp/is-mcp-config"; import { Alert, AlertDescription, AlertTitle } from "ui/alert"; import { z } from "zod"; import { useTranslations } from "next-intl"; import { existMcpClientByServerNameAction } from "@/app/api/mcp/actions"; interface MCPEditorProps { initialConfig?: MCPServerConfig; name?: string; id?: string; } const STDIO_ARGS_ENV_PLACEHOLDER = `/** STDIO Example */ { "command": "node", "args": ["index.js"], "env": { "OPENAI_API_KEY": "sk-...", } } /** SSE,Streamable HTTP Example */ { "url": "https://api.example.com", "headers": { "Authorization": "Bearer sk-..." } } /** Standard mcpServers JSON Example */ { "mcpServers": { "my-server": { "command": "node", "args": ["index.js"] } } }`; // Helper to check and extract standard mcpServers configurations const getMcpServersFromObject = (obj: any): Record | null => { if (!obj || typeof obj !== "object") return null; const servers = obj.mcpServers || obj; if (typeof servers !== "object" || Array.isArray(servers)) return null; const keys = Object.keys(servers); if (keys.length === 0) return null; // Verify all keys represent valid server configs const allValid = keys.every(key => { const srv = servers[key]; return srv && typeof srv === "object" && isMaybeMCPServerConfig(srv); }); return allValid ? servers : null; }; // Helper to rewrite temp path to permanent path for loop-engineering const rewriteConfigPaths = (cfg: any): any => { try { let str = JSON.stringify(cfg); str = str.replace(/\/tmp\/loop-eng\/loop-engineering/g, "/app/loop-engineering"); return JSON.parse(str); } catch { return cfg; } }; export default function MCPEditor({ initialConfig, name: initialName, id, }: MCPEditorProps) { const t = useTranslations(); const shouldInsert = useMemo(() => isNull(id), [id]); const [isLoading, setIsLoading] = useState(false); const [jsonError, setJsonError] = useState(null); const [nameError, setNameError] = useState(null); const errorDebounce = useMemo(() => createDebounce(), []); // State for form fields const [name, setName] = useState(initialName ?? ""); const router = useRouter(); const [config, setConfig] = useState( initialConfig as MCPServerConfig, ); const [jsonString, setJsonString] = useState( initialConfig ? JSON.stringify(initialConfig, null, 2) : "", ); // Name validation schema const nameSchema = z.string().regex(/^[a-zA-Z0-9\-]+$/, { message: t("MCP.nameMustContainOnlyAlphanumericCharactersAndHyphens"), }); const validateName = (nameValue: string): boolean => { const result = nameSchema.safeParse(nameValue); if (!result.success) { setNameError( t("MCP.nameMustContainOnlyAlphanumericCharactersAndHyphens"), ); return false; } setNameError(null); return true; }; const isMulti = useMemo(() => { return getMcpServersFromObject(config) !== null; }, [config]); const saveDisabled = useMemo(() => { return ( (!isMulti && name.trim() === "") || isLoading || !!jsonError || (!isMulti && !!nameError) || !(isMaybeMCPServerConfig(config) || isMulti) ); }, [isLoading, jsonError, nameError, config, name, isMulti]); // Validate const validateConfig = (jsonConfig: unknown): boolean => { const isMultiConfig = getMcpServersFromObject(jsonConfig) !== null; if (isMultiConfig) return true; const result = isMaybeRemoteConfig(jsonConfig) ? MCPRemoteConfigZodSchema.safeParse(jsonConfig) : MCPStdioConfigZodSchema.safeParse(jsonConfig); if (!result.success) { handleErrorWithToast(result.error, "mcp-editor-error"); } return result.success; }; // Handle save button click const handleSave = async () => { const mcpServers = getMcpServersFromObject(config); if (mcpServers) { setIsLoading(true); try { for (const [srvName, srvConfig] of Object.entries(mcpServers)) { // Validate name const nameResult = nameSchema.safeParse(srvName); if (!nameResult.success) { throw new Error(`Invalid server name "${srvName}": ${nameResult.error.issues[0].message}`); } // Validate individual config schema const configResult = isMaybeRemoteConfig(srvConfig) ? MCPRemoteConfigZodSchema.safeParse(srvConfig) : MCPStdioConfigZodSchema.safeParse(srvConfig); if (!configResult.success) { throw new Error(`Invalid config for server "${srvName}": ${configResult.error.issues[0].message}`); } if (shouldInsert) { const exist = await existMcpClientByServerNameAction(srvName); if (exist) { throw new Error(`Server "${srvName}" already exists`); } } } // Save each server for (const [srvName, srvConfig] of Object.entries(mcpServers)) { await fetcher("/api/mcp", { method: "POST", body: JSON.stringify({ name: srvName, config: rewriteConfigPaths(srvConfig), }), }); } toast.success("All MCP configurations saved successfully"); mutate("/api/mcp/list"); router.push("/mcp"); } catch (err: any) { handleErrorWithToast(err); } finally { setIsLoading(false); } return; } // Perform validation if (!validateConfig(config)) return; if (!name) { return handleErrorWithToast( new Error(t("MCP.nameIsRequired")), "mcp-editor-error", ); } if (!validateName(name)) { return handleErrorWithToast( new Error(t("MCP.nameMustContainOnlyAlphanumericCharactersAndHyphens")), "mcp-editor-error", ); } safe(() => setIsLoading(true)) .map(async () => { if (shouldInsert) { const exist = await existMcpClientByServerNameAction(name); if (exist) { throw new Error(t("MCP.nameAlreadyExists")); } } }) .map(() => fetcher("/api/mcp", { method: "POST", body: JSON.stringify({ name, config: rewriteConfigPaths(config), id, }), }), ) .ifOk(() => { toast.success(t("MCP.configurationSavedSuccessfully")); mutate("/api/mcp/list"); router.push("/mcp"); }) .ifFail(handleErrorWithToast) .watch(() => setIsLoading(false)); }; const handleConfigChange = (data: string) => { setJsonString(data); const result = safeJSONParse(data); errorDebounce.clear(); if (result.success) { setConfig(result.value as MCPServerConfig); setJsonError(null); } else if (data.trim() !== "") { errorDebounce(() => { setJsonError( (result.error as Error)?.message ?? JSON.stringify(result.error, null, 2), ); }, 1000); } }; return ( <>
{/* Name field */}
{ setName(e.target.value); if (e.target.value) validateName(e.target.value); }} placeholder={t("MCP.enterMcpServerName")} className={nameError && !isMulti ? "border-destructive" : ""} /> {nameError && !isMulti &&

{nameError}

}
{/* Split view for config editor */}
{/* Left side: Textarea for editing */}