mcp-market.tsx 22 KB

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