756 lines
24 KiB
TypeScript
756 lines
24 KiB
TypeScript
import { IconButton } from "./button";
|
|
import { ErrorBoundary } from "./error";
|
|
import styles from "./mcp-market.module.scss";
|
|
import EditIcon from "../icons/edit.svg";
|
|
import AddIcon from "../icons/add.svg";
|
|
import CloseIcon from "../icons/close.svg";
|
|
import DeleteIcon from "../icons/delete.svg";
|
|
import RestartIcon from "../icons/reload.svg";
|
|
import EyeIcon from "../icons/eye.svg";
|
|
import GithubIcon from "../icons/github.svg";
|
|
import { List, ListItem, Modal, showToast } from "./ui-lib";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
addMcpServer,
|
|
getClientsStatus,
|
|
getClientTools,
|
|
getMcpConfigFromFile,
|
|
isMcpEnabled,
|
|
pauseMcpServer,
|
|
restartAllClients,
|
|
resumeMcpServer,
|
|
} from "../mcp/actions";
|
|
import {
|
|
ListToolsResponse,
|
|
McpConfigData,
|
|
PresetServer,
|
|
ServerConfig,
|
|
ServerStatusResponse,
|
|
} from "../mcp/types";
|
|
import clsx from "clsx";
|
|
import PlayIcon from "../icons/play.svg";
|
|
import StopIcon from "../icons/pause.svg";
|
|
import { Path } from "../constant";
|
|
|
|
interface ConfigProperty {
|
|
type: string;
|
|
description?: string;
|
|
required?: boolean;
|
|
minItems?: number;
|
|
}
|
|
|
|
export function McpMarketPage() {
|
|
const navigate = useNavigate();
|
|
const [mcpEnabled, setMcpEnabled] = useState(false);
|
|
const [searchText, setSearchText] = useState("");
|
|
const [userConfig, setUserConfig] = useState<Record<string, any>>({});
|
|
const [editingServerId, setEditingServerId] = useState<string | undefined>();
|
|
const [tools, setTools] = useState<ListToolsResponse["tools"] | null>(null);
|
|
const [viewingServerId, setViewingServerId] = useState<string | undefined>();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [config, setConfig] = useState<McpConfigData>();
|
|
const [clientStatuses, setClientStatuses] = useState<
|
|
Record<string, ServerStatusResponse>
|
|
>({});
|
|
const [loadingPresets, setLoadingPresets] = useState(true);
|
|
const [presetServers, setPresetServers] = useState<PresetServer[]>([]);
|
|
const [loadingStates, setLoadingStates] = useState<Record<string, string>>(
|
|
{},
|
|
);
|
|
|
|
// 检查 MCP 是否启用
|
|
useEffect(() => {
|
|
const checkMcpStatus = async () => {
|
|
const enabled = await isMcpEnabled();
|
|
setMcpEnabled(enabled);
|
|
if (!enabled) {
|
|
navigate(Path.Home);
|
|
}
|
|
};
|
|
checkMcpStatus();
|
|
}, [navigate]);
|
|
|
|
// 添加状态轮询
|
|
useEffect(() => {
|
|
if (!mcpEnabled || !config) return;
|
|
|
|
const updateStatuses = async () => {
|
|
const statuses = await getClientsStatus();
|
|
setClientStatuses(statuses);
|
|
};
|
|
|
|
// 立即执行一次
|
|
updateStatuses();
|
|
// 每 1000ms 轮询一次
|
|
const timer = setInterval(updateStatuses, 1000);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [mcpEnabled, config]);
|
|
|
|
// 加载预设服务器
|
|
useEffect(() => {
|
|
const loadPresetServers = async () => {
|
|
if (!mcpEnabled) return;
|
|
try {
|
|
setLoadingPresets(true);
|
|
const response = await fetch("https://nextchat.club/mcp/list");
|
|
if (!response.ok) {
|
|
throw new Error("Failed to load preset servers");
|
|
}
|
|
const data = await response.json();
|
|
setPresetServers(data?.data ?? []);
|
|
} catch (error) {
|
|
console.error("Failed to load preset servers:", error);
|
|
showToast("Failed to load preset servers");
|
|
} finally {
|
|
setLoadingPresets(false);
|
|
}
|
|
};
|
|
loadPresetServers();
|
|
}, [mcpEnabled]);
|
|
|
|
// 加载初始状态
|
|
useEffect(() => {
|
|
const loadInitialState = async () => {
|
|
if (!mcpEnabled) return;
|
|
try {
|
|
setIsLoading(true);
|
|
const config = await getMcpConfigFromFile();
|
|
setConfig(config);
|
|
|
|
// 获取所有客户端的状态
|
|
const statuses = await getClientsStatus();
|
|
setClientStatuses(statuses);
|
|
} catch (error) {
|
|
console.error("Failed to load initial state:", error);
|
|
showToast("Failed to load initial state");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
loadInitialState();
|
|
}, [mcpEnabled]);
|
|
|
|
// 加载当前编辑服务器的配置
|
|
useEffect(() => {
|
|
if (!editingServerId || !config) return;
|
|
const currentConfig = config.mcpServers[editingServerId];
|
|
if (currentConfig) {
|
|
// 从当前配置中提取用户配置
|
|
const preset = presetServers.find((s) => s.id === editingServerId);
|
|
if (preset?.configSchema) {
|
|
const userConfig: Record<string, any> = {};
|
|
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
|
if (mapping.type === "spread") {
|
|
// For spread types, extract the array from args.
|
|
const startPos = mapping.position ?? 0;
|
|
userConfig[key] = currentConfig.args.slice(startPos);
|
|
} else if (mapping.type === "single") {
|
|
// For single types, get a single value
|
|
userConfig[key] = currentConfig.args[mapping.position ?? 0];
|
|
} else if (
|
|
mapping.type === "env" &&
|
|
mapping.key &&
|
|
currentConfig.env
|
|
) {
|
|
// For env types, get values from environment variables
|
|
userConfig[key] = currentConfig.env[mapping.key];
|
|
}
|
|
});
|
|
setUserConfig(userConfig);
|
|
}
|
|
} else {
|
|
setUserConfig({});
|
|
}
|
|
}, [editingServerId, config, presetServers]);
|
|
|
|
if (!mcpEnabled) {
|
|
return null;
|
|
}
|
|
|
|
// 检查服务器是否已添加
|
|
const isServerAdded = (id: string) => {
|
|
return id in (config?.mcpServers ?? {});
|
|
};
|
|
|
|
// 保存服务器配置
|
|
const saveServerConfig = async () => {
|
|
const preset = presetServers.find((s) => s.id === editingServerId);
|
|
if (!preset || !preset.configSchema || !editingServerId) return;
|
|
|
|
const savingServerId = editingServerId;
|
|
setEditingServerId(undefined);
|
|
|
|
try {
|
|
updateLoadingState(savingServerId, "Updating configuration...");
|
|
// 构建服务器配置
|
|
const args = [...preset.baseArgs];
|
|
const env: Record<string, string> = {};
|
|
|
|
Object.entries(preset.argsMapping || {}).forEach(([key, mapping]) => {
|
|
const value = userConfig[key];
|
|
if (mapping.type === "spread" && Array.isArray(value)) {
|
|
const pos = mapping.position ?? 0;
|
|
args.splice(pos, 0, ...value);
|
|
} else if (
|
|
mapping.type === "single" &&
|
|
mapping.position !== undefined
|
|
) {
|
|
args[mapping.position] = value;
|
|
} else if (
|
|
mapping.type === "env" &&
|
|
mapping.key &&
|
|
typeof value === "string"
|
|
) {
|
|
env[mapping.key] = value;
|
|
}
|
|
});
|
|
|
|
const serverConfig: ServerConfig = {
|
|
command: preset.command,
|
|
args,
|
|
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
};
|
|
|
|
const newConfig = await addMcpServer(savingServerId, serverConfig);
|
|
setConfig(newConfig);
|
|
showToast("Server configuration updated successfully");
|
|
} catch (error) {
|
|
showToast(
|
|
error instanceof Error ? error.message : "Failed to save configuration",
|
|
);
|
|
} finally {
|
|
updateLoadingState(savingServerId, null);
|
|
}
|
|
};
|
|
|
|
// 获取服务器支持的 Tools
|
|
const loadTools = async (id: string) => {
|
|
try {
|
|
const result = await getClientTools(id);
|
|
if (result) {
|
|
setTools(result);
|
|
} else {
|
|
throw new Error("Failed to load tools");
|
|
}
|
|
} catch (error) {
|
|
showToast("Failed to load tools");
|
|
console.error(error);
|
|
setTools(null);
|
|
}
|
|
};
|
|
|
|
// 更新加载状态的辅助函数
|
|
const updateLoadingState = (id: string, message: string | null) => {
|
|
setLoadingStates((prev) => {
|
|
if (message === null) {
|
|
const { [id]: _, ...rest } = prev;
|
|
return rest;
|
|
}
|
|
return { ...prev, [id]: message };
|
|
});
|
|
};
|
|
|
|
// 修改添加服务器函数
|
|
const addServer = async (preset: PresetServer) => {
|
|
if (!preset.configurable) {
|
|
try {
|
|
const serverId = preset.id;
|
|
updateLoadingState(serverId, "Creating MCP client...");
|
|
|
|
const serverConfig: ServerConfig = {
|
|
command: preset.command,
|
|
args: [...preset.baseArgs],
|
|
};
|
|
const newConfig = await addMcpServer(preset.id, serverConfig);
|
|
setConfig(newConfig);
|
|
|
|
// 更新状态
|
|
const statuses = await getClientsStatus();
|
|
setClientStatuses(statuses);
|
|
} finally {
|
|
updateLoadingState(preset.id, null);
|
|
}
|
|
} else {
|
|
// 如果需要配置,打开配置对话框
|
|
setEditingServerId(preset.id);
|
|
setUserConfig({});
|
|
}
|
|
};
|
|
|
|
// 修改暂停服务器函数
|
|
const pauseServer = async (id: string) => {
|
|
try {
|
|
updateLoadingState(id, "Stopping server...");
|
|
const newConfig = await pauseMcpServer(id);
|
|
setConfig(newConfig);
|
|
showToast("Server stopped successfully");
|
|
} catch (error) {
|
|
showToast("Failed to stop server");
|
|
console.error(error);
|
|
} finally {
|
|
updateLoadingState(id, null);
|
|
}
|
|
};
|
|
|
|
// Restart server
|
|
const restartServer = async (id: string) => {
|
|
try {
|
|
updateLoadingState(id, "Starting server...");
|
|
await resumeMcpServer(id);
|
|
} catch (error) {
|
|
showToast(
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to start server, please check logs",
|
|
);
|
|
console.error(error);
|
|
} finally {
|
|
updateLoadingState(id, null);
|
|
}
|
|
};
|
|
|
|
// Restart all clients
|
|
const handleRestartAll = async () => {
|
|
try {
|
|
updateLoadingState("all", "Restarting all servers...");
|
|
const newConfig = await restartAllClients();
|
|
setConfig(newConfig);
|
|
showToast("Restarting all clients");
|
|
} catch (error) {
|
|
showToast("Failed to restart clients");
|
|
console.error(error);
|
|
} finally {
|
|
updateLoadingState("all", null);
|
|
}
|
|
};
|
|
|
|
// Render configuration form
|
|
const renderConfigForm = () => {
|
|
const preset = presetServers.find((s) => s.id === editingServerId);
|
|
if (!preset?.configSchema) return null;
|
|
|
|
return Object.entries(preset.configSchema.properties).map(
|
|
([key, prop]: [string, ConfigProperty]) => {
|
|
if (prop.type === "array") {
|
|
const currentValue = userConfig[key as keyof typeof userConfig] || [];
|
|
const itemLabel = (prop as any).itemLabel || key;
|
|
const addButtonText =
|
|
(prop as any).addButtonText || `Add ${itemLabel}`;
|
|
|
|
return (
|
|
<ListItem
|
|
key={key}
|
|
title={key}
|
|
subTitle={prop.description}
|
|
vertical
|
|
>
|
|
<div className={styles["path-list"]}>
|
|
{(currentValue as string[]).map(
|
|
(value: string, index: number) => (
|
|
<div key={index} className={styles["path-item"]}>
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
placeholder={`${itemLabel} ${index + 1}`}
|
|
onChange={(e) => {
|
|
const newValue = [...currentValue] as string[];
|
|
newValue[index] = e.target.value;
|
|
setUserConfig({ ...userConfig, [key]: newValue });
|
|
}}
|
|
/>
|
|
<IconButton
|
|
icon={<DeleteIcon />}
|
|
className={styles["delete-button"]}
|
|
onClick={() => {
|
|
const newValue = [...currentValue] as string[];
|
|
newValue.splice(index, 1);
|
|
setUserConfig({ ...userConfig, [key]: newValue });
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
)}
|
|
<IconButton
|
|
icon={<AddIcon />}
|
|
text={addButtonText}
|
|
className={styles["add-button"]}
|
|
bordered
|
|
onClick={() => {
|
|
const newValue = [...currentValue, ""] as string[];
|
|
setUserConfig({ ...userConfig, [key]: newValue });
|
|
}}
|
|
/>
|
|
</div>
|
|
</ListItem>
|
|
);
|
|
} else if (prop.type === "string") {
|
|
const currentValue = userConfig[key as keyof typeof userConfig] || "";
|
|
return (
|
|
<ListItem key={key} title={key} subTitle={prop.description}>
|
|
<input
|
|
aria-label={key}
|
|
type="text"
|
|
value={currentValue}
|
|
placeholder={`Enter ${key}`}
|
|
onChange={(e) => {
|
|
setUserConfig({ ...userConfig, [key]: e.target.value });
|
|
}}
|
|
/>
|
|
</ListItem>
|
|
);
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
};
|
|
|
|
const checkServerStatus = (clientId: string) => {
|
|
return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
|
|
};
|
|
|
|
const getServerStatusDisplay = (clientId: string) => {
|
|
const status = checkServerStatus(clientId);
|
|
|
|
const statusMap = {
|
|
undefined: null, // 未配置/未找到不显示
|
|
// 添加初始化状态
|
|
initializing: (
|
|
<span className={clsx(styles["server-status"], styles["initializing"])}>
|
|
Initializing
|
|
</span>
|
|
),
|
|
paused: (
|
|
<span className={clsx(styles["server-status"], styles["stopped"])}>
|
|
Stopped
|
|
</span>
|
|
),
|
|
active: <span className={styles["server-status"]}>Running</span>,
|
|
error: (
|
|
<span className={clsx(styles["server-status"], styles["error"])}>
|
|
Error
|
|
<span className={styles["error-message"]}>: {status.errorMsg}</span>
|
|
</span>
|
|
),
|
|
};
|
|
|
|
return statusMap[status.status];
|
|
};
|
|
|
|
// Get the type of operation status
|
|
const getOperationStatusType = (message: string) => {
|
|
if (message.toLowerCase().includes("stopping")) return "stopping";
|
|
if (message.toLowerCase().includes("starting")) return "starting";
|
|
if (message.toLowerCase().includes("error")) return "error";
|
|
return "default";
|
|
};
|
|
|
|
// 渲染服务器列表
|
|
const renderServerList = () => {
|
|
if (loadingPresets) {
|
|
return (
|
|
<div className={styles["loading-container"]}>
|
|
<div className={styles["loading-text"]}>
|
|
Loading preset server list...
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(presetServers) || presetServers.length === 0) {
|
|
return (
|
|
<div className={styles["empty-container"]}>
|
|
<div className={styles["empty-text"]}>No servers available</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return presetServers
|
|
.filter((server) => {
|
|
if (searchText.length === 0) return true;
|
|
const searchLower = searchText.toLowerCase();
|
|
return (
|
|
server.name.toLowerCase().includes(searchLower) ||
|
|
server.description.toLowerCase().includes(searchLower) ||
|
|
server.tags.some((tag) => tag.toLowerCase().includes(searchLower))
|
|
);
|
|
})
|
|
.sort((a, b) => {
|
|
const aStatus = checkServerStatus(a.id).status;
|
|
const bStatus = checkServerStatus(b.id).status;
|
|
const aLoading = loadingStates[a.id];
|
|
const bLoading = loadingStates[b.id];
|
|
|
|
// 定义状态优先级
|
|
const statusPriority: Record<string, number> = {
|
|
error: 0, // Highest priority for error status
|
|
active: 1, // Second for active
|
|
initializing: 2, // Initializing
|
|
starting: 3, // Starting
|
|
stopping: 4, // Stopping
|
|
paused: 5, // Paused
|
|
undefined: 6, // Lowest priority for undefined
|
|
};
|
|
|
|
// Get actual status (including loading status)
|
|
const getEffectiveStatus = (status: string, loading?: string) => {
|
|
if (loading) {
|
|
const operationType = getOperationStatusType(loading);
|
|
return operationType === "default" ? status : operationType;
|
|
}
|
|
|
|
if (status === "initializing" && !loading) {
|
|
return "active";
|
|
}
|
|
|
|
return status;
|
|
};
|
|
|
|
const aEffectiveStatus = getEffectiveStatus(aStatus, aLoading);
|
|
const bEffectiveStatus = getEffectiveStatus(bStatus, bLoading);
|
|
|
|
// 首先按状态排序
|
|
if (aEffectiveStatus !== bEffectiveStatus) {
|
|
return (
|
|
(statusPriority[aEffectiveStatus] ?? 6) -
|
|
(statusPriority[bEffectiveStatus] ?? 6)
|
|
);
|
|
}
|
|
|
|
// Sort by name when statuses are the same
|
|
return a.name.localeCompare(b.name);
|
|
})
|
|
.map((server) => (
|
|
<div
|
|
className={clsx(styles["mcp-market-item"], {
|
|
[styles["loading"]]: loadingStates[server.id],
|
|
})}
|
|
key={server.id}
|
|
>
|
|
<div className={styles["mcp-market-header"]}>
|
|
<div className={styles["mcp-market-title"]}>
|
|
<div className={styles["mcp-market-name"]}>
|
|
{server.name}
|
|
{loadingStates[server.id] && (
|
|
<span
|
|
className={styles["operation-status"]}
|
|
data-status={getOperationStatusType(
|
|
loadingStates[server.id],
|
|
)}
|
|
>
|
|
{loadingStates[server.id]}
|
|
</span>
|
|
)}
|
|
{!loadingStates[server.id] && getServerStatusDisplay(server.id)}
|
|
{server.repo && (
|
|
<a
|
|
href={server.repo}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={styles["repo-link"]}
|
|
title="Open repository"
|
|
>
|
|
<GithubIcon />
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className={styles["tags-container"]}>
|
|
{server.tags.map((tag, index) => (
|
|
<span key={index} className={styles["tag"]}>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div
|
|
className={clsx(styles["mcp-market-info"], "one-line")}
|
|
title={server.description}
|
|
>
|
|
{server.description}
|
|
</div>
|
|
</div>
|
|
<div className={styles["mcp-market-actions"]}>
|
|
{isServerAdded(server.id) ? (
|
|
<>
|
|
{server.configurable && (
|
|
<IconButton
|
|
icon={<EditIcon />}
|
|
text="Configure"
|
|
onClick={() => setEditingServerId(server.id)}
|
|
disabled={isLoading}
|
|
/>
|
|
)}
|
|
{checkServerStatus(server.id).status === "paused" ? (
|
|
<>
|
|
<IconButton
|
|
icon={<PlayIcon />}
|
|
text="Start"
|
|
onClick={() => restartServer(server.id)}
|
|
disabled={isLoading}
|
|
/>
|
|
{/* <IconButton
|
|
icon={<DeleteIcon />}
|
|
text="Remove"
|
|
onClick={() => removeServer(server.id)}
|
|
disabled={isLoading}
|
|
/> */}
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconButton
|
|
icon={<EyeIcon />}
|
|
text="Tools"
|
|
onClick={async () => {
|
|
setViewingServerId(server.id);
|
|
await loadTools(server.id);
|
|
}}
|
|
disabled={
|
|
isLoading ||
|
|
checkServerStatus(server.id).status === "error"
|
|
}
|
|
/>
|
|
<IconButton
|
|
icon={<StopIcon />}
|
|
text="Stop"
|
|
onClick={() => pauseServer(server.id)}
|
|
disabled={isLoading}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<IconButton
|
|
icon={<AddIcon />}
|
|
text="Add"
|
|
onClick={() => addServer(server)}
|
|
disabled={isLoading}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
));
|
|
};
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<div className={styles["mcp-market-page"]}>
|
|
<div className="window-header">
|
|
<div className="window-header-title">
|
|
<div className="window-header-main-title">
|
|
MCP Market
|
|
{loadingStates["all"] && (
|
|
<span className={styles["loading-indicator"]}>
|
|
{loadingStates["all"]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="window-header-sub-title">
|
|
{Object.keys(config?.mcpServers ?? {}).length} servers configured
|
|
</div>
|
|
</div>
|
|
|
|
<div className="window-actions">
|
|
<div className="window-action-button">
|
|
<IconButton
|
|
icon={<RestartIcon />}
|
|
bordered
|
|
onClick={handleRestartAll}
|
|
text="Restart All"
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
<div className="window-action-button">
|
|
<IconButton
|
|
icon={<CloseIcon />}
|
|
bordered
|
|
onClick={() => navigate(-1)}
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles["mcp-market-page-body"]}>
|
|
<div className={styles["mcp-market-filter"]}>
|
|
<input
|
|
type="text"
|
|
className={styles["search-bar"]}
|
|
placeholder={"Search MCP Server"}
|
|
autoFocus
|
|
onInput={(e) => setSearchText(e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles["server-list"]}>{renderServerList()}</div>
|
|
</div>
|
|
|
|
{/*编辑服务器配置*/}
|
|
{editingServerId && (
|
|
<div className="modal-mask">
|
|
<Modal
|
|
title={`Configure Server - ${editingServerId}`}
|
|
onClose={() => !isLoading && setEditingServerId(undefined)}
|
|
actions={[
|
|
<IconButton
|
|
key="cancel"
|
|
text="Cancel"
|
|
onClick={() => setEditingServerId(undefined)}
|
|
bordered
|
|
disabled={isLoading}
|
|
/>,
|
|
<IconButton
|
|
key="confirm"
|
|
text="Save"
|
|
type="primary"
|
|
onClick={saveServerConfig}
|
|
bordered
|
|
disabled={isLoading}
|
|
/>,
|
|
]}
|
|
>
|
|
<List>{renderConfigForm()}</List>
|
|
</Modal>
|
|
</div>
|
|
)}
|
|
|
|
{viewingServerId && (
|
|
<div className="modal-mask">
|
|
<Modal
|
|
title={`Server Details - ${viewingServerId}`}
|
|
onClose={() => setViewingServerId(undefined)}
|
|
actions={[
|
|
<IconButton
|
|
key="close"
|
|
text="Close"
|
|
onClick={() => setViewingServerId(undefined)}
|
|
bordered
|
|
/>,
|
|
]}
|
|
>
|
|
<div className={styles["tools-list"]}>
|
|
{isLoading ? (
|
|
<div>Loading...</div>
|
|
) : tools?.tools ? (
|
|
tools.tools.map(
|
|
(tool: ListToolsResponse["tools"], index: number) => (
|
|
<div key={index} className={styles["tool-item"]}>
|
|
<div className={styles["tool-name"]}>{tool.name}</div>
|
|
<div className={styles["tool-description"]}>
|
|
{tool.description}
|
|
</div>
|
|
</div>
|
|
),
|
|
)
|
|
) : (
|
|
<div>No tools available</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|