mcp-market.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. import { IconButton } from "./button";
  2. import { ErrorBoundary } from "./error";
  3. import styles from "./mcp-market.module.scss";
  4. import EditIcon from "../icons/edit.svg";
  5. import AddIcon from "../icons/add.svg";
  6. import CloseIcon from "../icons/close.svg";
  7. import DeleteIcon from "../icons/delete.svg";
  8. import RestartIcon from "../icons/reload.svg";
  9. import EyeIcon from "../icons/eye.svg";
  10. import GithubIcon from "../icons/github.svg";
  11. import { List, ListItem, Modal, showToast } from "./ui-lib";
  12. import { useNavigate } from "react-router-dom";
  13. import { useEffect, useState } from "react";
  14. import {
  15. addMcpServer,
  16. getClientStatus,
  17. getClientTools,
  18. getMcpConfigFromFile,
  19. restartAllClients,
  20. pauseMcpServer,
  21. resumeMcpServer,
  22. } from "../mcp/actions";
  23. import {
  24. ListToolsResponse,
  25. McpConfigData,
  26. PresetServer,
  27. ServerConfig,
  28. ServerStatusResponse,
  29. } from "../mcp/types";
  30. import clsx from "clsx";
  31. import PlayIcon from "../icons/play.svg";
  32. import StopIcon from "../icons/pause.svg";
  33. interface ConfigProperty {
  34. type: string;
  35. description?: string;
  36. required?: boolean;
  37. minItems?: number;
  38. }
  39. export function McpMarketPage() {
  40. const navigate = useNavigate();
  41. const [searchText, setSearchText] = useState("");
  42. const [userConfig, setUserConfig] = useState<Record<string, any>>({});
  43. const [editingServerId, setEditingServerId] = useState<string | undefined>();
  44. const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null);
  45. const [viewingServerId, setViewingServerId] = useState<string | undefined>();
  46. const [isLoading, setIsLoading] = useState(false);
  47. const [config, setConfig] = useState<McpConfigData>();
  48. const [clientStatuses, setClientStatuses] = useState<
  49. Record<string, ServerStatusResponse>
  50. >({});
  51. const [loadingPresets, setLoadingPresets] = useState(true);
  52. const [presetServers, setPresetServers] = useState<PresetServer[]>([]);
  53. useEffect(() => {
  54. const loadPresetServers = async () => {
  55. try {
  56. setLoadingPresets(true);
  57. const response = await fetch("https://nextchat.club/mcp/list");
  58. if (!response.ok) {
  59. throw new Error("Failed to load preset servers");
  60. }
  61. const data = await response.json();
  62. setPresetServers(data?.data ?? []);
  63. } catch (error) {
  64. console.error("Failed to load preset servers:", error);
  65. showToast("Failed to load preset servers");
  66. } finally {
  67. setLoadingPresets(false);
  68. }
  69. };
  70. loadPresetServers().then();
  71. }, []);
  72. // 检查服务器是否已添加
  73. const isServerAdded = (id: string) => {
  74. return id in (config?.mcpServers ?? {});
  75. };
  76. // 从服务器获取初始状态
  77. useEffect(() => {
  78. const loadInitialState = async () => {
  79. try {
  80. setIsLoading(true);
  81. const config = await getMcpConfigFromFile();
  82. setConfig(config);
  83. // 获取所有客户端的状态
  84. const statuses: Record<string, any> = {};
  85. for (const clientId of Object.keys(config.mcpServers)) {
  86. statuses[clientId] = await getClientStatus(clientId);
  87. }
  88. setClientStatuses(statuses);
  89. } catch (error) {
  90. console.error("Failed to load initial state:", error);
  91. showToast("Failed to load initial state");
  92. } finally {
  93. setIsLoading(false);
  94. }
  95. };
  96. loadInitialState();
  97. }, []);
  98. // Debug: 监控状态变化
  99. useEffect(() => {
  100. console.log("MCP Market - Current config:", config);
  101. console.log("MCP Market - Current clientStatuses:", clientStatuses);
  102. }, [config, clientStatuses]);
  103. // 加载当前编辑服务器的配置
  104. useEffect(() => {
  105. if (editingServerId && config) {
  106. const currentConfig = config.mcpServers[editingServerId];
  107. if (currentConfig) {
  108. // 从当前配置中提取用户配置
  109. const preset = presetServers.find((s) => s.id === editingServerId);
  110. if (preset?.configSchema) {
  111. const userConfig: Record<string, any> = {};
  112. Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
  113. if (mapping.type === "spread") {
  114. // 对于 spread 类型,从 args 中提取数组
  115. const startPos = mapping.position ?? 0;
  116. userConfig[key] = currentConfig.args.slice(startPos);
  117. } else if (mapping.type === "single") {
  118. // 对于 single 类型,获取单个值
  119. userConfig[key] = currentConfig.args[mapping.position ?? 0];
  120. } else if (
  121. mapping.type === "env" &&
  122. mapping.key &&
  123. currentConfig.env
  124. ) {
  125. // 对于 env 类型,从环境变量中获取值
  126. userConfig[key] = currentConfig.env[mapping.key];
  127. }
  128. });
  129. setUserConfig(userConfig);
  130. }
  131. } else {
  132. setUserConfig({});
  133. }
  134. }
  135. }, [editingServerId, config]);
  136. // 保存服务器配置
  137. const saveServerConfig = async () => {
  138. const preset = presetServers.find((s) => s.id === editingServerId);
  139. if (!preset || !preset.configSchema || !editingServerId) return;
  140. try {
  141. setIsLoading(true);
  142. // 构建服务器配置
  143. const args = [...preset.baseArgs];
  144. const env: Record<string, string> = {};
  145. Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
  146. const value = userConfig[key];
  147. if (mapping.type === "spread" && Array.isArray(value)) {
  148. const pos = mapping.position ?? 0;
  149. args.splice(pos, 0, ...value);
  150. } else if (
  151. mapping.type === "single" &&
  152. mapping.position !== undefined
  153. ) {
  154. args[mapping.position] = value;
  155. } else if (
  156. mapping.type === "env" &&
  157. mapping.key &&
  158. typeof value === "string"
  159. ) {
  160. env[mapping.key] = value;
  161. }
  162. });
  163. const serverConfig: ServerConfig = {
  164. command: preset.command,
  165. args,
  166. ...(Object.keys(env).length > 0 ? { env } : {}),
  167. };
  168. // 更新配置并初始化新服务器
  169. const newConfig = await addMcpServer(editingServerId, serverConfig);
  170. setConfig(newConfig);
  171. // 更新状态
  172. const status = await getClientStatus(editingServerId);
  173. setClientStatuses((prev) => ({
  174. ...prev,
  175. [editingServerId]: status,
  176. }));
  177. setEditingServerId(undefined);
  178. showToast("Server configuration saved successfully");
  179. } catch (error) {
  180. showToast(
  181. error instanceof Error ? error.message : "Failed to save configuration",
  182. );
  183. } finally {
  184. setIsLoading(false);
  185. }
  186. };
  187. // 获取服务器支持的 Tools
  188. const loadTools = async (id: string) => {
  189. try {
  190. const result = await getClientTools(id);
  191. if (result) {
  192. setTools(result);
  193. } else {
  194. throw new Error("Failed to load tools");
  195. }
  196. } catch (error) {
  197. showToast("Failed to load tools");
  198. console.error(error);
  199. setTools(null);
  200. }
  201. };
  202. // 重启所有客户端
  203. const handleRestartAll = async () => {
  204. try {
  205. setIsLoading(true);
  206. const newConfig = await restartAllClients();
  207. setConfig(newConfig);
  208. // 更新所有客户端状态
  209. const statuses: Record<string, any> = {};
  210. for (const clientId of Object.keys(newConfig.mcpServers)) {
  211. statuses[clientId] = await getClientStatus(clientId);
  212. }
  213. setClientStatuses(statuses);
  214. showToast("Successfully restarted all clients");
  215. } catch (error) {
  216. showToast("Failed to restart clients");
  217. console.error(error);
  218. } finally {
  219. setIsLoading(false);
  220. }
  221. };
  222. // 添加服务器
  223. const addServer = async (preset: PresetServer) => {
  224. if (!preset.configurable) {
  225. try {
  226. setIsLoading(true);
  227. showToast("Creating MCP client...");
  228. // 如果服务器不需要配置,直接添加
  229. const serverConfig: ServerConfig = {
  230. command: preset.command,
  231. args: [...preset.baseArgs],
  232. };
  233. const newConfig = await addMcpServer(preset.id, serverConfig);
  234. setConfig(newConfig);
  235. // 更新状态
  236. const status = await getClientStatus(preset.id);
  237. setClientStatuses((prev) => ({
  238. ...prev,
  239. [preset.id]: status,
  240. }));
  241. } finally {
  242. setIsLoading(false);
  243. }
  244. } else {
  245. // 如果需要配置,打开配置对话框
  246. setEditingServerId(preset.id);
  247. setUserConfig({});
  248. }
  249. };
  250. // 移除服务器
  251. // const removeServer = async (id: string) => {
  252. // try {
  253. // setIsLoading(true);
  254. // const newConfig = await removeMcpServer(id);
  255. // setConfig(newConfig);
  256. // // 移除状态
  257. // setClientStatuses((prev) => {
  258. // const newStatuses = { ...prev };
  259. // delete newStatuses[id];
  260. // return newStatuses;
  261. // });
  262. // } finally {
  263. // setIsLoading(false);
  264. // }
  265. // };
  266. // 暂停服务器
  267. const pauseServer = async (id: string) => {
  268. try {
  269. setIsLoading(true);
  270. showToast("Stopping server...");
  271. const newConfig = await pauseMcpServer(id);
  272. setConfig(newConfig);
  273. // 更新状态为暂停
  274. setClientStatuses((prev) => ({
  275. ...prev,
  276. [id]: { status: "paused", errorMsg: null },
  277. }));
  278. showToast("Server stopped successfully");
  279. } catch (error) {
  280. showToast("Failed to stop server");
  281. console.error(error);
  282. } finally {
  283. setIsLoading(false);
  284. }
  285. };
  286. // 恢复服务器
  287. const resumeServer = async (id: string) => {
  288. try {
  289. setIsLoading(true);
  290. showToast("Starting server...");
  291. // 尝试启动服务器
  292. const success = await resumeMcpServer(id);
  293. // 获取最新状态(这个状态是从 clientsMap 中获取的,反映真实状态)
  294. const status = await getClientStatus(id);
  295. setClientStatuses((prev) => ({
  296. ...prev,
  297. [id]: status,
  298. }));
  299. // 根据启动结果显示消息
  300. if (success) {
  301. showToast("Server started successfully");
  302. } else {
  303. throw new Error("Failed to start server");
  304. }
  305. } catch (error) {
  306. showToast(
  307. error instanceof Error
  308. ? error.message
  309. : "Failed to start server, please check logs",
  310. );
  311. console.error(error);
  312. } finally {
  313. setIsLoading(false);
  314. }
  315. };
  316. // 渲染配置表单
  317. const renderConfigForm = () => {
  318. const preset = presetServers.find((s) => s.id === editingServerId);
  319. if (!preset?.configSchema) return null;
  320. return Object.entries(preset.configSchema.properties).map(
  321. ([key, prop]: [string, ConfigProperty]) => {
  322. if (prop.type === "array") {
  323. const currentValue = userConfig[key as keyof typeof userConfig] || [];
  324. const itemLabel = (prop as any).itemLabel || key;
  325. const addButtonText =
  326. (prop as any).addButtonText || `Add ${itemLabel}`;
  327. return (
  328. <ListItem
  329. key={key}
  330. title={key}
  331. subTitle={prop.description}
  332. vertical
  333. >
  334. <div className={styles["path-list"]}>
  335. {(currentValue as string[]).map(
  336. (value: string, index: number) => (
  337. <div key={index} className={styles["path-item"]}>
  338. <input
  339. type="text"
  340. value={value}
  341. placeholder={`${itemLabel} ${index + 1}`}
  342. onChange={(e) => {
  343. const newValue = [...currentValue] as string[];
  344. newValue[index] = e.target.value;
  345. setUserConfig({ ...userConfig, [key]: newValue });
  346. }}
  347. />
  348. <IconButton
  349. icon={<DeleteIcon />}
  350. className={styles["delete-button"]}
  351. onClick={() => {
  352. const newValue = [...currentValue] as string[];
  353. newValue.splice(index, 1);
  354. setUserConfig({ ...userConfig, [key]: newValue });
  355. }}
  356. />
  357. </div>
  358. ),
  359. )}
  360. <IconButton
  361. icon={<AddIcon />}
  362. text={addButtonText}
  363. className={styles["add-button"]}
  364. bordered
  365. onClick={() => {
  366. const newValue = [...currentValue, ""] as string[];
  367. setUserConfig({ ...userConfig, [key]: newValue });
  368. }}
  369. />
  370. </div>
  371. </ListItem>
  372. );
  373. } else if (prop.type === "string") {
  374. const currentValue = userConfig[key as keyof typeof userConfig] || "";
  375. return (
  376. <ListItem
  377. key={key}
  378. title={key}
  379. subTitle={prop.description}
  380. vertical
  381. >
  382. <div className={styles["input-item"]}>
  383. <input
  384. type="text"
  385. value={currentValue}
  386. placeholder={`Enter ${key}`}
  387. onChange={(e) => {
  388. setUserConfig({ ...userConfig, [key]: e.target.value });
  389. }}
  390. />
  391. </div>
  392. </ListItem>
  393. );
  394. }
  395. return null;
  396. },
  397. );
  398. };
  399. // 检查服务器状态
  400. const checkServerStatus = (clientId: string) => {
  401. return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
  402. };
  403. // 修改状态显示逻辑
  404. const getServerStatusDisplay = (clientId: string) => {
  405. const status = checkServerStatus(clientId);
  406. const statusMap = {
  407. undefined: null, // 未配置/未找到不显示
  408. paused: (
  409. <span className={clsx(styles["server-status"], styles["stopped"])}>
  410. Stopped
  411. </span>
  412. ),
  413. active: <span className={styles["server-status"]}>Running</span>,
  414. error: (
  415. <span className={clsx(styles["server-status"], styles["error"])}>
  416. Error
  417. <span className={styles["error-message"]}>: {status.errorMsg}</span>
  418. </span>
  419. ),
  420. };
  421. return statusMap[status.status];
  422. };
  423. // 渲染服务器列表
  424. const renderServerList = () => {
  425. if (loadingPresets) {
  426. return (
  427. <div className={styles["loading-container"]}>
  428. <div className={styles["loading-text"]}>
  429. Loading preset server list...
  430. </div>
  431. </div>
  432. );
  433. }
  434. if (!Array.isArray(presetServers) || presetServers.length === 0) {
  435. return (
  436. <div className={styles["empty-container"]}>
  437. <div className={styles["empty-text"]}>No servers available</div>
  438. </div>
  439. );
  440. }
  441. return presetServers
  442. .filter((server) => {
  443. if (searchText.length === 0) return true;
  444. const searchLower = searchText.toLowerCase();
  445. return (
  446. server.name.toLowerCase().includes(searchLower) ||
  447. server.description.toLowerCase().includes(searchLower) ||
  448. server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
  449. );
  450. })
  451. .sort((a, b) => {
  452. const aStatus = checkServerStatus(a.id).status;
  453. const bStatus = checkServerStatus(b.id).status;
  454. // 定义状态优先级
  455. const statusPriority: Record<string, number> = {
  456. error: 0, // 最高优先级
  457. active: 1, // 运行中
  458. paused: 2, // 已暂停
  459. undefined: 3, // 未配置/未找到
  460. };
  461. // 首先按状态排序
  462. if (aStatus !== bStatus) {
  463. return (
  464. (statusPriority[aStatus] || 3) - (statusPriority[bStatus] || 3)
  465. );
  466. }
  467. // 然后按名称排序
  468. return a.name.localeCompare(b.name);
  469. })
  470. .map((server) => (
  471. <div
  472. className={clsx(styles["mcp-market-item"], {
  473. [styles["disabled"]]: isLoading,
  474. })}
  475. key={server.id}
  476. >
  477. <div className={styles["mcp-market-header"]}>
  478. <div className={styles["mcp-market-title"]}>
  479. <div className={styles["mcp-market-name"]}>
  480. {server.name}
  481. {getServerStatusDisplay(server.id)}
  482. {server.repo && (
  483. <a
  484. href={server.repo}
  485. target="_blank"
  486. rel="noopener noreferrer"
  487. className={styles["repo-link"]}
  488. title="Open repository"
  489. >
  490. <GithubIcon />
  491. </a>
  492. )}
  493. </div>
  494. <div className={styles["tags-container"]}>
  495. {server.tags.map((tag, index) => (
  496. <span key={index} className={styles["tag"]}>
  497. {tag}
  498. </span>
  499. ))}
  500. </div>
  501. <div
  502. className={clsx(styles["mcp-market-info"], "one-line")}
  503. title={server.description}
  504. >
  505. {server.description}
  506. </div>
  507. </div>
  508. <div className={styles["mcp-market-actions"]}>
  509. {isServerAdded(server.id) ? (
  510. <>
  511. {server.configurable && (
  512. <IconButton
  513. icon={<EditIcon />}
  514. text="Configure"
  515. onClick={() => setEditingServerId(server.id)}
  516. disabled={isLoading}
  517. />
  518. )}
  519. {checkServerStatus(server.id).status === "paused" ? (
  520. <>
  521. <IconButton
  522. icon={<PlayIcon />}
  523. text="Start"
  524. onClick={() => resumeServer(server.id)}
  525. disabled={isLoading}
  526. />
  527. {/* <IconButton
  528. icon={<DeleteIcon />}
  529. text="Remove"
  530. onClick={() => removeServer(server.id)}
  531. disabled={isLoading}
  532. /> */}
  533. </>
  534. ) : (
  535. <>
  536. <IconButton
  537. icon={<EyeIcon />}
  538. text="Tools"
  539. onClick={async () => {
  540. setViewingServerId(server.id);
  541. await loadTools(server.id);
  542. }}
  543. disabled={
  544. isLoading ||
  545. checkServerStatus(server.id).status === "error"
  546. }
  547. />
  548. <IconButton
  549. icon={<StopIcon />}
  550. text="Stop"
  551. onClick={() => pauseServer(server.id)}
  552. disabled={isLoading}
  553. />
  554. </>
  555. )}
  556. </>
  557. ) : (
  558. <IconButton
  559. icon={<AddIcon />}
  560. text="Add"
  561. onClick={() => addServer(server)}
  562. disabled={isLoading}
  563. />
  564. )}
  565. </div>
  566. </div>
  567. </div>
  568. ));
  569. };
  570. return (
  571. <ErrorBoundary>
  572. <div className={styles["mcp-market-page"]}>
  573. <div className="window-header">
  574. <div className="window-header-title">
  575. <div className="window-header-main-title">
  576. MCP Market
  577. {isLoading && (
  578. <span className={styles["loading-indicator"]}>Loading...</span>
  579. )}
  580. </div>
  581. <div className="window-header-sub-title">
  582. {Object.keys(config?.mcpServers ?? {}).length} servers configured
  583. </div>
  584. </div>
  585. <div className="window-actions">
  586. <div className="window-action-button">
  587. <IconButton
  588. icon={<RestartIcon />}
  589. bordered
  590. onClick={handleRestartAll}
  591. text="Restart All"
  592. disabled={isLoading}
  593. />
  594. </div>
  595. <div className="window-action-button">
  596. <IconButton
  597. icon={<CloseIcon />}
  598. bordered
  599. onClick={() => navigate(-1)}
  600. disabled={isLoading}
  601. />
  602. </div>
  603. </div>
  604. </div>
  605. <div className={styles["mcp-market-page-body"]}>
  606. <div className={styles["mcp-market-filter"]}>
  607. <input
  608. type="text"
  609. className={styles["search-bar"]}
  610. placeholder={"Search MCP Server"}
  611. autoFocus
  612. onInput={(e) => setSearchText(e.currentTarget.value)}
  613. />
  614. </div>
  615. <div className={styles["server-list"]}>{renderServerList()}</div>
  616. </div>
  617. {/*编辑服务器配置*/}
  618. {editingServerId && (
  619. <div className="modal-mask">
  620. <Modal
  621. title={`Configure Server - ${editingServerId}`}
  622. onClose={() => !isLoading && setEditingServerId(undefined)}
  623. actions={[
  624. <IconButton
  625. key="cancel"
  626. text="Cancel"
  627. onClick={() => setEditingServerId(undefined)}
  628. bordered
  629. disabled={isLoading}
  630. />,
  631. <IconButton
  632. key="confirm"
  633. text="Save"
  634. type="primary"
  635. onClick={saveServerConfig}
  636. bordered
  637. disabled={isLoading}
  638. />,
  639. ]}
  640. >
  641. <List>{renderConfigForm()}</List>
  642. </Modal>
  643. </div>
  644. )}
  645. {/*支持的Tools*/}
  646. {viewingServerId && (
  647. <div className="modal-mask">
  648. <Modal
  649. title={`Server Details - ${viewingServerId}`}
  650. onClose={() => setViewingServerId(undefined)}
  651. actions={[
  652. <IconButton
  653. key="close"
  654. text="Close"
  655. onClick={() => setViewingServerId(undefined)}
  656. bordered
  657. />,
  658. ]}
  659. >
  660. <div className={styles["tools-list"]}>
  661. {isLoading ? (
  662. <div>Loading...</div>
  663. ) : tools?.tools ? (
  664. tools.tools.map(
  665. (tool: ListToolsResponse["tools"], index: number) => (
  666. <div key={index} className={styles["tool-item"]}>
  667. <div className={styles["tool-name"]}>{tool.name}</div>
  668. <div className={styles["tool-description"]}>
  669. {tool.description}
  670. </div>
  671. </div>
  672. ),
  673. )
  674. ) : (
  675. <div>No tools available</div>
  676. )}
  677. </div>
  678. </Modal>
  679. </div>
  680. )}
  681. </div>
  682. </ErrorBoundary>
  683. );
  684. }