mcp-market.tsx 20 KB

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