import { IconButton } from "./button"; import { ErrorBoundary } from "./error"; import styles from "./mcp-market.module.scss"; import EditIcon from "../icons/edit.svg"; import AddIcon from "../icons/add.svg"; import CloseIcon from "../icons/close.svg"; import DeleteIcon from "../icons/delete.svg"; import RestartIcon from "../icons/reload.svg"; import EyeIcon from "../icons/eye.svg"; import GithubIcon from "../icons/github.svg"; import { List, ListItem, Modal, showToast } from "./ui-lib"; import { useNavigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { addMcpServer, getClientsStatus, getClientTools, getMcpConfigFromFile, isMcpEnabled, pauseMcpServer, restartAllClients, resumeMcpServer, } from "../mcp/actions"; import { ListToolsResponse, McpConfigData, PresetServer, ServerConfig, ServerStatusResponse, } from "../mcp/types"; import clsx from "clsx"; import PlayIcon from "../icons/play.svg"; import StopIcon from "../icons/pause.svg"; import { Path } from "../constant"; interface ConfigProperty { type: string; description?: string; required?: boolean; minItems?: number; } export function McpMarketPage() { const navigate = useNavigate(); const [mcpEnabled, setMcpEnabled] = useState(false); const [searchText, setSearchText] = useState(""); const [userConfig, setUserConfig] = useState<Record<string, any>>({}); const [editingServerId, setEditingServerId] = useState<string | undefined>(); const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null); const [viewingServerId, setViewingServerId] = useState<string | undefined>(); const [isLoading, setIsLoading] = useState(false); const [config, setConfig] = useState<McpConfigData>(); const [clientStatuses, setClientStatuses] = useState< Record<string, ServerStatusResponse> >({}); const [loadingPresets, setLoadingPresets] = useState(true); const [presetServers, setPresetServers] = useState<PresetServer[]>([]); const [loadingStates, setLoadingStates] = useState<Record<string, string>>( {}, ); // 检查 MCP 是否启用 useEffect(() => { const checkMcpStatus = async () => { const enabled = await isMcpEnabled(); setMcpEnabled(enabled); if (!enabled) { navigate(Path.Home); } }; checkMcpStatus(); }, [navigate]); // 添加状态轮询 useEffect(() => { if (!mcpEnabled || !config) return; const updateStatuses = async () => { const statuses = await getClientsStatus(); setClientStatuses(statuses); }; // 立即执行一次 updateStatuses(); // 每 1000ms 轮询一次 const timer = setInterval(updateStatuses, 1000); return () => clearInterval(timer); }, [mcpEnabled, config]); // 加载预设服务器 useEffect(() => { const loadPresetServers = async () => { if (!mcpEnabled) return; try { setLoadingPresets(true); const response = await fetch("https://nextchat.club/mcp/list"); if (!response.ok) { throw new Error("Failed to load preset servers"); } const data = await response.json(); setPresetServers(data?.data ?? []); } catch (error) { console.error("Failed to load preset servers:", error); showToast("Failed to load preset servers"); } finally { setLoadingPresets(false); } }; loadPresetServers(); }, [mcpEnabled]); // 加载初始状态 useEffect(() => { const loadInitialState = async () => { if (!mcpEnabled) return; try { setIsLoading(true); const config = await getMcpConfigFromFile(); setConfig(config); // 获取所有客户端的状态 const statuses = await getClientsStatus(); setClientStatuses(statuses); } catch (error) { console.error("Failed to load initial state:", error); showToast("Failed to load initial state"); } finally { setIsLoading(false); } }; loadInitialState(); }, [mcpEnabled]); // 加载当前编辑服务器的配置 useEffect(() => { if (!editingServerId || !config) return; const currentConfig = config.mcpServers[editingServerId]; if (currentConfig) { // 从当前配置中提取用户配置 const preset = presetServers.find((s) => s.id === editingServerId); if (preset?.configSchema) { const userConfig: Record<string, any> = {}; Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => { if (mapping.type === "spread") { // For spread types, extract the array from args. const startPos = mapping.position ?? 0; userConfig[key] = currentConfig.args.slice(startPos); } else if (mapping.type === "single") { // For single types, get a single value userConfig[key] = currentConfig.args[mapping.position ?? 0]; } else if ( mapping.type === "env" && mapping.key && currentConfig.env ) { // For env types, get values from environment variables userConfig[key] = currentConfig.env[mapping.key]; } }); setUserConfig(userConfig); } } else { setUserConfig({}); } }, [editingServerId, config, presetServers]); if (!mcpEnabled) { return null; } // 检查服务器是否已添加 const isServerAdded = (id: string) => { return id in (config?.mcpServers ?? {}); }; // 保存服务器配置 const saveServerConfig = async () => { const preset = presetServers.find((s) => s.id === editingServerId); if (!preset || !preset.configSchema || !editingServerId) return; const savingServerId = editingServerId; setEditingServerId(undefined); try { updateLoadingState(savingServerId, "Updating configuration..."); // 构建服务器配置 const args = [...preset.baseArgs]; const env: Record<string, string> = {}; Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => { const value = userConfig[key]; if (mapping.type === "spread" && Array.isArray(value)) { const pos = mapping.position ?? 0; args.splice(pos, 0, ...value); } else if ( mapping.type === "single" && mapping.position !== undefined ) { args[mapping.position] = value; } else if ( mapping.type === "env" && mapping.key && typeof value === "string" ) { env[mapping.key] = value; } }); const serverConfig: ServerConfig = { command: preset.command, args, ...(Object.keys(env).length > 0 ? { env } : {}), }; const newConfig = await addMcpServer(savingServerId, serverConfig); setConfig(newConfig); showToast("Server configuration updated successfully"); } catch (error) { showToast( error instanceof Error ? error.message : "Failed to save configuration", ); } finally { updateLoadingState(savingServerId, null); } }; // 获取服务器支持的 Tools const loadTools = async (id: string) => { try { const result = await getClientTools(id); if (result) { setTools(result); } else { throw new Error("Failed to load tools"); } } catch (error) { showToast("Failed to load tools"); console.error(error); setTools(null); } }; // 更新加载状态的辅助函数 const updateLoadingState = (id: string, message: string | null) => { setLoadingStates((prev) => { if (message === null) { const { [id]: _, ...rest } = prev; return rest; } return { ...prev, [id]: message }; }); }; // 修改添加服务器函数 const addServer = async (preset: PresetServer) => { if (!preset.configurable) { try { const serverId = preset.id; updateLoadingState(serverId, "Creating MCP client..."); const serverConfig: ServerConfig = { command: preset.command, args: [...preset.baseArgs], }; const newConfig = await addMcpServer(preset.id, serverConfig); setConfig(newConfig); // 更新状态 const statuses = await getClientsStatus(); setClientStatuses(statuses); } finally { updateLoadingState(preset.id, null); } } else { // 如果需要配置,打开配置对话框 setEditingServerId(preset.id); setUserConfig({}); } }; // 修改暂停服务器函数 const pauseServer = async (id: string) => { try { updateLoadingState(id, "Stopping server..."); const newConfig = await pauseMcpServer(id); setConfig(newConfig); showToast("Server stopped successfully"); } catch (error) { showToast("Failed to stop server"); console.error(error); } finally { updateLoadingState(id, null); } }; // Restart server const restartServer = async (id: string) => { try { updateLoadingState(id, "Starting server..."); await resumeMcpServer(id); } catch (error) { showToast( error instanceof Error ? error.message : "Failed to start server, please check logs", ); console.error(error); } finally { updateLoadingState(id, null); } }; // Restart all clients const handleRestartAll = async () => { try { updateLoadingState("all", "Restarting all servers..."); const newConfig = await restartAllClients(); setConfig(newConfig); showToast("Restarting all clients"); } catch (error) { showToast("Failed to restart clients"); console.error(error); } finally { updateLoadingState("all", null); } }; // Render configuration form const renderConfigForm = () => { const preset = presetServers.find((s) => s.id === editingServerId); if (!preset?.configSchema) return null; return Object.entries(preset.configSchema.properties).map( ([key, prop]: [string, ConfigProperty]) => { if (prop.type === "array") { const currentValue = userConfig[key as keyof typeof userConfig] || []; const itemLabel = (prop as any).itemLabel || key; const addButtonText = (prop as any).addButtonText || `Add ${itemLabel}`; return ( <ListItem key={key} title={key} subTitle={prop.description} vertical > <div className={styles["path-list"]}> {(currentValue as string[]).map( (value: string, index: number) => ( <div key={index} className={styles["path-item"]}> <input type="text" value={value} placeholder={`${itemLabel} ${index + 1}`} onChange={(e) => { const newValue = [...currentValue] as string[]; newValue[index] = e.target.value; setUserConfig({ ...userConfig, [key]: newValue }); }} /> <IconButton icon={<DeleteIcon />} className={styles["delete-button"]} onClick={() => { const newValue = [...currentValue] as string[]; newValue.splice(index, 1); setUserConfig({ ...userConfig, [key]: newValue }); }} /> </div> ), )} <IconButton icon={<AddIcon />} text={addButtonText} className={styles["add-button"]} bordered onClick={() => { const newValue = [...currentValue, ""] as string[]; setUserConfig({ ...userConfig, [key]: newValue }); }} /> </div> </ListItem> ); } else if (prop.type === "string") { const currentValue = userConfig[key as keyof typeof userConfig] || ""; return ( <ListItem key={key} title={key} subTitle={prop.description}> <input aria-label={key} type="text" value={currentValue} placeholder={`Enter ${key}`} onChange={(e) => { setUserConfig({ ...userConfig, [key]: e.target.value }); }} /> </ListItem> ); } return null; }, ); }; const checkServerStatus = (clientId: string) => { return clientStatuses[clientId] || { status: "undefined", errorMsg: null }; }; const getServerStatusDisplay = (clientId: string) => { const status = checkServerStatus(clientId); const statusMap = { undefined: null, // 未配置/未找到不显示 // 添加初始化状态 initializing: ( <span className={clsx(styles["server-status"], styles["initializing"])}> Initializing </span> ), paused: ( <span className={clsx(styles["server-status"], styles["stopped"])}> Stopped </span> ), active: <span className={styles["server-status"]}>Running</span>, error: ( <span className={clsx(styles["server-status"], styles["error"])}> Error <span className={styles["error-message"]}>: {status.errorMsg}</span> </span> ), }; return statusMap[status.status]; }; // Get the type of operation status const getOperationStatusType = (message: string) => { if (message.toLowerCase().includes("stopping")) return "stopping"; if (message.toLowerCase().includes("starting")) return "starting"; if (message.toLowerCase().includes("error")) return "error"; return "default"; }; // 渲染服务器列表 const renderServerList = () => { if (loadingPresets) { return ( <div className={styles["loading-container"]}> <div className={styles["loading-text"]}> Loading preset server list... </div> </div> ); } if (!Array.isArray(presetServers) || presetServers.length === 0) { return ( <div className={styles["empty-container"]}> <div className={styles["empty-text"]}>No servers available</div> </div> ); } return presetServers .filter((server) => { if (searchText.length === 0) return true; const searchLower = searchText.toLowerCase(); return ( server.name.toLowerCase().includes(searchLower) || server.description.toLowerCase().includes(searchLower) || server.tags.some((tag) => tag.toLowerCase().includes(searchLower)) ); }) .sort((a, b) => { const aStatus = checkServerStatus(a.id).status; const bStatus = checkServerStatus(b.id).status; const aLoading = loadingStates[a.id]; const bLoading = loadingStates[b.id]; // 定义状态优先级 const statusPriority: Record<string, number> = { error: 0, // Highest priority for error status active: 1, // Second for active initializing: 2, // Initializing starting: 3, // Starting stopping: 4, // Stopping paused: 5, // Paused undefined: 6, // Lowest priority for undefined }; // Get actual status (including loading status) const getEffectiveStatus = (status: string, loading?: string) => { if (loading) { const operationType = getOperationStatusType(loading); return operationType === "default" ? status : operationType; } if (status === "initializing" && !loading) { return "active"; } return status; }; const aEffectiveStatus = getEffectiveStatus(aStatus, aLoading); const bEffectiveStatus = getEffectiveStatus(bStatus, bLoading); // 首先按状态排序 if (aEffectiveStatus !== bEffectiveStatus) { return ( (statusPriority[aEffectiveStatus] ?? 6) - (statusPriority[bEffectiveStatus] ?? 6) ); } // Sort by name when statuses are the same return a.name.localeCompare(b.name); }) .map((server) => ( <div className={clsx(styles["mcp-market-item"], { [styles["loading"]]: loadingStates[server.id], })} key={server.id} > <div className={styles["mcp-market-header"]}> <div className={styles["mcp-market-title"]}> <div className={styles["mcp-market-name"]}> {server.name} {loadingStates[server.id] && ( <span className={styles["operation-status"]} data-status={getOperationStatusType( loadingStates[server.id], )} > {loadingStates[server.id]} </span> )} {!loadingStates[server.id] && getServerStatusDisplay(server.id)} {server.repo && ( <a href={server.repo} target="_blank" rel="noopener noreferrer" className={styles["repo-link"]} title="Open repository" > <GithubIcon /> </a> )} </div> <div className={styles["tags-container"]}> {server.tags.map((tag, index) => ( <span key={index} className={styles["tag"]}> {tag} </span> ))} </div> <div className={clsx(styles["mcp-market-info"], "one-line")} title={server.description} > {server.description} </div> </div> <div className={styles["mcp-market-actions"]}> {isServerAdded(server.id) ? ( <> {server.configurable && ( <IconButton icon={<EditIcon />} text="Configure" onClick={() => setEditingServerId(server.id)} disabled={isLoading} /> )} {checkServerStatus(server.id).status === "paused" ? ( <> <IconButton icon={<PlayIcon />} text="Start" onClick={() => restartServer(server.id)} disabled={isLoading} /> {/* <IconButton icon={<DeleteIcon />} text="Remove" onClick={() => removeServer(server.id)} disabled={isLoading} /> */} </> ) : ( <> <IconButton icon={<EyeIcon />} text="Tools" onClick={async () => { setViewingServerId(server.id); await loadTools(server.id); }} disabled={ isLoading || checkServerStatus(server.id).status === "error" } /> <IconButton icon={<StopIcon />} text="Stop" onClick={() => pauseServer(server.id)} disabled={isLoading} /> </> )} </> ) : ( <IconButton icon={<AddIcon />} text="Add" onClick={() => addServer(server)} disabled={isLoading} /> )} </div> </div> </div> )); }; return ( <ErrorBoundary> <div className={styles["mcp-market-page"]}> <div className="window-header"> <div className="window-header-title"> <div className="window-header-main-title"> MCP Market {loadingStates["all"] && ( <span className={styles["loading-indicator"]}> {loadingStates["all"]} </span> )} </div> <div className="window-header-sub-title"> {Object.keys(config?.mcpServers ?? {}).length} servers configured </div> </div> <div className="window-actions"> <div className="window-action-button"> <IconButton icon={<RestartIcon />} bordered onClick={handleRestartAll} text="Restart All" disabled={isLoading} /> </div> <div className="window-action-button"> <IconButton icon={<CloseIcon />} bordered onClick={() => navigate(-1)} disabled={isLoading} /> </div> </div> </div> <div className={styles["mcp-market-page-body"]}> <div className={styles["mcp-market-filter"]}> <input type="text" className={styles["search-bar"]} placeholder={"Search MCP Server"} autoFocus onInput={(e) => setSearchText(e.currentTarget.value)} /> </div> <div className={styles["server-list"]}>{renderServerList()}</div> </div> {/*编辑服务器配置*/} {editingServerId && ( <div className="modal-mask"> <Modal title={`Configure Server - ${editingServerId}`} onClose={() => !isLoading && setEditingServerId(undefined)} actions={[ <IconButton key="cancel" text="Cancel" onClick={() => setEditingServerId(undefined)} bordered disabled={isLoading} />, <IconButton key="confirm" text="Save" type="primary" onClick={saveServerConfig} bordered disabled={isLoading} />, ]} > <List>{renderConfigForm()}</List> </Modal> </div> )} {viewingServerId && ( <div className="modal-mask"> <Modal title={`Server Details - ${viewingServerId}`} onClose={() => setViewingServerId(undefined)} actions={[ <IconButton key="close" text="Close" onClick={() => setViewingServerId(undefined)} bordered />, ]} > <div className={styles["tools-list"]}> {isLoading ? ( <div>Loading...</div> ) : tools?.tools ? ( tools.tools.map( (tool: ListToolsResponse["tools"], index: number) => ( <div key={index} className={styles["tool-item"]}> <div className={styles["tool-name"]}>{tool.name}</div> <div className={styles["tool-description"]}> {tool.description} </div> </div> ), ) ) : ( <div>No tools available</div> )} </div> </Modal> </div> )} </div> </ErrorBoundary> ); }