mcp-market.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  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 { List, ListItem, Modal, showToast } from "./ui-lib";
  11. import { useNavigate } from "react-router-dom";
  12. import { useState, useEffect } from "react";
  13. import presetServersJson from "../mcp/preset-server.json";
  14. const presetServers = presetServersJson as PresetServer[];
  15. import {
  16. getMcpConfig,
  17. updateMcpConfig,
  18. getClientPrimitives,
  19. restartAllClients,
  20. reinitializeMcpClients,
  21. getClientErrors,
  22. } from "../mcp/actions";
  23. import { McpConfig, PresetServer, ServerConfig } from "../mcp/types";
  24. import clsx from "clsx";
  25. interface ConfigProperty {
  26. type: string;
  27. description?: string;
  28. required?: boolean;
  29. minItems?: number;
  30. }
  31. export function McpMarketPage() {
  32. const navigate = useNavigate();
  33. const [searchText, setSearchText] = useState("");
  34. const [config, setConfig] = useState<McpConfig>({ mcpServers: {} });
  35. const [editingServerId, setEditingServerId] = useState<string | undefined>();
  36. const [viewingServerId, setViewingServerId] = useState<string | undefined>();
  37. const [primitives, setPrimitives] = useState<any[]>([]);
  38. const [userConfig, setUserConfig] = useState<Record<string, any>>({});
  39. const [isLoading, setIsLoading] = useState(false);
  40. const [clientErrors, setClientErrors] = useState<
  41. Record<string, string | null>
  42. >({});
  43. // 更新服务器状态
  44. const updateServerStatus = async () => {
  45. await reinitializeMcpClients();
  46. const errors = await getClientErrors();
  47. setClientErrors(errors);
  48. };
  49. // 初始加载配置
  50. useEffect(() => {
  51. const init = async () => {
  52. try {
  53. setIsLoading(true);
  54. const data = await getMcpConfig();
  55. setConfig(data);
  56. await updateServerStatus();
  57. } catch (error) {
  58. showToast("Failed to load configuration");
  59. console.error(error);
  60. } finally {
  61. setIsLoading(false);
  62. }
  63. };
  64. init();
  65. }, []);
  66. // 保存配置
  67. const saveConfig = async (newConfig: McpConfig) => {
  68. try {
  69. setIsLoading(true);
  70. await updateMcpConfig(newConfig);
  71. setConfig(newConfig);
  72. await updateServerStatus();
  73. showToast("Configuration saved successfully");
  74. } catch (error) {
  75. showToast("Failed to save configuration");
  76. console.error(error);
  77. } finally {
  78. setIsLoading(false);
  79. }
  80. };
  81. // 检查服务器是否已添加
  82. const isServerAdded = (id: string) => {
  83. return id in config.mcpServers;
  84. };
  85. // 加载当前编辑服务器的配置
  86. useEffect(() => {
  87. if (editingServerId) {
  88. const currentConfig = config.mcpServers[editingServerId];
  89. if (currentConfig) {
  90. // 从当前配置中提取用户配置
  91. const preset = presetServers.find((s) => s.id === editingServerId);
  92. if (preset?.configSchema) {
  93. const userConfig: Record<string, any> = {};
  94. Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
  95. if (mapping.type === "spread") {
  96. // 对于 spread 类型,从 args 中提取数组
  97. const startPos = mapping.position ?? 0;
  98. userConfig[key] = currentConfig.args.slice(startPos);
  99. } else if (mapping.type === "single") {
  100. // 对于 single 类型,获取单个值
  101. userConfig[key] = currentConfig.args[mapping.position ?? 0];
  102. } else if (
  103. mapping.type === "env" &&
  104. mapping.key &&
  105. currentConfig.env
  106. ) {
  107. // 对于 env 类型,从环境变量中获取值
  108. userConfig[key] = currentConfig.env[mapping.key];
  109. }
  110. });
  111. setUserConfig(userConfig);
  112. }
  113. } else {
  114. setUserConfig({});
  115. }
  116. }
  117. }, [editingServerId, config.mcpServers]);
  118. // 保存服务器配置
  119. const saveServerConfig = async () => {
  120. const preset = presetServers.find((s) => s.id === editingServerId);
  121. if (!preset || !preset.configSchema || !editingServerId) return;
  122. try {
  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 = {
  151. ...config,
  152. mcpServers: {
  153. ...config.mcpServers,
  154. [editingServerId]: serverConfig,
  155. },
  156. };
  157. await saveConfig(newConfig);
  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. }
  165. };
  166. // 渲染配置表单
  167. const renderConfigForm = () => {
  168. const preset = presetServers.find((s) => s.id === editingServerId);
  169. if (!preset?.configSchema) return null;
  170. return Object.entries(preset.configSchema.properties).map(
  171. ([key, prop]: [string, ConfigProperty]) => {
  172. if (prop.type === "array") {
  173. const currentValue = userConfig[key as keyof typeof userConfig] || [];
  174. return (
  175. <ListItem key={key} title={key} subTitle={prop.description}>
  176. <div className={styles["path-list"]}>
  177. {(currentValue as string[]).map(
  178. (value: string, index: number) => (
  179. <div key={index} className={styles["path-item"]}>
  180. <input
  181. type="text"
  182. value={value}
  183. placeholder={`Path ${index + 1}`}
  184. onChange={(e) => {
  185. const newValue = [...currentValue] as string[];
  186. newValue[index] = e.target.value;
  187. setUserConfig({ ...userConfig, [key]: newValue });
  188. }}
  189. />
  190. <IconButton
  191. icon={<DeleteIcon />}
  192. className={styles["delete-button"]}
  193. onClick={() => {
  194. const newValue = [...currentValue] as string[];
  195. newValue.splice(index, 1);
  196. setUserConfig({ ...userConfig, [key]: newValue });
  197. }}
  198. />
  199. </div>
  200. ),
  201. )}
  202. <IconButton
  203. icon={<AddIcon />}
  204. text="Add Path"
  205. className={styles["add-button"]}
  206. bordered
  207. onClick={() => {
  208. const newValue = [...currentValue, ""] as string[];
  209. setUserConfig({ ...userConfig, [key]: newValue });
  210. }}
  211. />
  212. </div>
  213. </ListItem>
  214. );
  215. } else if (prop.type === "string") {
  216. const currentValue = userConfig[key as keyof typeof userConfig] || "";
  217. return (
  218. <ListItem key={key} title={key} subTitle={prop.description}>
  219. <div className={styles["input-item"]}>
  220. <input
  221. type="text"
  222. value={currentValue}
  223. placeholder={`Enter ${key}`}
  224. onChange={(e) => {
  225. setUserConfig({ ...userConfig, [key]: e.target.value });
  226. }}
  227. />
  228. </div>
  229. </ListItem>
  230. );
  231. }
  232. return null;
  233. },
  234. );
  235. };
  236. // 获取服务器的 Primitives
  237. const loadPrimitives = async (id: string) => {
  238. try {
  239. setIsLoading(true);
  240. const result = await getClientPrimitives(id);
  241. if (result) {
  242. setPrimitives(result);
  243. } else {
  244. showToast("Server is not running");
  245. setPrimitives([]);
  246. }
  247. } catch (error) {
  248. showToast("Failed to load primitives");
  249. console.error(error);
  250. setPrimitives([]);
  251. } finally {
  252. setIsLoading(false);
  253. }
  254. };
  255. // 重启所有客户端
  256. const handleRestart = async () => {
  257. try {
  258. setIsLoading(true);
  259. await restartAllClients();
  260. await updateServerStatus();
  261. showToast("All clients restarted successfully");
  262. } catch (error) {
  263. showToast("Failed to restart clients");
  264. console.error(error);
  265. } finally {
  266. setIsLoading(false);
  267. }
  268. };
  269. // 添加服务器
  270. const addServer = async (preset: PresetServer) => {
  271. if (!preset.configurable) {
  272. try {
  273. setIsLoading(true);
  274. showToast("Creating MCP client...");
  275. // 如果服务器不需要配置,直接添加
  276. const serverConfig: ServerConfig = {
  277. command: preset.command,
  278. args: [...preset.baseArgs],
  279. };
  280. const newConfig = {
  281. ...config,
  282. mcpServers: {
  283. ...config.mcpServers,
  284. [preset.id]: serverConfig,
  285. },
  286. };
  287. await saveConfig(newConfig);
  288. } finally {
  289. setIsLoading(false);
  290. }
  291. } else {
  292. // 如果需要配置,打开配置对话框
  293. setEditingServerId(preset.id);
  294. setUserConfig({});
  295. }
  296. };
  297. // 移除服务器
  298. const removeServer = async (id: string) => {
  299. try {
  300. setIsLoading(true);
  301. const { [id]: _, ...rest } = config.mcpServers;
  302. const newConfig = {
  303. ...config,
  304. mcpServers: rest,
  305. };
  306. await saveConfig(newConfig);
  307. } finally {
  308. setIsLoading(false);
  309. }
  310. };
  311. return (
  312. <ErrorBoundary>
  313. <div className={styles["mcp-market-page"]}>
  314. <div className="window-header">
  315. <div className="window-header-title">
  316. <div className="window-header-main-title">
  317. MCP Market
  318. {isLoading && (
  319. <span className={styles["loading-indicator"]}>Loading...</span>
  320. )}
  321. </div>
  322. <div className="window-header-sub-title">
  323. {Object.keys(config.mcpServers).length} servers configured
  324. </div>
  325. </div>
  326. <div className="window-actions">
  327. <div className="window-action-button">
  328. <IconButton
  329. icon={<RestartIcon />}
  330. bordered
  331. onClick={handleRestart}
  332. text="Restart"
  333. disabled={isLoading}
  334. />
  335. </div>
  336. <div className="window-action-button">
  337. <IconButton
  338. icon={<CloseIcon />}
  339. bordered
  340. onClick={() => navigate(-1)}
  341. disabled={isLoading}
  342. />
  343. </div>
  344. </div>
  345. </div>
  346. <div className={styles["mcp-market-page-body"]}>
  347. <div className={styles["mcp-market-filter"]}>
  348. <input
  349. type="text"
  350. className={styles["search-bar"]}
  351. placeholder={"Search MCP Server"}
  352. autoFocus
  353. onInput={(e) => setSearchText(e.currentTarget.value)}
  354. />
  355. </div>
  356. <div className={styles["server-list"]}>
  357. {presetServers
  358. .filter(
  359. (m) =>
  360. searchText.length === 0 ||
  361. m.name.toLowerCase().includes(searchText.toLowerCase()) ||
  362. m.description
  363. .toLowerCase()
  364. .includes(searchText.toLowerCase()),
  365. )
  366. .sort((a, b) => {
  367. const aAdded = isServerAdded(a.id);
  368. const bAdded = isServerAdded(b.id);
  369. const aError = clientErrors[a.id] !== null;
  370. const bError = clientErrors[b.id] !== null;
  371. if (aAdded !== bAdded) {
  372. return aAdded ? -1 : 1;
  373. }
  374. if (aAdded && bAdded) {
  375. if (aError !== bError) {
  376. return aError ? -1 : 1;
  377. }
  378. }
  379. return 0;
  380. })
  381. .map((server) => (
  382. <div
  383. className={clsx(styles["mcp-market-item"], {
  384. [styles["disabled"]]: isLoading,
  385. })}
  386. key={server.id}
  387. >
  388. <div className={styles["mcp-market-header"]}>
  389. <div className={styles["mcp-market-title"]}>
  390. <div className={styles["mcp-market-name"]}>
  391. {server.name}
  392. {isServerAdded(server.id) && (
  393. <span
  394. className={clsx(styles["server-status"], {
  395. [styles["error"]]:
  396. clientErrors[server.id] !== null,
  397. })}
  398. >
  399. {clientErrors[server.id] === null
  400. ? "Active"
  401. : "Error"}
  402. {clientErrors[server.id] && (
  403. <span className={styles["error-message"]}>
  404. : {clientErrors[server.id]}
  405. </span>
  406. )}
  407. </span>
  408. )}
  409. </div>
  410. <div
  411. className={clsx(styles["mcp-market-info"], "one-line")}
  412. >
  413. {server.description}
  414. </div>
  415. </div>
  416. </div>
  417. <div className={styles["mcp-market-actions"]}>
  418. {isServerAdded(server.id) ? (
  419. <>
  420. {server.configurable && (
  421. <IconButton
  422. icon={<EditIcon />}
  423. text="Configure"
  424. className={clsx({
  425. [styles["action-error"]]:
  426. clientErrors[server.id] !== null,
  427. })}
  428. onClick={() => setEditingServerId(server.id)}
  429. disabled={isLoading}
  430. />
  431. )}
  432. {isServerAdded(server.id) && (
  433. <IconButton
  434. icon={<EyeIcon />}
  435. text="Detail"
  436. onClick={async () => {
  437. if (clientErrors[server.id] !== null) {
  438. showToast("Server is not running");
  439. return;
  440. }
  441. setViewingServerId(server.id);
  442. await loadPrimitives(server.id);
  443. }}
  444. disabled={isLoading}
  445. />
  446. )}
  447. <IconButton
  448. icon={<DeleteIcon />}
  449. text="Remove"
  450. className={styles["action-danger"]}
  451. onClick={() => removeServer(server.id)}
  452. disabled={isLoading}
  453. />
  454. </>
  455. ) : (
  456. <IconButton
  457. icon={<AddIcon />}
  458. text="Add"
  459. className={styles["action-primary"]}
  460. onClick={() => addServer(server)}
  461. disabled={isLoading}
  462. />
  463. )}
  464. </div>
  465. </div>
  466. ))}
  467. </div>
  468. </div>
  469. {editingServerId && (
  470. <div className="modal-mask">
  471. <Modal
  472. title={`Configure Server - ${editingServerId}`}
  473. onClose={() => !isLoading && setEditingServerId(undefined)}
  474. actions={[
  475. <IconButton
  476. key="cancel"
  477. text="Cancel"
  478. onClick={() => setEditingServerId(undefined)}
  479. bordered
  480. disabled={isLoading}
  481. />,
  482. <IconButton
  483. key="confirm"
  484. text="Save"
  485. type="primary"
  486. onClick={saveServerConfig}
  487. bordered
  488. disabled={isLoading}
  489. />,
  490. ]}
  491. >
  492. <List>{renderConfigForm()}</List>
  493. </Modal>
  494. </div>
  495. )}
  496. {viewingServerId && (
  497. <div className="modal-mask">
  498. <Modal
  499. title={`Server Details - ${viewingServerId}`}
  500. onClose={() => setViewingServerId(undefined)}
  501. actions={[
  502. <IconButton
  503. key="close"
  504. text="Close"
  505. onClick={() => setViewingServerId(undefined)}
  506. bordered
  507. />,
  508. ]}
  509. >
  510. <div className={styles["primitives-list"]}>
  511. {isLoading ? (
  512. <div>Loading...</div>
  513. ) : primitives.filter((p) => p.type === "tool").length > 0 ? (
  514. primitives
  515. .filter((p) => p.type === "tool")
  516. .map((primitive, index) => (
  517. <div key={index} className={styles["primitive-item"]}>
  518. <div className={styles["primitive-name"]}>
  519. {primitive.value.name}
  520. </div>
  521. {primitive.value.description && (
  522. <div className={styles["primitive-description"]}>
  523. {primitive.value.description}
  524. </div>
  525. )}
  526. </div>
  527. ))
  528. ) : (
  529. <div>No tools available</div>
  530. )}
  531. </div>
  532. </Modal>
  533. </div>
  534. )}
  535. </div>
  536. </ErrorBoundary>
  537. );
  538. }