Browse Source

feat: support stop/start MCP servers

Kadxy 10 tháng trước cách đây
mục cha
commit
07c63497dc

+ 5 - 35
app/components/mcp-market.module.scss

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

+ 145 - 65
app/components/mcp-market.tsx

@@ -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}
                 />

+ 3 - 1
app/icons/pause.svg

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

+ 3 - 0
app/icons/play.svg

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

+ 133 - 6
app/mcp/actions.ts

@@ -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];
+
+  // 如果配置中不存在该服务器
+  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: status.errorMsg ? ("error" as const) : ("active" as const),
-    errorMsg: status.errorMsg,
-  };
+  // 如果客户端不存在
+  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 {

+ 0 - 25
app/mcp/preset-server.json

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

+ 9 - 0
app/mcp/types.ts

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