feat: support stop/start MCP servers

This commit is contained in:
Kadxy 2025-01-16 08:52:54 +08:00
parent e440ff56c8
commit 07c63497dc
7 changed files with 298 additions and 132 deletions

View File

@ -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) {

View File

@ -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,14 +517,27 @@ export function McpMarketPage() {
<IconButton
icon={<EditIcon />}
text="Configure"
className={clsx({
[styles["action-error"]]:
checkServerStatus(server.id).status === "error",
})}
onClick={() => setEditingServerId(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"
@ -471,18 +551,18 @@ export function McpMarketPage() {
}
/>
<IconButton
icon={<DeleteIcon />}
text="Remove"
className={styles["action-danger"]}
onClick={() => removeServer(server.id)}
icon={<StopIcon />}
text="Stop"
onClick={() => pauseServer(server.id)}
disabled={isLoading}
/>
</>
)}
</>
) : (
<IconButton
icon={<AddIcon />}
text="Add"
className={styles["action-primary"]}
onClick={() => addServer(server)}
disabled={isLoading}
/>

View File

@ -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
View 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

View File

@ -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 {

View File

@ -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",

View File

@ -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 {