mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-19 04:00:16 +09:00
feat: support stop/start MCP servers
This commit is contained in:
parent
e440ff56c8
commit
07c63497dc
@ -98,6 +98,10 @@
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
&.stopped {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
@ -151,21 +155,11 @@
|
||||
|
||||
.mcp-market-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
justify-content: flex-end;
|
||||
|
||||
:global(.icon-button) {
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -213,30 +207,6 @@
|
||||
color: var(--gray-300);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--gray-200);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gray-100);
|
||||
border-color: var(--gray-300);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon-button.add-path-button) {
|
||||
|
@ -17,16 +17,20 @@ import {
|
||||
getClientStatus,
|
||||
getClientTools,
|
||||
getMcpConfigFromFile,
|
||||
removeMcpServer,
|
||||
restartAllClients,
|
||||
pauseMcpServer,
|
||||
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";
|
||||
|
||||
const presetServers = presetServersJson as PresetServer[];
|
||||
|
||||
@ -47,13 +51,7 @@ export function McpMarketPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [config, setConfig] = useState<McpConfigData>();
|
||||
const [clientStatuses, setClientStatuses] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
status: "active" | "error" | "undefined";
|
||||
errorMsg: string | null;
|
||||
}
|
||||
>
|
||||
Record<string, ServerStatusResponse>
|
||||
>({});
|
||||
|
||||
// 检查服务器是否已添加
|
||||
@ -253,18 +251,74 @@ export function McpMarketPage() {
|
||||
};
|
||||
|
||||
// 移除服务器
|
||||
const removeServer = async (id: string) => {
|
||||
// const removeServer = async (id: string) => {
|
||||
// try {
|
||||
// setIsLoading(true);
|
||||
// const newConfig = await removeMcpServer(id);
|
||||
// setConfig(newConfig);
|
||||
|
||||
// // 移除状态
|
||||
// setClientStatuses((prev) => {
|
||||
// const newStatuses = { ...prev };
|
||||
// delete newStatuses[id];
|
||||
// return newStatuses;
|
||||
// });
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// 暂停服务器
|
||||
const pauseServer = async (id: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const newConfig = await removeMcpServer(id);
|
||||
showToast("Stopping server...");
|
||||
const newConfig = await pauseMcpServer(id);
|
||||
setConfig(newConfig);
|
||||
|
||||
// 移除状态
|
||||
setClientStatuses((prev) => {
|
||||
const newStatuses = { ...prev };
|
||||
delete newStatuses[id];
|
||||
return newStatuses;
|
||||
});
|
||||
// 更新状态为暂停
|
||||
setClientStatuses((prev) => ({
|
||||
...prev,
|
||||
[id]: { status: "paused", errorMsg: null },
|
||||
}));
|
||||
showToast("Server stopped successfully");
|
||||
} catch (error) {
|
||||
showToast("Failed to stop server");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复服务器
|
||||
const resumeServer = async (id: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
showToast("Starting server...");
|
||||
|
||||
// 尝试启动服务器
|
||||
const success = await resumeMcpServer(id);
|
||||
|
||||
// 获取最新状态(这个状态是从 clientsMap 中获取的,反映真实状态)
|
||||
const status = await getClientStatus(id);
|
||||
setClientStatuses((prev) => ({
|
||||
...prev,
|
||||
[id]: status,
|
||||
}));
|
||||
|
||||
// 根据启动结果显示消息
|
||||
if (success) {
|
||||
showToast("Server started successfully");
|
||||
} else {
|
||||
throw new Error("Failed to start server");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start server, please check logs",
|
||||
);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -332,7 +386,12 @@ export function McpMarketPage() {
|
||||
} else if (prop.type === "string") {
|
||||
const currentValue = userConfig[key as keyof typeof userConfig] || "";
|
||||
return (
|
||||
<ListItem key={key} title={key} subTitle={prop.description}>
|
||||
<ListItem
|
||||
key={key}
|
||||
title={key}
|
||||
subTitle={prop.description}
|
||||
vertical
|
||||
>
|
||||
<div className={styles["input-item"]}>
|
||||
<input
|
||||
type="text"
|
||||
@ -356,6 +415,29 @@ export function McpMarketPage() {
|
||||
return clientStatuses[clientId] || { status: "undefined", errorMsg: null };
|
||||
};
|
||||
|
||||
// 修改状态显示逻辑
|
||||
const getServerStatusDisplay = (clientId: string) => {
|
||||
const status = checkServerStatus(clientId);
|
||||
|
||||
const statusMap = {
|
||||
undefined: null, // 未配置/未找到不显示
|
||||
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];
|
||||
};
|
||||
|
||||
// 渲染服务器列表
|
||||
const renderServerList = () => {
|
||||
return presetServers
|
||||
@ -373,15 +455,18 @@ export function McpMarketPage() {
|
||||
const bStatus = checkServerStatus(b.id).status;
|
||||
|
||||
// 定义状态优先级
|
||||
const statusPriority = {
|
||||
error: 0,
|
||||
active: 1,
|
||||
undefined: 2,
|
||||
const statusPriority: Record<string, number> = {
|
||||
error: 0, // 最高优先级
|
||||
active: 1, // 运行中
|
||||
paused: 2, // 已暂停
|
||||
undefined: 3, // 未配置/未找到
|
||||
};
|
||||
|
||||
// 首先按状态排序
|
||||
if (aStatus !== bStatus) {
|
||||
return statusPriority[aStatus] - statusPriority[bStatus];
|
||||
return (
|
||||
(statusPriority[aStatus] || 3) - (statusPriority[bStatus] || 3)
|
||||
);
|
||||
}
|
||||
|
||||
// 然后按名称排序
|
||||
@ -398,25 +483,7 @@ export function McpMarketPage() {
|
||||
<div className={styles["mcp-market-title"]}>
|
||||
<div className={styles["mcp-market-name"]}>
|
||||
{server.name}
|
||||
{checkServerStatus(server.id).status !== "undefined" && (
|
||||
<span
|
||||
className={clsx(styles["server-status"], {
|
||||
[styles["error"]]:
|
||||
checkServerStatus(server.id).status === "error",
|
||||
})}
|
||||
>
|
||||
{checkServerStatus(server.id).status === "error" ? (
|
||||
<>
|
||||
Error
|
||||
<span className={styles["error-message"]}>
|
||||
: {checkServerStatus(server.id).errorMsg}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"Active"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{getServerStatusDisplay(server.id)}
|
||||
{server.repo && (
|
||||
<a
|
||||
href={server.repo}
|
||||
@ -450,39 +517,52 @@ export function McpMarketPage() {
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text="Configure"
|
||||
className={clsx({
|
||||
[styles["action-error"]]:
|
||||
checkServerStatus(server.id).status === "error",
|
||||
})}
|
||||
onClick={() => setEditingServerId(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={<DeleteIcon />}
|
||||
text="Remove"
|
||||
className={styles["action-danger"]}
|
||||
onClick={() => removeServer(server.id)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{checkServerStatus(server.id).status === "paused" ? (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<PlayIcon />}
|
||||
text="Start"
|
||||
onClick={() => resumeServer(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"
|
||||
className={styles["action-primary"]}
|
||||
onClick={() => addServer(server)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" fill="none" viewBox="0 0 16 16"><defs><rect id="path_0" width="16" height="16" x="0" y="0"/></defs><g opacity="1" transform="translate(0 0) rotate(0 8 8)"><mask id="bg-mask-0" fill="#fff"><use xlink:href="#path_0"/></mask><g mask="url(#bg-mask-0)"><path id="路径 1" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M13.33,6.67C13.33,2.98 10.35,0 6.67,0C2.98,0 0,2.98 0,6.67C0,10.35 2.98,13.33 6.67,13.33C10.35,13.33 13.33,10.35 13.33,6.67Z" transform="translate(1.3333333333333333 1.3333333333333333) rotate(0 6.666666666666666 6.666666666666666)"/><path id="路径 2" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,4" transform="translate(6.333333333333333 6) rotate(0 0 2)"/><path id="路径 3" style="stroke:#333;stroke-width:1.3333333333333333;stroke-opacity:1;stroke-dasharray:0 0" d="M0,0L0,4" transform="translate(9.666666666666666 6) rotate(0 0 2)"/></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 253 B |
3
app/icons/play.svg
Normal file
3
app/icons/play.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
After Width: | Height: | Size: 239 B |
@ -12,6 +12,7 @@ import {
|
||||
McpConfigData,
|
||||
McpRequestMessage,
|
||||
ServerConfig,
|
||||
ServerStatusResponse,
|
||||
} from "./types";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
@ -22,14 +23,40 @@ const CONFIG_PATH = path.join(process.cwd(), "app/mcp/mcp_config.json");
|
||||
const clientsMap = new Map<string, McpClientData>();
|
||||
|
||||
// 获取客户端状态
|
||||
export async function getClientStatus(clientId: string) {
|
||||
export async function getClientStatus(
|
||||
clientId: string,
|
||||
): Promise<ServerStatusResponse> {
|
||||
const status = clientsMap.get(clientId);
|
||||
if (!status) return { status: "undefined" as const, errorMsg: null };
|
||||
const config = await getMcpConfigFromFile();
|
||||
const serverConfig = config.mcpServers[clientId];
|
||||
|
||||
return {
|
||||
status: status.errorMsg ? ("error" as const) : ("active" as const),
|
||||
errorMsg: status.errorMsg,
|
||||
};
|
||||
// 如果配置中不存在该服务器
|
||||
if (!serverConfig) {
|
||||
return { status: "undefined", errorMsg: null };
|
||||
}
|
||||
|
||||
// 如果服务器配置为暂停状态
|
||||
if (serverConfig.status === "paused") {
|
||||
return { status: "paused", errorMsg: null };
|
||||
}
|
||||
|
||||
// 如果 clientsMap 中没有记录
|
||||
if (!status) {
|
||||
return { status: "undefined", errorMsg: null };
|
||||
}
|
||||
|
||||
// 如果有错误
|
||||
if (status.errorMsg) {
|
||||
return { status: "error", errorMsg: status.errorMsg };
|
||||
}
|
||||
|
||||
// 如果客户端正常运行
|
||||
if (status.client) {
|
||||
return { status: "active", errorMsg: null };
|
||||
}
|
||||
|
||||
// 如果客户端不存在
|
||||
return { status: "error", errorMsg: "Client not found" };
|
||||
}
|
||||
|
||||
// 获取客户端工具
|
||||
@ -61,6 +88,12 @@ async function initializeSingleClient(
|
||||
clientId: string,
|
||||
serverConfig: ServerConfig,
|
||||
) {
|
||||
// 如果服务器状态是暂停,则不初始化
|
||||
if (serverConfig.status === "paused") {
|
||||
logger.info(`Skipping initialization for paused client [${clientId}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Initializing client [${clientId}]...`);
|
||||
try {
|
||||
const client = await createClient(clientId, serverConfig);
|
||||
@ -114,6 +147,100 @@ export async function addMcpServer(clientId: string, config: ServerConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停服务器
|
||||
export async function pauseMcpServer(clientId: string) {
|
||||
try {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const serverConfig = currentConfig.mcpServers[clientId];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`Server ${clientId} not found`);
|
||||
}
|
||||
|
||||
// 先更新配置
|
||||
const newConfig: McpConfigData = {
|
||||
...currentConfig,
|
||||
mcpServers: {
|
||||
...currentConfig.mcpServers,
|
||||
[clientId]: {
|
||||
...serverConfig,
|
||||
status: "paused" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
await updateMcpConfig(newConfig);
|
||||
|
||||
// 然后关闭客户端
|
||||
const client = clientsMap.get(clientId);
|
||||
if (client?.client) {
|
||||
await removeClient(client.client);
|
||||
}
|
||||
clientsMap.delete(clientId);
|
||||
|
||||
return newConfig;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to pause server [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复服务器
|
||||
export async function resumeMcpServer(clientId: string): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const serverConfig = currentConfig.mcpServers[clientId];
|
||||
if (!serverConfig) {
|
||||
throw new Error(`Server ${clientId} not found`);
|
||||
}
|
||||
|
||||
// 先尝试初始化客户端
|
||||
logger.info(`Trying to initialize client [${clientId}]...`);
|
||||
try {
|
||||
const client = await createClient(clientId, serverConfig);
|
||||
const tools = await listTools(client);
|
||||
clientsMap.set(clientId, { client, tools, errorMsg: null });
|
||||
logger.success(`Client [${clientId}] initialized successfully`);
|
||||
|
||||
// 初始化成功后更新配置
|
||||
const newConfig: McpConfigData = {
|
||||
...currentConfig,
|
||||
mcpServers: {
|
||||
...currentConfig.mcpServers,
|
||||
[clientId]: {
|
||||
...serverConfig,
|
||||
status: "active" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
await updateMcpConfig(newConfig);
|
||||
|
||||
// 再次确认状态
|
||||
const status = await getClientStatus(clientId);
|
||||
return status.status === "active";
|
||||
} catch (error) {
|
||||
const currentConfig = await getMcpConfigFromFile();
|
||||
const serverConfig = currentConfig.mcpServers[clientId];
|
||||
|
||||
// 如果配置中存在该服务器,则更新其状态为 error
|
||||
if (serverConfig) {
|
||||
serverConfig.status = "error";
|
||||
await updateMcpConfig(currentConfig);
|
||||
}
|
||||
|
||||
// 初始化失败
|
||||
clientsMap.set(clientId, {
|
||||
client: null,
|
||||
tools: null,
|
||||
errorMsg: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
logger.error(`Failed to initialize client [${clientId}]: ${error}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to resume server [${clientId}]: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除服务器
|
||||
export async function removeMcpServer(clientId: string) {
|
||||
try {
|
||||
|
@ -72,31 +72,6 @@
|
||||
"baseArgs": ["-y", "@executeautomation/playwright-mcp-server"],
|
||||
"configurable": false
|
||||
},
|
||||
{
|
||||
"id": "mongodb",
|
||||
"name": "MongoDB",
|
||||
"description": "Direct interaction with MongoDB databases",
|
||||
"repo": "",
|
||||
"tags": ["database", "mongodb", "nosql"],
|
||||
"command": "node",
|
||||
"baseArgs": ["dist/index.js"],
|
||||
"configurable": true,
|
||||
"configSchema": {
|
||||
"properties": {
|
||||
"connectionString": {
|
||||
"type": "string",
|
||||
"description": "MongoDB connection string",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"argsMapping": {
|
||||
"connectionString": {
|
||||
"type": "single",
|
||||
"position": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "difyworkflow",
|
||||
"name": "Dify Workflow",
|
||||
|
@ -87,11 +87,20 @@ interface McpErrorClient {
|
||||
errorMsg: string;
|
||||
}
|
||||
|
||||
// 服务器状态类型
|
||||
export type ServerStatus = "undefined" | "active" | "paused" | "error";
|
||||
|
||||
export interface ServerStatusResponse {
|
||||
status: ServerStatus;
|
||||
errorMsg: string | null;
|
||||
}
|
||||
|
||||
// MCP 服务器配置相关类型
|
||||
export interface ServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
status?: "active" | "paused" | "error";
|
||||
}
|
||||
|
||||
export interface McpConfigData {
|
||||
|
Loading…
Reference in New Issue
Block a user