mcp-market.tsx 23 KB

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