mcp-market.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  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, presetServers]);
  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 key={key} title={key} subTitle={prop.description}>
  377. <input
  378. aria-label={key}
  379. type="text"
  380. value={currentValue}
  381. placeholder={`Enter ${key}`}
  382. onChange={(e) => {
  383. setUserConfig({ ...userConfig, [key]: e.target.value });
  384. }}
  385. />
  386. </ListItem>
  387. );
  388. }
  389. return null;
  390. },
  391. );
  392. };
  393. // 检查服务器状态
  394. const checkServerStatus = (clientId: string) => {
  395. return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
  396. };
  397. // 修改状态显示逻辑
  398. const getServerStatusDisplay = (clientId: string) => {
  399. const status = checkServerStatus(clientId);
  400. const statusMap = {
  401. undefined: null, // 未配置/未找到不显示
  402. paused: (
  403. <span className={clsx(styles["server-status"], styles["stopped"])}>
  404. Stopped
  405. </span>
  406. ),
  407. active: <span className={styles["server-status"]}>Running</span>,
  408. error: (
  409. <span className={clsx(styles["server-status"], styles["error"])}>
  410. Error
  411. <span className={styles["error-message"]}>: {status.errorMsg}</span>
  412. </span>
  413. ),
  414. };
  415. return statusMap[status.status];
  416. };
  417. // 渲染服务器列表
  418. const renderServerList = () => {
  419. if (loadingPresets) {
  420. return (
  421. <div className={styles["loading-container"]}>
  422. <div className={styles["loading-text"]}>
  423. Loading preset server list...
  424. </div>
  425. </div>
  426. );
  427. }
  428. if (!Array.isArray(presetServers) || presetServers.length === 0) {
  429. return (
  430. <div className={styles["empty-container"]}>
  431. <div className={styles["empty-text"]}>No servers available</div>
  432. </div>
  433. );
  434. }
  435. return presetServers
  436. .filter((server) => {
  437. if (searchText.length === 0) return true;
  438. const searchLower = searchText.toLowerCase();
  439. return (
  440. server.name.toLowerCase().includes(searchLower) ||
  441. server.description.toLowerCase().includes(searchLower) ||
  442. server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
  443. );
  444. })
  445. .sort((a, b) => {
  446. const aStatus = checkServerStatus(a.id).status;
  447. const bStatus = checkServerStatus(b.id).status;
  448. // 定义状态优先级
  449. const statusPriority: Record<string, number> = {
  450. error: 0, // 最高优先级
  451. active: 1, // 运行中
  452. paused: 2, // 已暂停
  453. undefined: 3, // 未配置/未找到
  454. };
  455. // 首先按状态排序
  456. if (aStatus !== bStatus) {
  457. return (
  458. (statusPriority[aStatus] || 3) - (statusPriority[bStatus] || 3)
  459. );
  460. }
  461. // 然后按名称排序
  462. return a.name.localeCompare(b.name);
  463. })
  464. .map((server) => (
  465. <div
  466. className={clsx(styles["mcp-market-item"], {
  467. [styles["disabled"]]: isLoading,
  468. })}
  469. key={server.id}
  470. >
  471. <div className={styles["mcp-market-header"]}>
  472. <div className={styles["mcp-market-title"]}>
  473. <div className={styles["mcp-market-name"]}>
  474. {server.name}
  475. {getServerStatusDisplay(server.id)}
  476. {server.repo && (
  477. <a
  478. href={server.repo}
  479. target="_blank"
  480. rel="noopener noreferrer"
  481. className={styles["repo-link"]}
  482. title="Open repository"
  483. >
  484. <GithubIcon />
  485. </a>
  486. )}
  487. </div>
  488. <div className={styles["tags-container"]}>
  489. {server.tags.map((tag, index) => (
  490. <span key={index} className={styles["tag"]}>
  491. {tag}
  492. </span>
  493. ))}
  494. </div>
  495. <div
  496. className={clsx(styles["mcp-market-info"], "one-line")}
  497. title={server.description}
  498. >
  499. {server.description}
  500. </div>
  501. </div>
  502. <div className={styles["mcp-market-actions"]}>
  503. {isServerAdded(server.id) ? (
  504. <>
  505. {server.configurable && (
  506. <IconButton
  507. icon={<EditIcon />}
  508. text="Configure"
  509. onClick={() => setEditingServerId(server.id)}
  510. disabled={isLoading}
  511. />
  512. )}
  513. {checkServerStatus(server.id).status === "paused" ? (
  514. <>
  515. <IconButton
  516. icon={<PlayIcon />}
  517. text="Start"
  518. onClick={() => resumeServer(server.id)}
  519. disabled={isLoading}
  520. />
  521. {/* <IconButton
  522. icon={<DeleteIcon />}
  523. text="Remove"
  524. onClick={() => removeServer(server.id)}
  525. disabled={isLoading}
  526. /> */}
  527. </>
  528. ) : (
  529. <>
  530. <IconButton
  531. icon={<EyeIcon />}
  532. text="Tools"
  533. onClick={async () => {
  534. setViewingServerId(server.id);
  535. await loadTools(server.id);
  536. }}
  537. disabled={
  538. isLoading ||
  539. checkServerStatus(server.id).status === "error"
  540. }
  541. />
  542. <IconButton
  543. icon={<StopIcon />}
  544. text="Stop"
  545. onClick={() => pauseServer(server.id)}
  546. disabled={isLoading}
  547. />
  548. </>
  549. )}
  550. </>
  551. ) : (
  552. <IconButton
  553. icon={<AddIcon />}
  554. text="Add"
  555. onClick={() => addServer(server)}
  556. disabled={isLoading}
  557. />
  558. )}
  559. </div>
  560. </div>
  561. </div>
  562. ));
  563. };
  564. return (
  565. <ErrorBoundary>
  566. <div className={styles["mcp-market-page"]}>
  567. <div className="window-header">
  568. <div className="window-header-title">
  569. <div className="window-header-main-title">
  570. MCP Market
  571. {isLoading && (
  572. <span className={styles["loading-indicator"]}>Loading...</span>
  573. )}
  574. </div>
  575. <div className="window-header-sub-title">
  576. {Object.keys(config?.mcpServers ?? {}).length} servers configured
  577. </div>
  578. </div>
  579. <div className="window-actions">
  580. <div className="window-action-button">
  581. <IconButton
  582. icon={<RestartIcon />}
  583. bordered
  584. onClick={handleRestartAll}
  585. text="Restart All"
  586. disabled={isLoading}
  587. />
  588. </div>
  589. <div className="window-action-button">
  590. <IconButton
  591. icon={<CloseIcon />}
  592. bordered
  593. onClick={() => navigate(-1)}
  594. disabled={isLoading}
  595. />
  596. </div>
  597. </div>
  598. </div>
  599. <div className={styles["mcp-market-page-body"]}>
  600. <div className={styles["mcp-market-filter"]}>
  601. <input
  602. type="text"
  603. className={styles["search-bar"]}
  604. placeholder={"Search MCP Server"}
  605. autoFocus
  606. onInput={(e) => setSearchText(e.currentTarget.value)}
  607. />
  608. </div>
  609. <div className={styles["server-list"]}>{renderServerList()}</div>
  610. </div>
  611. {/*编辑服务器配置*/}
  612. {editingServerId && (
  613. <div className="modal-mask">
  614. <Modal
  615. title={`Configure Server - ${editingServerId}`}
  616. onClose={() => !isLoading && setEditingServerId(undefined)}
  617. actions={[
  618. <IconButton
  619. key="cancel"
  620. text="Cancel"
  621. onClick={() => setEditingServerId(undefined)}
  622. bordered
  623. disabled={isLoading}
  624. />,
  625. <IconButton
  626. key="confirm"
  627. text="Save"
  628. type="primary"
  629. onClick={saveServerConfig}
  630. bordered
  631. disabled={isLoading}
  632. />,
  633. ]}
  634. >
  635. <List>{renderConfigForm()}</List>
  636. </Modal>
  637. </div>
  638. )}
  639. {/*支持的Tools*/}
  640. {viewingServerId && (
  641. <div className="modal-mask">
  642. <Modal
  643. title={`Server Details - ${viewingServerId}`}
  644. onClose={() => setViewingServerId(undefined)}
  645. actions={[
  646. <IconButton
  647. key="close"
  648. text="Close"
  649. onClick={() => setViewingServerId(undefined)}
  650. bordered
  651. />,
  652. ]}
  653. >
  654. <div className={styles["tools-list"]}>
  655. {isLoading ? (
  656. <div>Loading...</div>
  657. ) : tools?.tools ? (
  658. tools.tools.map(
  659. (tool: ListToolsResponse["tools"], index: number) => (
  660. <div key={index} className={styles["tool-item"]}>
  661. <div className={styles["tool-name"]}>{tool.name}</div>
  662. <div className={styles["tool-description"]}>
  663. {tool.description}
  664. </div>
  665. </div>
  666. ),
  667. )
  668. ) : (
  669. <div>No tools available</div>
  670. )}
  671. </div>
  672. </Modal>
  673. </div>
  674. )}
  675. </div>
  676. </ErrorBoundary>
  677. );
  678. }