mcp-market.tsx 19 KB

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