mcp-market.tsx 25 KB

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