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; background-color: #ef4444;
} }
&.stopped {
background-color: #6b7280;
}
.error-message { .error-message {
margin-left: 4px; margin-left: 4px;
font-size: 12px; font-size: 12px;
@ -151,21 +155,11 @@
.mcp-market-actions { .mcp-market-actions {
display: flex; display: flex;
gap: 8px; gap: 12px;
align-items: flex-start; align-items: flex-start;
flex-shrink: 0; flex-shrink: 0;
min-width: 180px; min-width: 180px;
justify-content: flex-end; 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); 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) { :global(.icon-button.add-path-button) {

View File

@ -17,16 +17,20 @@ import {
getClientStatus, getClientStatus,
getClientTools, getClientTools,
getMcpConfigFromFile, getMcpConfigFromFile,
removeMcpServer,
restartAllClients, restartAllClients,
pauseMcpServer,
resumeMcpServer,
} from "../mcp/actions"; } from "../mcp/actions";
import { import {
ListToolsResponse, ListToolsResponse,
McpConfigData, McpConfigData,
PresetServer, PresetServer,
ServerConfig, ServerConfig,
ServerStatusResponse,
} from "../mcp/types"; } from "../mcp/types";
import clsx from "clsx"; import clsx from "clsx";
import PlayIcon from "../icons/play.svg";
import StopIcon from "../icons/pause.svg";
const presetServers = presetServersJson as PresetServer[]; const presetServers = presetServersJson as PresetServer[];
@ -47,13 +51,7 @@ export function McpMarketPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [config, setConfig] = useState<McpConfigData>(); const [config, setConfig] = useState<McpConfigData>();
const [clientStatuses, setClientStatuses] = useState< const [clientStatuses, setClientStatuses] = useState<
Record< Record<string, ServerStatusResponse>
string,
{
status: "active" | "error" | "undefined";
errorMsg: string | null;
}
>
>({}); >({});
// 检查服务器是否已添加 // 检查服务器是否已添加
@ -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 { try {
setIsLoading(true); setIsLoading(true);
const newConfig = await removeMcpServer(id); showToast("Stopping server...");
const newConfig = await pauseMcpServer(id);
setConfig(newConfig); setConfig(newConfig);
// 移除状态 // 更新状态为暂停
setClientStatuses((prev) => { setClientStatuses((prev) => ({
const newStatuses = { ...prev }; ...prev,
delete newStatuses[id]; [id]: { status: "paused", errorMsg: null },
return newStatuses; }));
}); 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -332,7 +386,12 @@ export function McpMarketPage() {
} else if (prop.type === "string") { } else if (prop.type === "string") {
const currentValue = userConfig[key as keyof typeof userConfig] || ""; const currentValue = userConfig[key as keyof typeof userConfig] || "";
return ( return (
<ListItem key={key} title={key} subTitle={prop.description}> <ListItem
key={key}
title={key}
subTitle={prop.description}
vertical
>
<div className={styles["input-item"]}> <div className={styles["input-item"]}>
<input <input
type="text" type="text"
@ -356,6 +415,29 @@ export function McpMarketPage() {
return clientStatuses[clientId] || { status: "undefined", errorMsg: null }; 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 = () => { const renderServerList = () => {
return presetServers return presetServers
@ -373,15 +455,18 @@ export function McpMarketPage() {
const bStatus = checkServerStatus(b.id).status; const bStatus = checkServerStatus(b.id).status;
// 定义状态优先级 // 定义状态优先级
const statusPriority = { const statusPriority: Record<string, number> = {
error: 0, error: 0, // 最高优先级
active: 1, active: 1, // 运行中
undefined: 2, paused: 2, // 已暂停
undefined: 3, // 未配置/未找到
}; };
// 首先按状态排序 // 首先按状态排序
if (aStatus !== bStatus) { 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-title"]}>
<div className={styles["mcp-market-name"]}> <div className={styles["mcp-market-name"]}>
{server.name} {server.name}
{checkServerStatus(server.id).status !== "undefined" && ( {getServerStatusDisplay(server.id)}
<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>
)}
{server.repo && ( {server.repo && (
<a <a
href={server.repo} href={server.repo}
@ -450,39 +517,52 @@ export function McpMarketPage() {
<IconButton <IconButton
icon={<EditIcon />} icon={<EditIcon />}
text="Configure" text="Configure"
className={clsx({
[styles["action-error"]]:
checkServerStatus(server.id).status === "error",
})}
onClick={() => setEditingServerId(server.id)} onClick={() => setEditingServerId(server.id)}
disabled={isLoading} disabled={isLoading}
/> />
)} )}
<IconButton {checkServerStatus(server.id).status === "paused" ? (
icon={<EyeIcon />} <>
text="Tools" <IconButton
onClick={async () => { icon={<PlayIcon />}
setViewingServerId(server.id); text="Start"
await loadTools(server.id); onClick={() => resumeServer(server.id)}
}} disabled={isLoading}
disabled={ />
isLoading || {/* <IconButton
checkServerStatus(server.id).status === "error" icon={<DeleteIcon />}
} text="Remove"
/> onClick={() => removeServer(server.id)}
<IconButton disabled={isLoading}
icon={<DeleteIcon />} /> */}
text="Remove" </>
className={styles["action-danger"]} ) : (
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 <IconButton
icon={<AddIcon />} icon={<AddIcon />}
text="Add" text="Add"
className={styles["action-primary"]}
onClick={() => addServer(server)} onClick={() => addServer(server)}
disabled={isLoading} 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, McpConfigData,
McpRequestMessage, McpRequestMessage,
ServerConfig, ServerConfig,
ServerStatusResponse,
} from "./types"; } from "./types";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; 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>(); 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); 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), if (!serverConfig) {
errorMsg: status.errorMsg, 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, clientId: string,
serverConfig: ServerConfig, serverConfig: ServerConfig,
) { ) {
// 如果服务器状态是暂停,则不初始化
if (serverConfig.status === "paused") {
logger.info(`Skipping initialization for paused client [${clientId}]`);
return;
}
logger.info(`Initializing client [${clientId}]...`); logger.info(`Initializing client [${clientId}]...`);
try { try {
const client = await createClient(clientId, serverConfig); 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) { export async function removeMcpServer(clientId: string) {
try { try {

View File

@ -72,31 +72,6 @@
"baseArgs": ["-y", "@executeautomation/playwright-mcp-server"], "baseArgs": ["-y", "@executeautomation/playwright-mcp-server"],
"configurable": false "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", "id": "difyworkflow",
"name": "Dify Workflow", "name": "Dify Workflow",

View File

@ -87,11 +87,20 @@ interface McpErrorClient {
errorMsg: string; errorMsg: string;
} }
// 服务器状态类型
export type ServerStatus = "undefined" | "active" | "paused" | "error";
export interface ServerStatusResponse {
status: ServerStatus;
errorMsg: string | null;
}
// MCP 服务器配置相关类型 // MCP 服务器配置相关类型
export interface ServerConfig { export interface ServerConfig {
command: string; command: string;
args: string[]; args: string[];
env?: Record<string, string>; env?: Record<string, string>;
status?: "active" | "paused" | "error";
} }
export interface McpConfigData { export interface McpConfigData {