mcp-market.tsx 20 KB

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