"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Globe, Loader2, RefreshCw, Search, ShieldCheck, TerminalSquare, } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Textarea } from "@/components/ui/textarea" import { mcpGetMarketplaceServerDetail, mcpInstallFromMarketplace, mcpListMarketplaces, mcpRemoveServer, mcpScanLocal, mcpSearchMarketplace, mcpUpsertLocalServer, } from "@/lib/api" import { cn } from "@/lib/utils" import type { LocalMcpServer, McpAppType, McpMarketplaceItem, McpMarketplaceInstallOption, McpMarketplaceProvider, McpMarketplaceServerDetail, } from "@/lib/types" type LeftTab = "local" | "market" type Selection = | { kind: "local"; id: string } | { kind: "market"; id: string } | null type McpTranslator = ( key: string, values?: Record ) => string const APP_OPTIONS: { value: McpAppType; label: string }[] = [ { value: "claude_code", label: "Claude" }, { value: "codex", label: "Codex" }, { value: "open_code", label: "OpenCode" }, ] function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value) } function readString(spec: Record, key: string): string | null { const raw = spec[key] if (typeof raw !== "string") return null const trimmed = raw.trim() return trimmed ? trimmed : null } function specSummary(spec: Record, t: McpTranslator): string { const typ = readString(spec, "type") ?? "stdio" if (typ === "stdio") { const command = readString(spec, "command") ?? t("summary.missingCommand") const rawArgs = spec.args const args = Array.isArray(rawArgs) ? rawArgs .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean) : [] return args.length > 0 ? `${command} ${args.join(" ")}` : command } const url = readString(spec, "url") ?? t("summary.missingUrl") return `${typ}: ${url}` } function protocolBadgeLabel(protocol: string, t: McpTranslator): string { if (protocol === "stdio") return t("protocol.stdio") if (protocol === "sse") return "SSE" if (protocol === "http") return "HTTP" return protocol } function defaultParamDraft( option: McpMarketplaceInstallOption | null ): Record { if (!option) return {} const draft: Record = {} for (const field of option.parameters) { if (field.default_value === null || field.default_value === undefined) continue if (typeof field.default_value === "string") { draft[field.key] = field.default_value continue } if ( typeof field.default_value === "number" || typeof field.default_value === "boolean" ) { draft[field.key] = String(field.default_value) continue } draft[field.key] = JSON.stringify(field.default_value) } return draft } function parseParameterValues( option: McpMarketplaceInstallOption | null, draft: Record, t: McpTranslator ): { values: Record; error: string | null } { if (!option) return { values: {}, error: t("errors.selectInstallProtocol") } const values: Record = {} for (const field of option.parameters) { const raw = (draft[field.key] ?? "").trim() if (!raw) { if (field.required && field.default_value == null) { return { values: {}, error: t("errors.fieldRequired", { field: field.label }), } } continue } if (field.kind === "boolean") { if (raw !== "true" && raw !== "false") { return { values: {}, error: t("errors.fieldNeedsBoolean", { field: field.label }), } } values[field.key] = raw === "true" continue } if (field.kind === "number") { const next = Number(raw) if (!Number.isFinite(next)) { return { values: {}, error: t("errors.fieldNeedsNumber", { field: field.label }), } } values[field.key] = next continue } if (field.kind === "integer") { const next = Number(raw) if (!Number.isInteger(next)) { return { values: {}, error: t("errors.fieldNeedsInteger", { field: field.label }), } } values[field.key] = next continue } if (field.kind === "json") { try { values[field.key] = JSON.parse(raw) } catch (err) { const message = err instanceof Error ? err.message : String(err) return { values: {}, error: t("errors.fieldInvalidJson", { field: field.label, message, }), } } continue } if (field.enum_values.length > 0 && !field.enum_values.includes(raw)) { return { values: {}, error: t("errors.fieldOutOfRange", { field: field.label }), } } values[field.key] = raw } return { values, error: null } } function normalizeApps(apps: McpAppType[]): McpAppType[] { return [...new Set(apps)] } function appsToDraft(apps: McpAppType[]): Record { const appSet = new Set(apps) return { claude_code: appSet.has("claude_code"), codex: appSet.has("codex"), open_code: appSet.has("open_code"), } } function selectedAppsFromDraft( draft: Record ): McpAppType[] { return APP_OPTIONS.filter((item) => draft[item.value]).map( (item) => item.value ) } function parseJsonObject( text: string, name: string, t: McpTranslator ): Record { const trimmed = text.trim() if (!trimmed) { throw new Error(t("errors.jsonEmpty", { name })) } let parsed: unknown try { parsed = JSON.parse(trimmed) } catch (err) { const message = err instanceof Error ? err.message : String(err) throw new Error(t("errors.jsonInvalid", { name, message })) } if (!isObject(parsed)) { throw new Error(t("errors.jsonMustBeObject", { name })) } return parsed } export function McpSettings() { const t = useTranslations("McpSettings") const mcpT = t as unknown as McpTranslator const [loading, setLoading] = useState(true) const [loadingError, setLoadingError] = useState(null) const [leftTab, setLeftTab] = useState("local") const [selection, setSelection] = useState(null) const [installedServers, setInstalledServers] = useState([]) const [localFilter, setLocalFilter] = useState("") const [providers, setProviders] = useState([]) const [selectedProvider, setSelectedProvider] = useState("") const [marketQuery, setMarketQuery] = useState("") const marketQueryRef = useRef("") const [searching, setSearching] = useState(false) const [searchError, setSearchError] = useState(null) const [searchResults, setSearchResults] = useState([]) const [marketDetail, setMarketDetail] = useState(null) const [marketDetailLoading, setMarketDetailLoading] = useState(false) const [marketDetailError, setMarketDetailError] = useState( null ) const [marketSpecText, setMarketSpecText] = useState("") const [marketSpecDirty, setMarketSpecDirty] = useState(false) const [selectedInstallOptionId, setSelectedInstallOptionId] = useState("") const [installParamDraft, setInstallParamDraft] = useState< Record >({}) const [localSpecText, setLocalSpecText] = useState("") const [localAppsDraft, setLocalAppsDraft] = useState< Record >(appsToDraft([])) const [installDialogOpen, setInstallDialogOpen] = useState(false) const [installAppsDraft, setInstallAppsDraft] = useState< Record >(appsToDraft(APP_OPTIONS.map((x) => x.value))) const [runningAction, setRunningAction] = useState(null) const selectedLocal = useMemo(() => { if (selection?.kind !== "local") return null return installedServers.find((item) => item.id === selection.id) ?? null }, [installedServers, selection]) const selectedMarketItem = useMemo(() => { if (selection?.kind !== "market") return null return searchResults.find((item) => item.server_id === selection.id) ?? null }, [searchResults, selection]) const selectedInstallOption = useMemo(() => { if (!marketDetail) return null return ( marketDetail.install_options.find( (item) => item.id === selectedInstallOptionId ) ?? marketDetail.install_options[0] ?? null ) }, [marketDetail, selectedInstallOptionId]) const filteredLocalServers = useMemo(() => { const q = localFilter.trim().toLowerCase() if (!q) return installedServers return installedServers.filter((item) => { if (item.id.toLowerCase().includes(q)) return true const spec = isObject(item.spec) ? item.spec : {} return specSummary(spec, mcpT).toLowerCase().includes(q) }) }, [installedServers, localFilter, mcpT]) const refreshLocalServers = useCallback(async () => { const servers = await mcpScanLocal() setInstalledServers(servers) return servers }, []) const loadInitial = useCallback(async () => { setLoading(true) setLoadingError(null) try { const [servers, marketProviders] = await Promise.all([ mcpScanLocal(), mcpListMarketplaces(), ]) setInstalledServers(servers) setProviders(marketProviders) setSelectedProvider( (current) => current || marketProviders[0]?.id || "official_registry" ) if (servers[0]) { setSelection({ kind: "local", id: servers[0].id }) } } catch (err) { const message = err instanceof Error ? err.message : String(err) setLoadingError(message) } finally { setLoading(false) } }, []) useEffect(() => { loadInitial().catch((err) => { console.error("[Settings] load MCP settings failed:", err) }) }, [loadInitial]) useEffect(() => { if (!selectedLocal) return const nextSpec = JSON.stringify(selectedLocal.spec, null, 2) setLocalSpecText(nextSpec) setLocalAppsDraft(appsToDraft(selectedLocal.apps)) }, [selectedLocal]) useEffect(() => { if (selection?.kind !== "market" || !selectedMarketItem) { setMarketDetail(null) setMarketDetailError(null) setMarketSpecText("") setMarketSpecDirty(false) setSelectedInstallOptionId("") setInstallParamDraft({}) return } setMarketDetailLoading(true) setMarketDetailError(null) mcpGetMarketplaceServerDetail({ providerId: selectedMarketItem.provider_id, serverId: selectedMarketItem.server_id, }) .then((detail) => { setMarketDetail(detail) const defaultOption = detail.install_options.find( (item) => item.id === detail.default_option_id ) ?? detail.install_options[0] ?? null setSelectedInstallOptionId(defaultOption?.id ?? "") setInstallParamDraft(defaultParamDraft(defaultOption)) setMarketSpecText( JSON.stringify(defaultOption?.spec ?? detail.spec, null, 2) ) setMarketSpecDirty(false) }) .catch((err) => { const message = err instanceof Error ? err.message : String(err) setMarketDetailError(message) setMarketDetail(null) setMarketSpecText("") setMarketSpecDirty(false) setSelectedInstallOptionId("") setInstallParamDraft({}) }) .finally(() => { setMarketDetailLoading(false) }) }, [selection, selectedMarketItem]) const executeSearch = useCallback( async ({ providerId, query, }: { providerId: string query: string | null }) => { if (!providerId) return setSearching(true) setSearchError(null) try { const results = await mcpSearchMarketplace({ providerId, query: query?.trim() || null, limit: 30, }) setSearchResults(results) if (results[0]) { setSelection((current) => { if (current?.kind === "market") { const hit = results.some((item) => item.server_id === current.id) if (hit) return current } return { kind: "market", id: results[0].server_id } }) } } catch (err) { const message = err instanceof Error ? err.message : String(err) setSearchError(message) } finally { setSearching(false) } }, [] ) useEffect(() => { marketQueryRef.current = marketQuery }, [marketQuery]) useEffect(() => { if (leftTab !== "market" || !selectedProvider) return executeSearch({ providerId: selectedProvider, query: marketQueryRef.current, }).catch((err) => { console.error("[Settings] auto search MCP marketplace failed:", err) }) }, [executeSearch, leftTab, selectedProvider]) const uninstallServer = useCallback( async (serverId: string) => { const action = `uninstall:${serverId}` setRunningAction(action) try { await mcpRemoveServer(serverId) const next = await refreshLocalServers() toast.success(t("toasts.uninstalled")) setSelection((current) => { if (current?.kind !== "local" || current.id !== serverId) return current if (next[0]) return { kind: "local", id: next[0].id } return null }) } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(t("toasts.uninstallFailed", { message })) } finally { setRunningAction(null) } }, [refreshLocalServers, t] ) const saveLocalServer = useCallback(async () => { if (!selectedLocal) return let parsedSpec: Record try { parsedSpec = parseJsonObject( localSpecText, t("jsonNames.localConfig"), mcpT ) } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(message) return } const apps = normalizeApps(selectedAppsFromDraft(localAppsDraft)) if (apps.length === 0) { toast.error(t("toasts.selectAtLeastOneApp")) return } const action = `save:${selectedLocal.id}` setRunningAction(action) try { await mcpUpsertLocalServer({ serverId: selectedLocal.id, spec: parsedSpec, apps, }) const next = await refreshLocalServers() toast.success(t("toasts.saveSuccess")) const updated = next.find((item) => item.id === selectedLocal.id) if (updated) { setSelection({ kind: "local", id: updated.id }) setLocalSpecText(JSON.stringify(updated.spec, null, 2)) setLocalAppsDraft(appsToDraft(updated.apps)) } } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(t("toasts.saveFailed", { message })) } finally { setRunningAction(null) } }, [ localAppsDraft, localSpecText, mcpT, refreshLocalServers, selectedLocal, t, ]) const switchInstallOption = useCallback( (optionId: string) => { if (!marketDetail) return const option = marketDetail.install_options.find((item) => item.id === optionId) ?? marketDetail.install_options[0] ?? null setSelectedInstallOptionId(option?.id ?? "") setInstallParamDraft(defaultParamDraft(option)) setMarketSpecText( JSON.stringify(option?.spec ?? marketDetail.spec, null, 2) ) setMarketSpecDirty(false) }, [marketDetail] ) const openInstallDialog = useCallback(() => { if (!marketDetail) return setInstallAppsDraft(appsToDraft(APP_OPTIONS.map((item) => item.value))) const option = marketDetail.install_options.find( (item) => item.id === selectedInstallOptionId ) ?? marketDetail.install_options[0] ?? null setSelectedInstallOptionId(option?.id ?? "") setInstallParamDraft(defaultParamDraft(option)) setInstallDialogOpen(true) }, [marketDetail, selectedInstallOptionId]) const installMarketServer = useCallback(async () => { if (!marketDetail) return const parsedParams = parseParameterValues( selectedInstallOption, installParamDraft, mcpT ) if (parsedParams.error) { toast.error(parsedParams.error) return } let specOverride: Record | null = null const baselineText = JSON.stringify( selectedInstallOption?.spec ?? marketDetail.spec, null, 2 ).trim() const currentSpecText = marketSpecText.trim() if (marketSpecDirty && currentSpecText !== baselineText) { try { specOverride = parseJsonObject( marketSpecText, t("jsonNames.installConfig"), mcpT ) } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(message) return } } const apps = normalizeApps(selectedAppsFromDraft(installAppsDraft)) if (apps.length === 0) { toast.error(t("toasts.selectAtLeastOneApp")) return } const action = `install:${marketDetail.server_id}` setRunningAction(action) try { await mcpInstallFromMarketplace({ providerId: marketDetail.provider_id, serverId: marketDetail.server_id, apps, optionId: selectedInstallOption?.id ?? null, protocol: selectedInstallOption?.protocol ?? null, parameterValues: parsedParams.values, specOverride, }) const nextLocal = await refreshLocalServers() toast.success(t("toasts.installed", { name: marketDetail.name })) setInstallDialogOpen(false) setLeftTab("local") const installed = nextLocal.find( (item) => item.id === marketDetail.server_id ) if (installed) { setSelection({ kind: "local", id: installed.id }) } } catch (err) { const message = err instanceof Error ? err.message : String(err) toast.error(t("toasts.installFailed", { message })) } finally { setRunningAction(null) } }, [ installAppsDraft, installParamDraft, marketDetail, marketSpecDirty, marketSpecText, mcpT, refreshLocalServers, selectedInstallOption, t, ]) if (loading) { return (
{t("loading")}
) } return ( <> {t("installDialog.title")} {marketDetail ? t("installDialog.descriptionWithName", { name: marketDetail.name, }) : t("installDialog.description")}
{t("installDialog.protocol")}
{selectedInstallOption?.parameters.length ? (
{t("installDialog.parameters")}
{selectedInstallOption.parameters.map((field) => { const raw = installParamDraft[field.key] ?? "" return (
{field.label} {field.required ? ( * ) : null} {field.location ? ( {field.location} ) : null}
{field.kind === "boolean" ? ( ) : field.enum_values.length > 0 ? ( ) : field.kind === "json" ? (