Kadxy 11 сар өмнө
parent
commit
7d51bfd42e

+ 10 - 2
app/components/home.tsx

@@ -2,7 +2,7 @@
 
 require("../polyfill");
 
-import { useState, useEffect } from "react";
+import { useEffect, useState } from "react";
 import styles from "./home.module.scss";
 
 import BotIcon from "../icons/bot.svg";
@@ -18,8 +18,8 @@ import { getISOLang, getLang } from "../locales";
 
 import {
   HashRouter as Router,
-  Routes,
   Route,
+  Routes,
   useLocation,
 } from "react-router-dom";
 import { SideBar } from "./sidebar";
@@ -74,6 +74,13 @@ const Sd = dynamic(async () => (await import("./sd")).Sd, {
   loading: () => <Loading noLogo />,
 });
 
+const McpMarketPage = dynamic(
+  async () => (await import("./mcp-market")).McpMarketPage,
+  {
+    loading: () => <Loading noLogo />,
+  },
+);
+
 export function useSwitchTheme() {
   const config = useAppConfig();
 
@@ -193,6 +200,7 @@ function Screen() {
             <Route path={Path.SearchChat} element={<SearchChat />} />
             <Route path={Path.Chat} element={<Chat />} />
             <Route path={Path.Settings} element={<Settings />} />
+            <Route path={Path.McpMarket} element={<McpMarketPage />} />
           </Routes>
         </WindowContent>
       </>

+ 612 - 0
app/components/mcp-market.module.scss

@@ -0,0 +1,612 @@
+@import "../styles/animation.scss";
+
+.mcp-market-page {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  .loading-indicator {
+    font-size: 12px;
+    color: var(--primary);
+    margin-left: 8px;
+    font-weight: normal;
+    opacity: 0.8;
+  }
+
+  .mcp-market-page-body {
+    padding: 20px;
+    overflow-y: auto;
+
+    .mcp-market-filter {
+      width: 100%;
+      max-width: 100%;
+      margin-bottom: 20px;
+      animation: slide-in ease 0.3s;
+      height: 40px;
+      display: flex;
+
+      .search-bar {
+        flex-grow: 1;
+        max-width: 100%;
+        min-width: 0;
+      }
+    }
+
+    .server-list {
+      display: flex;
+      flex-direction: column;
+      gap: 1px;
+    }
+
+    .mcp-market-item {
+      display: flex;
+      justify-content: space-between;
+      padding: 20px;
+      border: var(--border-in-light);
+      animation: slide-in ease 0.3s;
+      background-color: var(--white);
+      transition: all 0.3s ease;
+
+      &.disabled {
+        opacity: 0.7;
+        pointer-events: none;
+      }
+
+      &:not(:last-child) {
+        border-bottom: 0;
+      }
+
+      &:first-child {
+        border-top-left-radius: 10px;
+        border-top-right-radius: 10px;
+      }
+
+      &:last-child {
+        border-bottom-left-radius: 10px;
+        border-bottom-right-radius: 10px;
+      }
+
+      .mcp-market-header {
+        display: flex;
+        align-items: center;
+
+        .mcp-market-title {
+          .mcp-market-name {
+            font-size: 14px;
+            font-weight: bold;
+            display: flex;
+            align-items: center;
+            gap: 8px;
+
+            .server-status {
+              font-size: 12px;
+              padding: 2px 6px;
+              border-radius: 4px;
+              margin-left: 8px;
+              background-color: #10b981;
+              color: white;
+
+              &.error {
+                background-color: #ef4444;
+              }
+
+              &.waiting {
+                background-color: #f59e0b;
+              }
+
+              .error-message {
+                font-size: 11px;
+                opacity: 0.9;
+                margin-left: 4px;
+              }
+            }
+          }
+
+          .mcp-market-info {
+            font-size: 12px;
+            color: var(--black-50);
+            margin-top: 4px;
+          }
+        }
+      }
+
+      .mcp-market-actions {
+        display: flex;
+        gap: 8px;
+        align-items: center;
+
+        :global(.icon-button) {
+          transition: all 0.3s ease;
+          border: 1px solid transparent;
+
+          &:hover {
+            transform: translateY(-1px);
+            filter: brightness(1.1);
+          }
+
+          &.action-primary {
+            background-color: var(--primary);
+            color: white;
+
+            svg {
+              filter: brightness(2);
+            }
+
+            &:hover {
+              background-color: var(--primary);
+              border-color: var(--primary);
+            }
+          }
+
+          &.action-warning {
+            background-color: var(--warning);
+            color: white;
+
+            svg {
+              filter: brightness(2);
+            }
+
+            &:hover {
+              background-color: var(--warning);
+              border-color: var(--warning);
+            }
+          }
+
+          &.action-danger {
+            background-color: transparent;
+            color: var(--danger);
+            border-color: var(--danger);
+
+            &:hover {
+              background-color: var(--danger);
+              color: white;
+
+              svg {
+                filter: brightness(2);
+              }
+            }
+          }
+
+          &.action-error {
+            color: #ef4444 !important;
+            border-color: #ef4444 !important;
+          }
+        }
+      }
+
+      @media screen and (max-width: 600px) {
+        flex-direction: column;
+        gap: 10px;
+
+        .mcp-market-actions {
+          justify-content: flex-end;
+        }
+      }
+    }
+  }
+
+  .array-input {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+    width: 100%;
+    padding: 16px;
+    border: 1px solid var(--gray-200);
+    border-radius: 10px;
+    background-color: var(--white);
+
+    .array-input-item {
+      display: flex;
+      gap: 8px;
+      align-items: center;
+      width: 100%;
+      padding: 0;
+
+      input {
+        width: 100%;
+        padding: 8px 12px;
+        background-color: var(--gray-50);
+        border-radius: 6px;
+        transition: all 0.3s ease;
+        font-size: 13px;
+        border: 1px solid var(--gray-200);
+
+        &:hover {
+          background-color: var(--gray-100);
+          border-color: var(--gray-300);
+        }
+
+        &:focus {
+          background-color: var(--white);
+          border-color: var(--primary);
+          outline: none;
+          box-shadow: 0 0 0 2px var(--primary-10);
+        }
+
+        &::placeholder {
+          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) {
+      width: 100%;
+      background-color: var(--primary);
+      color: white;
+      padding: 8px 12px;
+      border-radius: 6px;
+      transition: all 0.3s ease;
+      margin-top: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border: none;
+      height: 36px;
+
+      &:hover {
+        background-color: var(--primary-dark);
+      }
+
+      svg {
+        width: 16px;
+        height: 16px;
+        margin-right: 4px;
+        filter: brightness(2);
+      }
+    }
+  }
+
+  .path-list {
+    width: 100%;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+
+    .path-item {
+      display: flex;
+      gap: 10px;
+      width: 100%;
+
+      input {
+        flex: 1;
+        width: 100%;
+        max-width: 100%;
+        padding: 10px;
+        border: var(--border-in-light);
+        border-radius: 10px;
+        box-sizing: border-box;
+        font-size: 14px;
+        background-color: var(--white);
+        color: var(--black);
+
+        &:hover {
+          border-color: var(--gray-300);
+        }
+
+        &:focus {
+          border-color: var(--primary);
+          outline: none;
+          box-shadow: 0 0 0 2px var(--primary-10);
+        }
+
+        &::placeholder {
+          color: var(--gray-300) !important;
+          opacity: 1;
+        }
+      }
+
+      .browse-button {
+        padding: 8px;
+        border: var(--border-in-light);
+        border-radius: 10px;
+        background-color: transparent;
+        color: var(--black-50);
+
+        &:hover {
+          border-color: var(--primary);
+          color: var(--primary);
+          background-color: transparent;
+        }
+
+        svg {
+          width: 16px;
+          height: 16px;
+        }
+      }
+
+      .delete-button {
+        padding: 8px;
+        border: var(--border-in-light);
+        border-radius: 10px;
+        background-color: transparent;
+        color: var(--black-50);
+
+        &:hover {
+          border-color: var(--danger);
+          color: var(--danger);
+          background-color: transparent;
+        }
+
+        svg {
+          width: 16px;
+          height: 16px;
+        }
+      }
+
+      .file-input {
+        display: none;
+      }
+    }
+
+    .add-button {
+      align-self: flex-start;
+      display: flex;
+      align-items: center;
+      gap: 5px;
+      padding: 8px 12px;
+      background-color: transparent;
+      border: var(--border-in-light);
+      border-radius: 10px;
+      color: var(--black);
+      font-size: 12px;
+      margin-top: 5px;
+
+      &:hover {
+        border-color: var(--primary);
+        color: var(--primary);
+        background-color: transparent;
+      }
+
+      svg {
+        width: 16px;
+        height: 16px;
+      }
+    }
+  }
+
+  .config-section {
+    width: 100%;
+
+    .config-header {
+      margin-bottom: 12px;
+
+      .config-title {
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--black);
+        text-transform: capitalize;
+      }
+
+      .config-description {
+        font-size: 12px;
+        color: var(--gray-500);
+        margin-top: 4px;
+      }
+    }
+
+    .array-input {
+      display: flex;
+      flex-direction: column;
+      gap: 12px;
+      width: 100%;
+      padding: 16px;
+      border: 1px solid var(--gray-200);
+      border-radius: 10px;
+      background-color: var(--white);
+
+      .array-input-item {
+        display: flex;
+        gap: 8px;
+        align-items: center;
+        width: 100%;
+        padding: 0;
+
+        input {
+          width: 100%;
+          padding: 8px 12px;
+          background-color: var(--gray-50);
+          border-radius: 6px;
+          transition: all 0.3s ease;
+          font-size: 13px;
+          border: 1px solid var(--gray-200);
+
+          &:hover {
+            background-color: var(--gray-100);
+            border-color: var(--gray-300);
+          }
+
+          &:focus {
+            background-color: var(--white);
+            border-color: var(--primary);
+            outline: none;
+            box-shadow: 0 0 0 2px var(--primary-10);
+          }
+
+          &::placeholder {
+            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) {
+        width: 100%;
+        background-color: var(--primary);
+        color: white;
+        padding: 8px 12px;
+        border-radius: 6px;
+        transition: all 0.3s ease;
+        margin-top: 8px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border: none;
+        height: 36px;
+
+        &:hover {
+          background-color: var(--primary-dark);
+        }
+
+        svg {
+          width: 16px;
+          height: 16px;
+          margin-right: 4px;
+          filter: brightness(2);
+        }
+      }
+    }
+  }
+
+  .input-item {
+    width: 100%;
+
+    input {
+      width: 100%;
+      padding: 10px;
+      border: var(--border-in-light);
+      border-radius: 10px;
+      box-sizing: border-box;
+      font-size: 14px;
+      background-color: var(--white);
+      color: var(--black);
+
+      &:hover {
+        border-color: var(--gray-300);
+      }
+
+      &:focus {
+        border-color: var(--primary);
+        outline: none;
+        box-shadow: 0 0 0 2px var(--primary-10);
+      }
+
+      &::placeholder {
+        color: var(--gray-300) !important;
+        opacity: 1;
+      }
+    }
+  }
+
+  .primitives-list {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    width: 100%;
+    padding: 20px;
+    max-width: 100%;
+    overflow-x: hidden;
+    word-break: break-word;
+    box-sizing: border-box;
+
+    .primitive-item {
+      width: 100%;
+      box-sizing: border-box;
+
+      .primitive-name {
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--black);
+        margin-bottom: 8px;
+        padding-left: 12px;
+        border-left: 3px solid var(--primary);
+        box-sizing: border-box;
+        width: 100%;
+      }
+
+      .primitive-description {
+        font-size: 13px;
+        color: var(--gray-500);
+        line-height: 1.6;
+        padding-left: 15px;
+        box-sizing: border-box;
+        width: 100%;
+      }
+    }
+  }
+
+  :global {
+    .modal-content {
+      margin-top: 20px;
+      max-width: 100%;
+      overflow-x: hidden;
+    }
+
+    .list {
+      padding: 10px;
+      margin-bottom: 10px;
+      background-color: var(--white);
+    }
+
+    .list-item {
+      border: none;
+      background-color: transparent;
+      border-radius: 10px;
+      padding: 10px;
+      margin-bottom: 10px;
+
+      .list-header {
+        margin-bottom: 10px;
+
+        .list-title {
+          font-size: 14px;
+          font-weight: bold;
+          text-transform: capitalize;
+          color: var(--black);
+        }
+
+        .list-sub-title {
+          font-size: 12px;
+          color: var(--gray-500);
+          margin-top: 4px;
+        }
+      }
+    }
+  }
+}

+ 564 - 0
app/components/mcp-market.tsx

@@ -0,0 +1,564 @@
+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 { List, ListItem, Modal, showToast } from "./ui-lib";
+import { useNavigate } from "react-router-dom";
+import { useState, useEffect } from "react";
+import presetServersJson from "../mcp/preset-server.json";
+const presetServers = presetServersJson as PresetServer[];
+import {
+  getMcpConfig,
+  updateMcpConfig,
+  getClientPrimitives,
+  restartAllClients,
+  reinitializeMcpClients,
+  getClientErrors,
+} from "../mcp/actions";
+import { McpConfig, PresetServer, ServerConfig } from "../mcp/types";
+import clsx from "clsx";
+
+interface ConfigProperty {
+  type: string;
+  description?: string;
+  required?: boolean;
+  minItems?: number;
+}
+
+export function McpMarketPage() {
+  const navigate = useNavigate();
+  const [searchText, setSearchText] = useState("");
+  const [config, setConfig] = useState<McpConfig>({ mcpServers: {} });
+  const [editingServerId, setEditingServerId] = useState<string | undefined>();
+  const [viewingServerId, setViewingServerId] = useState<string | undefined>();
+  const [primitives, setPrimitives] = useState<any[]>([]);
+  const [userConfig, setUserConfig] = useState<Record<string, any>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [clientErrors, setClientErrors] = useState<
+    Record<string, string | null>
+  >({});
+
+  // 更新服务器状态
+  const updateServerStatus = async () => {
+    await reinitializeMcpClients();
+    const errors = await getClientErrors();
+    setClientErrors(errors);
+  };
+
+  // 初始加载配置
+  useEffect(() => {
+    const init = async () => {
+      try {
+        setIsLoading(true);
+        const data = await getMcpConfig();
+        setConfig(data);
+        await updateServerStatus();
+      } catch (error) {
+        showToast("Failed to load configuration");
+        console.error(error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+    init();
+  }, []);
+
+  // 保存配置
+  const saveConfig = async (newConfig: McpConfig) => {
+    try {
+      setIsLoading(true);
+      await updateMcpConfig(newConfig);
+      setConfig(newConfig);
+      await updateServerStatus();
+      showToast("Configuration saved successfully");
+    } catch (error) {
+      showToast("Failed to save configuration");
+      console.error(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  // 检查服务器是否已添加
+  const isServerAdded = (id: string) => {
+    return id in config.mcpServers;
+  };
+
+  // 加载当前编辑服务器的配置
+  useEffect(() => {
+    if (editingServerId) {
+      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") {
+              // 对于 spread 类型,从 args 中提取数组
+              const startPos = mapping.position ?? 0;
+              userConfig[key] = currentConfig.args.slice(startPos);
+            } else if (mapping.type === "single") {
+              // 对于 single 类型,获取单个值
+              userConfig[key] = currentConfig.args[mapping.position ?? 0];
+            } else if (
+              mapping.type === "env" &&
+              mapping.key &&
+              currentConfig.env
+            ) {
+              // 对于 env 类型,从环境变量中获取值
+              userConfig[key] = currentConfig.env[mapping.key];
+            }
+          });
+          setUserConfig(userConfig);
+        }
+      } else {
+        setUserConfig({});
+      }
+    }
+  }, [editingServerId, config.mcpServers]);
+
+  // 保存服务器配置
+  const saveServerConfig = async () => {
+    const preset = presetServers.find((s) => s.id === editingServerId);
+    if (!preset || !preset.configSchema || !editingServerId) return;
+
+    try {
+      // 构建服务器配置
+      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 = {
+        ...config,
+        mcpServers: {
+          ...config.mcpServers,
+          [editingServerId]: serverConfig,
+        },
+      };
+
+      await saveConfig(newConfig);
+      setEditingServerId(undefined);
+      showToast("Server configuration saved successfully");
+    } catch (error) {
+      showToast(
+        error instanceof Error ? error.message : "Failed to save configuration",
+      );
+    }
+  };
+
+  // 渲染配置表单
+  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] || [];
+          return (
+            <ListItem key={key} title={key} subTitle={prop.description}>
+              <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={`Path ${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="Add Path"
+                  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}>
+              <div className={styles["input-item"]}>
+                <input
+                  type="text"
+                  value={currentValue}
+                  placeholder={`Enter ${key}`}
+                  onChange={(e) => {
+                    setUserConfig({ ...userConfig, [key]: e.target.value });
+                  }}
+                />
+              </div>
+            </ListItem>
+          );
+        }
+        return null;
+      },
+    );
+  };
+
+  // 获取服务器的 Primitives
+  const loadPrimitives = async (id: string) => {
+    try {
+      setIsLoading(true);
+      const result = await getClientPrimitives(id);
+      if (result) {
+        setPrimitives(result);
+      } else {
+        showToast("Server is not running");
+        setPrimitives([]);
+      }
+    } catch (error) {
+      showToast("Failed to load primitives");
+      console.error(error);
+      setPrimitives([]);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  // 重启所有客户端
+  const handleRestart = async () => {
+    try {
+      setIsLoading(true);
+      await restartAllClients();
+      await updateServerStatus();
+      showToast("All clients restarted successfully");
+    } catch (error) {
+      showToast("Failed to restart clients");
+      console.error(error);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  // 添加服务器
+  const addServer = async (preset: PresetServer) => {
+    if (!preset.configurable) {
+      try {
+        setIsLoading(true);
+        showToast("Creating MCP client...");
+        // 如果服务器不需要配置,直接添加
+        const serverConfig: ServerConfig = {
+          command: preset.command,
+          args: [...preset.baseArgs],
+        };
+        const newConfig = {
+          ...config,
+          mcpServers: {
+            ...config.mcpServers,
+            [preset.id]: serverConfig,
+          },
+        };
+        await saveConfig(newConfig);
+      } finally {
+        setIsLoading(false);
+      }
+    } else {
+      // 如果需要配置,打开配置对话框
+      setEditingServerId(preset.id);
+      setUserConfig({});
+    }
+  };
+
+  // 移除服务器
+  const removeServer = async (id: string) => {
+    try {
+      setIsLoading(true);
+      const { [id]: _, ...rest } = config.mcpServers;
+      const newConfig = {
+        ...config,
+        mcpServers: rest,
+      };
+      await saveConfig(newConfig);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  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
+              {isLoading && (
+                <span className={styles["loading-indicator"]}>Loading...</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={handleRestart}
+                text="Restart"
+                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"]}>
+            {presetServers
+              .filter(
+                (m) =>
+                  searchText.length === 0 ||
+                  m.name.toLowerCase().includes(searchText.toLowerCase()) ||
+                  m.description
+                    .toLowerCase()
+                    .includes(searchText.toLowerCase()),
+              )
+              .sort((a, b) => {
+                const aAdded = isServerAdded(a.id);
+                const bAdded = isServerAdded(b.id);
+                const aError = clientErrors[a.id] !== null;
+                const bError = clientErrors[b.id] !== null;
+
+                if (aAdded !== bAdded) {
+                  return aAdded ? -1 : 1;
+                }
+                if (aAdded && bAdded) {
+                  if (aError !== bError) {
+                    return aError ? -1 : 1;
+                  }
+                }
+                return 0;
+              })
+              .map((server) => (
+                <div
+                  className={clsx(styles["mcp-market-item"], {
+                    [styles["disabled"]]: isLoading,
+                  })}
+                  key={server.id}
+                >
+                  <div className={styles["mcp-market-header"]}>
+                    <div className={styles["mcp-market-title"]}>
+                      <div className={styles["mcp-market-name"]}>
+                        {server.name}
+                        {isServerAdded(server.id) && (
+                          <span
+                            className={clsx(styles["server-status"], {
+                              [styles["error"]]:
+                                clientErrors[server.id] !== null,
+                            })}
+                          >
+                            {clientErrors[server.id] === null
+                              ? "Active"
+                              : "Error"}
+                            {clientErrors[server.id] && (
+                              <span className={styles["error-message"]}>
+                                : {clientErrors[server.id]}
+                              </span>
+                            )}
+                          </span>
+                        )}
+                      </div>
+                      <div
+                        className={clsx(styles["mcp-market-info"], "one-line")}
+                      >
+                        {server.description}
+                      </div>
+                    </div>
+                  </div>
+                  <div className={styles["mcp-market-actions"]}>
+                    {isServerAdded(server.id) ? (
+                      <>
+                        {server.configurable && (
+                          <IconButton
+                            icon={<EditIcon />}
+                            text="Configure"
+                            className={clsx({
+                              [styles["action-error"]]:
+                                clientErrors[server.id] !== null,
+                            })}
+                            onClick={() => setEditingServerId(server.id)}
+                            disabled={isLoading}
+                          />
+                        )}
+                        {isServerAdded(server.id) && (
+                          <IconButton
+                            icon={<EyeIcon />}
+                            text="Detail"
+                            onClick={async () => {
+                              if (clientErrors[server.id] !== null) {
+                                showToast("Server is not running");
+                                return;
+                              }
+                              setViewingServerId(server.id);
+                              await loadPrimitives(server.id);
+                            }}
+                            disabled={isLoading}
+                          />
+                        )}
+                        <IconButton
+                          icon={<DeleteIcon />}
+                          text="Remove"
+                          className={styles["action-danger"]}
+                          onClick={() => removeServer(server.id)}
+                          disabled={isLoading}
+                        />
+                      </>
+                    ) : (
+                      <IconButton
+                        icon={<AddIcon />}
+                        text="Add"
+                        className={styles["action-primary"]}
+                        onClick={() => addServer(server)}
+                        disabled={isLoading}
+                      />
+                    )}
+                  </div>
+                </div>
+              ))}
+          </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["primitives-list"]}>
+                {isLoading ? (
+                  <div>Loading...</div>
+                ) : primitives.filter((p) => p.type === "tool").length > 0 ? (
+                  primitives
+                    .filter((p) => p.type === "tool")
+                    .map((primitive, index) => (
+                      <div key={index} className={styles["primitive-item"]}>
+                        <div className={styles["primitive-name"]}>
+                          {primitive.value.name}
+                        </div>
+                        {primitive.value.description && (
+                          <div className={styles["primitive-description"]}>
+                            {primitive.value.description}
+                          </div>
+                        )}
+                      </div>
+                    ))
+                ) : (
+                  <div>No tools available</div>
+                )}
+              </div>
+            </Modal>
+          </div>
+        )}
+      </div>
+    </ErrorBoundary>
+  );
+}

+ 10 - 0
app/components/sidebar.tsx

@@ -9,6 +9,7 @@ import ChatGptIcon from "../icons/chatgpt.svg";
 import AddIcon from "../icons/add.svg";
 import DeleteIcon from "../icons/delete.svg";
 import MaskIcon from "../icons/mask.svg";
+import McpIcon from "../icons/mcp.svg";
 import DragIcon from "../icons/drag.svg";
 import DiscoveryIcon from "../icons/discovery.svg";
 
@@ -250,6 +251,15 @@ export function SideBar(props: { className?: string }) {
             }}
             shadow
           />
+          <IconButton
+            icon={<McpIcon />}
+            text={shouldNarrow ? undefined : Locale.Mcp.Name}
+            className={styles["sidebar-bar-button"]}
+            onClick={() => {
+              navigate(Path.McpMarket, { state: { fromHome: true } });
+            }}
+            shadow
+          />
           <IconButton
             icon={<DiscoveryIcon />}
             text={shouldNarrow ? undefined : Locale.Discovery.Name}

+ 1 - 0
app/constant.ts

@@ -47,6 +47,7 @@ export enum Path {
   SdNew = "/sd-new",
   Artifacts = "/artifacts",
   SearchChat = "/search-chat",
+  McpMarket = "/mcp-market",
 }
 
 export enum ApiPath {

+ 15 - 0
app/icons/mcp.svg

@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 180 180" fill="none">
+    <g clip-path="url(#clip0_19_13)">
+        <path d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706V16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177"
+              stroke="black" stroke-width="12" stroke-linecap="round"/>
+        <path d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52"
+              stroke="black" stroke-width="12" stroke-linecap="round"/>
+        <path d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087V118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822"
+              stroke="black" stroke-width="12" stroke-linecap="round"/>
+    </g>
+    <defs>
+        <clipPath id="clip0_19_13">
+            <rect width="180" height="180" fill="white"/>
+        </clipPath>
+    </defs>
+</svg>

+ 3 - 0
app/locales/cn.ts

@@ -626,6 +626,9 @@ const cn = {
   Discovery: {
     Name: "发现",
   },
+  Mcp: {
+    Name: "MCP",
+  },
   FineTuned: {
     Sysmessage: "你是一个助手",
   },

+ 121 - 11
app/mcp/actions.ts

@@ -7,15 +7,16 @@ import {
   Primitive,
 } from "./client";
 import { MCPClientLogger } from "./logger";
-import conf from "./mcp_config.json";
-import { McpRequestMessage } from "./types";
+import { McpRequestMessage, McpConfig, ServerConfig } from "./types";
+import fs from "fs/promises";
+import path from "path";
 
 const logger = new MCPClientLogger("MCP Actions");
 
 // Use Map to store all clients
 const clientsMap = new Map<
   string,
-  { client: Client; primitives: Primitive[] }
+  { client: Client | null; primitives: Primitive[]; errorMsg: string | null }
 >();
 
 // Whether initialized
@@ -24,27 +25,76 @@ let initialized = false;
 // Store failed clients
 let errorClients: string[] = [];
 
+const CONFIG_PATH = path.join(process.cwd(), "app/mcp/mcp_config.json");
+
+// 获取 MCP 配置
+export async function getMcpConfig(): Promise<McpConfig> {
+  try {
+    const configStr = await fs.readFile(CONFIG_PATH, "utf-8");
+    return JSON.parse(configStr);
+  } catch (error) {
+    console.error("Failed to read MCP config:", error);
+    return { mcpServers: {} };
+  }
+}
+
+// 更新 MCP 配置
+export async function updateMcpConfig(config: McpConfig): Promise<void> {
+  try {
+    await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
+  } catch (error) {
+    console.error("Failed to write MCP config:", error);
+    throw error;
+  }
+}
+
+// 重新初始化所有客户端
+export async function reinitializeMcpClients() {
+  logger.info("Reinitializing MCP clients...");
+  // 遍历所有客户端,关闭
+  try {
+    for (const [clientId, clientData] of clientsMap.entries()) {
+      clientData.client?.close();
+    }
+  } catch (error) {
+    logger.error(`Failed to close clients: ${error}`);
+  }
+  // 清空状态
+  clientsMap.clear();
+  errorClients = [];
+  initialized = false;
+  // 重新初始化
+  return initializeMcpClients();
+}
+
 // Initialize all configured clients
 export async function initializeMcpClients() {
   // If already initialized, return
   if (initialized) {
-    return;
+    return { errorClients };
   }
 
   logger.info("Starting to initialize MCP clients...");
+  errorClients = [];
 
+  const config = await getMcpConfig();
   // Initialize all clients, key is clientId, value is client config
-  for (const [clientId, config] of Object.entries(conf.mcpServers)) {
+  for (const [clientId, serverConfig] of Object.entries(config.mcpServers)) {
     try {
       logger.info(`Initializing MCP client: ${clientId}`);
-      const client = await createClient(config, clientId);
+      const client = await createClient(serverConfig as ServerConfig, clientId);
       const primitives = await listPrimitives(client);
-      clientsMap.set(clientId, { client, primitives });
+      clientsMap.set(clientId, { client, primitives, errorMsg: null });
       logger.success(
         `Client [${clientId}] initialized, ${primitives.length} primitives supported`,
       );
     } catch (error) {
       errorClients.push(clientId);
+      clientsMap.set(clientId, {
+        client: null,
+        primitives: [],
+        errorMsg: error instanceof Error ? error.message : String(error),
+      });
       logger.error(`Failed to initialize client ${clientId}: ${error}`);
     }
   }
@@ -58,8 +108,9 @@ export async function initializeMcpClients() {
   }
 
   const availableClients = await getAvailableClients();
-
   logger.info(`Available clients: ${availableClients.join(",")}`);
+
+  return { errorClients };
 }
 
 // Execute MCP request
@@ -87,9 +138,9 @@ export async function executeMcpAction(
 
 // Get all available client IDs
 export async function getAvailableClients() {
-  return Array.from(clientsMap.keys()).filter(
-    (clientId) => !errorClients.includes(clientId),
-  );
+  return Array.from(clientsMap.entries())
+    .filter(([_, data]) => data.errorMsg === null)
+    .map(([clientId]) => clientId);
 }
 
 // Get all primitives from all clients
@@ -104,3 +155,62 @@ export async function getAllPrimitives(): Promise<
     primitives,
   }));
 }
+
+// 获取客户端的 Primitives
+export async function getClientPrimitives(clientId: string) {
+  try {
+    const clientData = clientsMap.get(clientId);
+    if (!clientData) {
+      console.warn(`Client ${clientId} not found in map`);
+      return null;
+    }
+    if (clientData.errorMsg) {
+      console.warn(`Client ${clientId} has error: ${clientData.errorMsg}`);
+      return null;
+    }
+    return clientData.primitives;
+  } catch (error) {
+    console.error(`Failed to get primitives for client ${clientId}:`, error);
+    return null;
+  }
+}
+
+// 重启所有客户端
+export async function restartAllClients() {
+  logger.info("Restarting all MCP clients...");
+
+  // 清空状态
+  clientsMap.clear();
+  errorClients = [];
+  initialized = false;
+
+  // 重新初始化
+  await initializeMcpClients();
+
+  return {
+    success: errorClients.length === 0,
+    errorClients,
+  };
+}
+
+// 获取所有客户端状态
+export async function getAllClientStatus(): Promise<
+  Record<string, string | null>
+> {
+  const status: Record<string, string | null> = {};
+  for (const [clientId, data] of clientsMap.entries()) {
+    status[clientId] = data.errorMsg;
+  }
+  return status;
+}
+
+// 检查客户端状态
+export async function getClientErrors(): Promise<
+  Record<string, string | null>
+> {
+  const errors: Record<string, string | null> = {};
+  for (const [clientId, data] of clientsMap.entries()) {
+    errors[clientId] = data.errorMsg;
+  }
+  return errors;
+}

+ 20 - 4
app/mcp/mcp_config.json

@@ -8,13 +8,29 @@
         "/Users/kadxy/Desktop"
       ]
     },
-    "everything": {
-      "command": "npx",
-      "args": ["-y", "@modelcontextprotocol/server-everything"]
-    },
     "docker-mcp": {
       "command": "uvx",
       "args": ["docker-mcp"]
+    },
+    "difyworkflow": {
+      "command": "mcp-difyworkflow-server",
+      "args": ["-base-url", "23"],
+      "env": {
+        "DIFY_WORKFLOW_NAME": "23",
+        "DIFY_API_KEYS": "23"
+      }
+    },
+    "postgres": {
+      "command": "docker",
+      "args": ["run", "-i", "--rm", "mcp/postgres", null]
+    },
+    "playwright": {
+      "command": "npx",
+      "args": ["-y", "@executeautomation/playwright-mcp-server"]
+    },
+    "gdrive": {
+      "command": "npx",
+      "args": ["-y", "@modelcontextprotocol/server-gdrive"]
     }
   }
 }

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

@@ -0,0 +1,206 @@
+[
+  {
+    "id": "filesystem",
+    "name": "Filesystem",
+    "description": "Secure file operations with configurable access controls",
+    "command": "npx",
+    "baseArgs": ["-y", "@modelcontextprotocol/server-filesystem"],
+    "configurable": true,
+    "configSchema": {
+      "properties": {
+        "paths": {
+          "type": "array",
+          "description": "Allowed file system paths",
+          "required": true,
+          "minItems": 1
+        }
+      }
+    },
+    "argsMapping": {
+      "paths": {
+        "type": "spread",
+        "position": 2
+      }
+    }
+  },
+  {
+    "id": "github",
+    "name": "GitHub",
+    "description": "Repository management, file operations, and GitHub API integration",
+    "command": "npx",
+    "baseArgs": ["-y", "@modelcontextprotocol/server-github"],
+    "configurable": true,
+    "configSchema": {
+      "properties": {
+        "token": {
+          "type": "string",
+          "description": "GitHub Personal Access Token",
+          "required": true
+        }
+      }
+    },
+    "argsMapping": {
+      "token": {
+        "type": "env",
+        "key": "GITHUB_PERSONAL_ACCESS_TOKEN"
+      }
+    }
+  },
+  {
+    "id": "gdrive",
+    "name": "Google Drive",
+    "description": "File access and search capabilities for Google Drive",
+    "command": "npx",
+    "baseArgs": ["-y", "@modelcontextprotocol/server-gdrive"],
+    "configurable": false
+  },
+  {
+    "id": "playwright",
+    "name": "Playwright",
+    "description": "Browser automation and webscrapping with Playwright",
+    "command": "npx",
+    "baseArgs": ["-y", "@executeautomation/playwright-mcp-server"],
+    "configurable": false
+  },
+  {
+    "id": "mongodb",
+    "name": "MongoDB",
+    "description": "Direct interaction with MongoDB databases",
+    "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",
+    "description": "Tools to query and execute Dify workflows",
+    "command": "mcp-difyworkflow-server",
+    "baseArgs": ["-base-url"],
+    "configurable": true,
+    "configSchema": {
+      "properties": {
+        "baseUrl": {
+          "type": "string",
+          "description": "Dify API base URL",
+          "required": true
+        },
+        "workflowName": {
+          "type": "string",
+          "description": "Dify workflow name",
+          "required": true
+        },
+        "apiKeys": {
+          "type": "string",
+          "description": "Comma-separated Dify API keys",
+          "required": true
+        }
+      }
+    },
+    "argsMapping": {
+      "baseUrl": {
+        "type": "single",
+        "position": 1
+      },
+      "workflowName": {
+        "type": "env",
+        "key": "DIFY_WORKFLOW_NAME"
+      },
+      "apiKeys": {
+        "type": "env",
+        "key": "DIFY_API_KEYS"
+      }
+    }
+  },
+  {
+    "id": "postgres",
+    "name": "PostgreSQL",
+    "description": "Read-only database access with schema inspection",
+    "command": "docker",
+    "baseArgs": ["run", "-i", "--rm", "mcp/postgres"],
+    "configurable": true,
+    "configSchema": {
+      "properties": {
+        "connectionString": {
+          "type": "string",
+          "description": "PostgreSQL connection string",
+          "required": true
+        }
+      }
+    },
+    "argsMapping": {
+      "connectionString": {
+        "type": "single",
+        "position": 4
+      }
+    }
+  },
+  {
+    "id": "brave-search",
+    "name": "Brave Search",
+    "description": "Web and local search using Brave's Search API",
+    "command": "npx",
+    "baseArgs": ["-y", "@modelcontextprotocol/server-brave-search"],
+    "configurable": true,
+    "configSchema": {
+      "properties": {
+        "apiKey": {
+          "type": "string",
+          "description": "Brave Search API Key",
+          "required": true
+        }
+      }
+    },
+    "argsMapping": {
+      "apiKey": {
+        "type": "env",
+        "key": "BRAVE_API_KEY"
+      }
+    }
+  },
+  {
+    "id": "google-maps",
+    "name": "Google Maps",
+    "description": "Location services, directions, and place details",
+    "command": "npx",
+    "baseArgs": ["-y", "@modelcontextprotocol/server-google-maps"],
+    "configurable": true,
+    "configSchema": {
+      "properties": {
+        "apiKey": {
+          "type": "string",
+          "description": "Google Maps API Key",
+          "required": true
+        }
+      }
+    },
+    "argsMapping": {
+      "apiKey": {
+        "type": "env",
+        "key": "GOOGLE_MAPS_API_KEY"
+      }
+    }
+  },
+  {
+    "id": "docker-mcp",
+    "name": "Docker",
+    "description": "Run and manage docker containers, docker compose, and logs",
+    "command": "uvx",
+    "baseArgs": ["docker-mcp"],
+    "configurable": false
+  }
+]

+ 38 - 0
app/mcp/types.ts

@@ -59,3 +59,41 @@ export const McpNotificationsSchema: z.ZodType<McpNotifications> = z.object({
   method: z.string(),
   params: z.record(z.unknown()).optional(),
 });
+
+// MCP 服务器配置相关类型
+export interface ServerConfig {
+  command: string;
+  args: string[];
+  env?: Record<string, string>;
+}
+
+export interface McpConfig {
+  mcpServers: Record<string, ServerConfig>;
+}
+
+export interface ArgsMapping {
+  type: "spread" | "single" | "env";
+  position?: number;
+  key?: string;
+}
+
+export interface PresetServer {
+  id: string;
+  name: string;
+  description: string;
+  command: string;
+  baseArgs: string[];
+  configurable: boolean;
+  configSchema?: {
+    properties: Record<
+      string,
+      {
+        type: string;
+        description?: string;
+        required?: boolean;
+        minItems?: number;
+      }
+    >;
+  };
+  argsMapping?: Record<string, ArgsMapping>;
+}

+ 3 - 3
app/mcp/utils.ts

@@ -1,10 +1,10 @@
 export function isMcpJson(content: string) {
-  return content.match(/```json:mcp:(\w+)([\s\S]*?)```/);
+  return content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/);
 }
 
 export function extractMcpJson(content: string) {
-  const match = content.match(/```json:mcp:(\w+)([\s\S]*?)```/);
-  if (match) {
+  const match = content.match(/```json:mcp:([^{\s]+)([\s\S]*?)```/);
+  if (match && match.length === 3) {
     return { clientId: match[1], mcp: JSON.parse(match[2]) };
   }
   return null;

+ 0 - 1
next.config.mjs

@@ -32,7 +32,6 @@ const nextConfig = {
   },
   experimental: {
     forceSwcTransforms: true,
-    serverActions: true,
   },
 };
 

+ 4 - 9
yarn.lock

@@ -3076,15 +3076,10 @@ camelcase@^6.2.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
   integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
 
-caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001579:
-  version "1.0.30001617"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz#809bc25f3f5027ceb33142a7d6c40759d7a901eb"
-  integrity sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA==
-
-caniuse-lite@^1.0.30001646:
-  version "1.0.30001649"
-  resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001649.tgz#3ec700309ca0da2b0d3d5fb03c411b191761c992"
-  integrity sha512-fJegqZZ0ZX8HOWr6rcafGr72+xcgJKI9oWfDW5DrD7ExUtgZC7a7R7ZYmZqplh7XDocFdGeIFn7roAxhOeYrPQ==
+caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001646:
+  version "1.0.30001692"
+  resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz"
+  integrity sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==
 
 ccount@^2.0.0:
   version "2.0.1"