Spaces:
Sleeping
Sleeping
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> | |
); | |
} | |